@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
268 lines • 12.5 kB
TypeScript
import type { AlertChannel } from './alert-channel.js';
/**
* A single alerting rule. Each rule is evaluated on every tick of the alerter,
* EXCEPT `new-exception` which evaluates per-flush over just-stored exception
* entries (so a brand-new error family pages you within a flush interval, not a
* full evaluation interval).
*
* - `exception-rate` — fires when `>= threshold` exception entries were
* recorded in the trailing `window`.
* - `slow-request-rate` — fires when `>= count` request entries slower than
* `thresholdMs` were recorded in the trailing `window`.
* - `dropped-entries` — fires when the Recorder's cumulative `droppedCount`
* grew by `>= threshold` since the previous evaluation
* (a delta, not an absolute — so a once-busy host that
* dropped a burst at boot doesn't keep re-firing).
* - `new-exception` — fires the FIRST time an exception's `familyHash` is
* seen within `window` (a genuinely NEW error family).
* Dedup uses the shared {@link StorageProvider} when it
* implements `markFamilySeen` (so a family pages ONCE
* across a multi-replica deployment), falling back to an
* in-memory per-replica seen-map otherwise.
* - `every-exception` — fires for EVERY exception (server + browser-reported
* `client_exception`), not just brand-new families —
* parity with a "notify on every error" setup. Still
* rate-limited by the shared `cooldown` PER FAMILY, so a
* hot loop of the same error re-pages once per cooldown
* rather than on every occurrence; set `cooldown: '0s'`
* for a truly uncollapsed stream. The optional `window`
* is used only to count occurrences shown on the alert
* (default `'1h'`); it does NOT gate firing.
* - `metric-threshold` — fires when a COMPUTED metric over the trailing
* `window` crosses `threshold` in the `comparator`
* direction. Unlike the rate rules (which count events),
* this evaluates the SAME aggregates the dashboard shows
* — e.g. request p95 latency, or cache hit-rate — so you
* can page on "p95 > 800ms" or "hit-rate < 0.8".
*/
export type AlertRule = {
type: 'exception-rate';
window: string;
threshold: number;
} | {
type: 'slow-request-rate';
window: string;
thresholdMs: number;
count: number;
} | {
type: 'dropped-entries';
threshold: number;
} | {
type: 'new-exception';
window: string;
} | {
type: 'every-exception';
window?: string;
} | {
type: 'metric-threshold';
/** The computed metric to evaluate (see {@link AlertMetric}). */
metric: AlertMetric;
/** Trailing window to aggregate over (e.g. `'5m'`). */
window: string;
/** `gte` fires when value >= threshold; `lte` when value <= threshold. */
comparator: 'gte' | 'lte';
/** The threshold the metric is compared against. */
threshold: number;
/**
* Minimum samples in the window before the rule can fire. Guards against a
* single slow request tripping a p95 page on a quiet host. Default 1.
*/
minSamples?: number;
};
/**
* The metrics a `metric-threshold` rule can evaluate. Each is derived from the
* SAME windowed aggregation the stats/pulse views use, so an alert means exactly
* what the dashboard shows. Latency metrics are in ms; `cache-hit-rate` is a
* ratio in [0, 1].
*/
export type AlertMetric = 'request-p95-ms' | 'request-p99-ms' | 'query-p95-ms' | 'query-p99-ms' | 'cache-hit-rate';
/**
* A coarse geo location resolved from a client IP, attached to an exception alert
* when a {@link AlertsOptions.geoLookup} hook is configured. Every field is
* optional — a partial result (e.g. country only) still renders. Deliberately
* dependency-light: the LIB ships no geo database or HTTP client; the host owns
* the lookup (and its caching/rate-limiting) and returns this shape.
*/
export interface AlertGeoLocation {
city?: string;
region?: string;
country?: string;
/** ISO 3166-1 alpha-2 (e.g. `US`), used to render a flag emoji. */
countryCode?: string;
}
/**
* Host-supplied resolver from a client IP to a coarse {@link AlertGeoLocation}.
* Called ONLY when an exception alert actually fires and carries a `clientIp`, so
* the common no-fire path pays nothing. May be sync or async; returning `null`
* (or throwing — swallowed) simply omits the Location field. The lib never caches
* or rate-limits it — do that in the hook if the provider needs it.
*/
export type GeoLookup = (ip: string) => AlertGeoLocation | null | Promise<AlertGeoLocation | null>;
/**
* Pluggable alerting (v2). When set, {@link TelescopeAlerter} evaluates `rules`
* and fans each fired alert out to EVERY configured channel concurrently. A
* configured `alerts` with no destination (neither `channels` nor the legacy
* `webhookUrl`) or empty `rules` is a fail-closed boot error.
*/
export interface AlertsOptions {
/**
* Optional IP→geo resolver. When set, a firing exception alert that carries a
* `clientIp` is enriched with a coarse {@link AlertGeoLocation} (rendered as a
* "Location" field by channels that support it). Kept out of the lib core so
* telescope ships no geo dependency — see {@link GeoLookup}.
*/
geoLookup?: GeoLookup;
/**
* Delivery destinations. Each fired alert is sent to every channel
* concurrently; one channel failing never blocks the others. Use the factory
* helpers: `slackChannel(url)`, `webhookChannel(url)`, `customChannel(fn)`.
* Optional ONLY for backward compatibility with `webhookUrl` — supply at least
* one of `channels` / `webhookUrl`.
*/
channels?: AlertChannel[];
/**
* Legacy single raw-JSON webhook (v1). Still accepted: internally rewritten
* into a `webhookChannel(webhookUrl)` and appended to `channels`. Prefer
* `channels` for new configs.
*/
webhookUrl?: string;
/**
* The host's EXTERNAL Telescope dashboard URL (e.g.
* `https://telescope.example.com/telescope/`). When set, channels that support
* deep links (Slack) build a link straight to the offending entry, e.g.
* `${dashboardUrl}#/entries/exception/${id}`. Optional; without it the Slack
* message simply omits the "Open in Telescope" button.
*/
dashboardUrl?: string;
/**
* Evaluation cadence as a duration string (e.g. `'1m'`). Default `'1m'`.
* Resolved to ms via `durationToMs`. (Interval rules only; `new-exception`
* evaluates per-flush.)
*/
every?: string;
/** Re-notify suppression per rule, as a duration string. Default `'15m'`. */
cooldown?: string;
/** Rules to evaluate. Must be non-empty when `alerts` is set. */
rules: AlertRule[];
}
/** Boot-resolved, validated alerting config (durations normalized to ms). */
export interface ResolvedAlerts {
/** All destinations, with any legacy `webhookUrl` already folded in. */
channels: AlertChannel[];
/** External dashboard URL for deep links, or `null` when unset. */
dashboardUrl: string | null;
intervalMs: number;
cooldownMs: number;
rules: AlertRule[];
/** Host IP→geo resolver, or `null` when unconfigured. */
geoLookup: GeoLookup | null;
}
/**
* Rich exception context attached to a `new-exception` alert. Pulled from the
* exception entry AND its sibling request entry in the SAME batch (queried by
* `batchId` only when the rule actually fires, so the common no-fire path stays
* a single in-memory map lookup). Absent on rate-rule alerts.
*/
export interface ExceptionAlertContext {
/** Stable family hash that was first-seen this window. */
familyHash: string;
/** Exception class name (e.g. `TypeError`). */
class: string;
/** Exception message. */
message: string;
/** Truncated stack (first frames), or `null`. */
stack: string | null;
/**
* Where the error happened: the sibling request's route/uri for a server
* exception, or the browser page `url` for a `client_exception`. `null` when
* neither is available.
*/
route: string | null;
/** Request method, or `null` (always `null` for a client_exception). */
method: string | null;
/**
* User-agent string. For a `client_exception` it's the reporting browser's UA;
* for a server exception it's the sibling request's `user-agent` header (when
* captured). `null` when unavailable.
*/
userAgent: string | null;
/**
* `Referer` header of the originating request (server exception) — the page a
* user came from. `null` when absent or for a `client_exception` (the browser
* report carries its own page `url` in `route`, not a referer).
*/
referer: string | null;
/**
* React component stack from an error boundary — present ONLY for a
* `client_exception` that supplied one. `null` otherwise.
*/
componentStack: string | null;
/**
* Host-defined free-form debugging bag from a `client_exception` (`extra`), or
* `null`. Redacted/bounded at record time like any other content.
*/
extra: Record<string, unknown> | null;
/** True when this alert is a browser-reported `client_exception`. */
client: boolean;
/**
* Originating client IP, or `null` when unknown. For a `client_exception` it's
* the browser IP the ingestion controller filled in server-side (`clientIp`);
* for a server exception it's the sibling request entry's `ip` (`request.ip` /
* the first `x-forwarded-for` hop). Never sourced from an untrusted body.
*/
clientIp: string | null;
/**
* Coarse geo location resolved from {@link clientIp}, or `null`. Populated only
* when an {@link AlertsOptions.geoLookup} hook is configured AND the alert has a
* `clientIp`; otherwise `null`.
*/
geo: AlertGeoLocation | null;
/** Response status code, or `null`. */
statusCode: number | null;
/** Request duration (ms), or `null`. */
durationMs: number | null;
/** Authenticated user id from a `user:<id>` tag, or `null`. */
user: string | null;
/** Times this family was seen in the window (>= 1; 1 on first-occurrence). */
occurrences: number;
/**
* True when this is the family's first occurrence in the window (`occurrences
* === 1`) — a brand-new error rather than a recurrence. Lets channels badge a
* new error distinctly from a recurring one.
*/
isNew: boolean;
/** Exception entry id (for the dashboard deep link). */
entryId: string;
/** Batch id that ties the exception to its request entry. */
batchId: string;
}
/**
* Shape delivered to channels when a rule fires. BACKWARD COMPATIBLE with the v1
* raw-webhook JSON: every v1 field (`rule`, `value`, `threshold`, `firedAt`,
* `instanceId`) is unchanged; new fields are purely additive and optional.
*/
export interface AlertPayload {
rule: AlertRule;
/** The measured value that crossed the threshold. */
value: number;
/** The rule's threshold (`threshold`/`count`, or `1` for `new-exception`). */
threshold: number;
/** ISO-8601 fire time. */
firedAt: string;
/** The reporting instance (`config.instanceId`). */
instanceId: string;
/** Rich context for `new-exception` alerts; absent for rate rules. */
exception?: ExceptionAlertContext;
/** External dashboard URL when configured (lets channels build deep links). */
dashboardUrl?: string;
/**
* AI-generated probable-cause markdown for a `new-exception` alert, attached
* when `ai` is configured in `'auto'` mode AND the diagnosis finished within
* the alert's short grace window. Absent when AI is off, the diagnosis was
* still running at dispatch time, or it failed — the alert always fires
* regardless, AI is purely additive. Channels that render it (Slack) append a
* "Probable cause (AI)" section.
*/
diagnosis?: string;
}
//# sourceMappingURL=alert-rule.d.ts.map