@adonis-agora/telescope/alerts
Alerting subpath of @adonis-agora/telescope — detect brand-new exception families (and re-occurrences after resolve), fire on every exception (server + browser), and catch exception-rate / metric spikes from recorded entries, then fan rich alerts out to Slack, a generic webhook, the console, or any custom channel.
Capturing exceptions is half the job — you also want to know when a new one appears. The
@adonis-agora/telescope/alerts subpath polls the Telescope store for exception entries,
evaluates alerting rules over them, and dispatches a rich payload to every configured
channel: a new error family pages you, a spike pages you, and a re-occurrence after you
thought it was fixed pages you again.
Install
Alerting ships inside the one @adonis-agora/telescope package. Enable it when configuring
telescope:
npm i @adonis-agora/telescope
node ace configure @adonis-agora/telescope # then pick "Alerts" at the promptSelecting Alerts registers @adonis-agora/telescope/alerts_provider and publishes
config/telescope_alerts.ts.
How it hooks in
The alerter watches the same store everything else reads. Every every interval (default 30s)
it picks up the exception entries recorded since the last look and evaluates your rules
against them. Nothing is instrumented or intercepted — if an exception made it into Telescope,
the alerter sees it.
Two consequences worth knowing:
- Alerts are as late as the interval. A
30scadence means up to 30 seconds between the error and the page. Shorteneveryif that matters; the work per cycle is proportional to what was recorded, not to how often you ask. - Only what was recorded is alertable. Exceptions arrive automatically once the core's
request middleware is wired, and whenever you call
recordExceptionyourself. An exception that sampling dropped never reaches a rule.
Alerting starts from "now" at boot, so restarting your app never re-pages the whole backlog, and it never keeps the process alive on its own. A store failure during a cycle is logged and retried on the next one rather than thrown at your app.
When rules do and don't run
Two behaviours regularly surprise people:
Only the first rule of each exception type is evaluated. new-exception,
every-exception and exception-rate are each looked up once, by type — list two
new-exception rules and the second is silently ignored. Configure at most one of each and
express the variations through its window and threshold. (metric-threshold is the
exception: every one of those is evaluated, so a p99 rule and a cache-hit-rate rule happily
coexist.)
metric-threshold rules pause while Telescope is shedding load. When the
overload guard pauses capture
because the event loop is lagging, threshold evaluation stops too — so an already-firing alert
is neither re-raised nor spuriously auto-resolved on data Telescope wasn't recording. The
exception rules keep evaluating whatever did get through.
Configuration
import env from '#start/env'
import { defineConfig } from '@adonis-agora/telescope/alerts'
export default defineConfig({
channels: [
{ type: 'slack', url: env.get('TELESCOPE_SLACK_WEBHOOK') },
],
rules: [{ type: 'new-exception', window: '1h' }],
dashboardUrl: 'https://telescope.example.com/',
every: '30s',
cooldown: '15m',
})| Key | Default | Description |
|---|---|---|
enabled | true | Master switch. |
channels | [{ type: 'console' }] | Delivery destinations (specs or channel objects). |
rules | [{ type: 'new-exception', window: '1h' }] | Rules to evaluate. |
dashboardUrl | unset | External dashboard URL — Slack alerts deep-link to the entry. |
every | '30s' | Poll cadence (duration string). |
cooldown | '15m' | Per-rule / per-family re-notify suppression. |
instanceId | 'telescope' | Reporting instance id carried on every payload. |
geoLookup | unset | Host IP→geo resolver for exception alerts — see Geo-enrichment. |
Durations are normalized at boot and an unparseable one ('15min', a typo) throws —
fail-closed, so a bad window surfaces at startup rather than silently never firing. Valid
units: ms, s, m, h, d.
Rules
type AlertRule =
| { type: 'new-exception'; window: string }
| { type: 'every-exception'; window?: string } // window is OPTIONAL — display-only occurrence count
| { type: 'exception-rate'; window: string; threshold: number }
| {
type: 'metric-threshold'
metric: 'request-p95-ms' | 'request-p99-ms' | 'query-p95-ms' | 'query-p99-ms'
| 'cache-hit-rate' | 'exception-count'
window: string
comparator: 'gte' | 'lte'
threshold: number
minSamples?: number // guard a percentile page against a single slow request; default 1
}new-exception— fires the first time an exception'sfamilyHashis seen withinwindow(a genuinely new error family), and again if the family re-appears after the window elapses, or after an explicitalerter.resolveFamily(hash)— the "resolved → re-occurred" signal.every-exception— fires for every exception (serverexceptionand browser-reportedclient_exception), not just brand-new families — parity with a "notify on every error" setup. It is still rate-limited bycooldownper family, on an independent clock fromnew-exception, so a hot loop of the same error re-pages once per cooldown rather than on every single occurrence. Itswindowis optional and used only to count the occurrences shown on the alert (it does not gate firing); omit it and a 1h display window is assumed. Fired payloads carry the fullexceptioncontext (the same shapenew-exceptiongets), and a fresh vs repeat firing is badged New vs Recurring via theisNewflag (see the payload). Because it also fires onclient_exceptionentries, this is the rule to reach for when you want front-end errors ingested via client-error reporting to page you the same way server errors do.exception-rate— fires when>= thresholdexception entries land in the trailingwindow.metric-threshold— evaluates a computed metric over the window and fires when it crosses the threshold (comparator: 'gte' | 'lte'). The metrics are derived from the same windowed aggregation the Pulse / stats views use, so an alert means exactly what the dashboard shows — page onrequest-p99-ms >= 800(a slow endpoint),cache-hit-rate <= 0.8, or anexception-count >= Nspike.minSamples(default 1) guards a percentile or ratio rule against a single slow request on a quiet host — below that many samples in the window the rule holds its current state instead of deciding. It is deliberately ignored forexception-count, where a count of zero is a real, meaningful value that has to be able to auto-resolve a firing spike.
The exception rules are rate-limited by cooldown (per-rule for rate, per-family for
new-exception and every-exception — each on its own independent per-family clock, so
enabling both never double-suppresses).
For a "page me on every error" setup — including front-end client_exception reports —
reach for every-exception. It's happy alongside a metric-threshold spike rule:
import env from '#start/env'
import { defineConfig } from '@adonis-agora/telescope/alerts'
export default defineConfig({
channels: [{ type: 'slack', url: env.get('TELESCOPE_SLACK_WEBHOOK') }],
rules: [
// Fire on EVERY exception (server + browser), rate-limited per family by `cooldown`.
// `window` is display-only here — it just labels the occurrence count on the alert.
{ type: 'every-exception', window: '15m' },
// …and still page loudly on a broad exception spike.
{ type: 'metric-threshold', metric: 'exception-count', window: '5m', comparator: 'gte', threshold: 25 },
],
cooldown: '10m', // one page per error family per 10m, however hot the loop
})Tune cooldown deliberately with every-exception: it is the only thing keeping a
tight loop of the same error from paging on every occurrence. A short cooldown on a noisy
family is a self-inflicted alert storm; the per-family clock means a different family
still pages immediately.
Stateful raise / auto-resolve
metric-threshold rules are stateful, unlike the fire-and-cooldown exception rules. The
alerter re-evaluates each rule on the poll interval and tracks whether it is currently firing:
- crossing the threshold when not already firing → raise (dispatch
status: 'firing'); - no longer crossing while firing → auto-resolve (dispatch
status: 'resolved'); - a just-resolved rule is held down for
cooldownbefore it can re-raise (flap control).
So a Slack channel shows a spike and its recovery, and you are not re-paged every poll while
an incident is ongoing. The exception rules carry no status (⇒ 'firing') — they have no
resolve signal.
Deduplication
The new-exception rule's only state is a NewExceptionTracker — a bounded in-memory map
(default cap 10,000 families, oldest-evicted) of family → last-seen. It's per-process
by design: in a multi-replica deployment the same family is reported new once per replica, so
budget your cooldown accordingly. The window check discards stale last-seen times, so it
doubles as the re-occurrence detector.
Channels
Each fired alert fans out to every channel concurrently; one channel failing never blocks the others, and a per-channel failure is warn-logged (rate-limited by channel name) rather than thrown into the host.
| Channel | Sends |
|---|---|
{ type: 'slack', url, options? } | Block Kit JSON to a Slack incoming webhook — severity header with a New / Recurring badge, fielded context (route/method/status, UA, referer, duration, user, client IP, location, occurrences), a truncated stack, client_exception component-stack + extra blocks, and an "Open in Telescope" deep link when dashboardUrl is set. |
{ type: 'webhook', url } | The raw AlertPayload as JSON. |
{ type: 'console' } | A one-line summary (the zero-config default). |
customChannel(fn, name?) | An arbitrary async sink — email, PagerDuty, SNS, … |
HTTP channels use the global fetch, race every request against a 5s abort timeout, and
treat a non-2xx response as a failure.
import env from '#start/env'
import { defineConfig, slackChannel, customChannel } from '@adonis-agora/telescope/alerts'
export default defineConfig({
channels: [
slackChannel(env.get('TELESCOPE_SLACK_WEBHOOK'), { username: 'Telescope', iconEmoji: ':rotating_light:' }),
customChannel(async (alert) => { await pagerduty.trigger(alert) }, 'pagerduty'),
],
})The alert payload
Every channel receives the same AlertPayload:
interface AlertPayload {
rule: AlertRule
value: number // measured value that crossed the threshold (occurrences, count, or metric)
threshold: number // the rule threshold (1 for new-exception)
firedAt: string // ISO-8601
instanceId: string
status?: 'firing' | 'resolved' // present on stateful metric-threshold alerts; absent ⇒ firing
metric?: AlertMetric // present on metric-threshold alerts
exception?: { // present for new-exception + every-exception; absent for rate/metric rules
familyHash: string
class: string
message: string
stack: string | null
route: string | null // route/uri/url of the request (or the page url for a client_exception)
method: string | null // always null for a client_exception
statusCode: number | null
userAgent: string | null // reporting browser UA (client) or the sibling request's UA (server)
referer: string | null // `Referer` of the originating request; null for a client_exception
componentStack: string | null // React error-boundary stack — client_exception only
extra: Record<string, unknown> | null // host-defined debugging bag — client_exception only
client: boolean // true when the alert is a browser-reported client_exception
clientIp: string | null // originating IP; NEVER sourced from an untrusted body
geo: AlertGeoLocation | null // populated only when a geoLookup hook is configured (see below)
durationMs: number | null // request duration, when captured
user: string | null // from a `user:<id>` tag, when present
occurrences: number // times this family was seen in the window (>= 1)
isNew: boolean // occurrences === 1 — a first occurrence vs a recurrence
entryId: string // for the dashboard deep link
}
diagnosis?: { // AI probable-cause, on exception alerts when AI is configured
cause: string
fix: string
confidence: string
model: string
}
dashboardUrl?: string
}The exception context is read generically from the exception entry (class/name,
message, stack, route/uri/url, method, statusCode/status, userAgent,
referer/referrer, durationMs, and a user:<id> tag), so it works with any source of
exception entries. A browser-reported client_exception
additionally carries componentStack (the React error-boundary stack), a host-defined
extra bag, and the server-filled clientIp, with client: true and method/referer
left null; a server exception carries method/referer and its recorded request IP,
with componentStack/extra null and client: false.
New vs Recurring, and the fuller exception fields
Every fired exception alert carries isNew — true when this is the family's first
occurrence in the window (occurrences === 1), false on a recurrence. Channels badge the
two distinctly so on-call can triage urgency at a glance: the Slack channel appends
· 🆕 New or · 🔁 Recurring to the end of the header line. This matters most with the
every-exception rule, which pages on repeats as well as first sightings.
Beyond the core error fields, the Slack channel renders these enriched fields as context (each shown only when present, so a sparse alert stays compact):
| Field | Rendered as |
|---|---|
userAgent | a User agent context field |
referer | a Referer context field (server exceptions) |
durationMs | a Duration context field (… ms) |
clientIp | a Client IP context field |
geo | a Location context field — 🇺🇸 City, Region, Country (see Geo-enrichment) |
componentStack | a fenced Component stack: code block (client_exception only) |
extra | a fenced Extra: JSON block (client_exception only) |
isNew | a · 🆕 New / · 🔁 Recurring suffix on the header line |
Slack caps a section's fields array at 10 items, and a fully-enriched exception (instance,
observed, error, route, UA, referer, duration, user, client IP, location, occurrences)
exceeds that — the formatter automatically spreads the fields across multiple section blocks
so the message never trips Slack's invalid_blocks rejection. The componentStack and
extra blocks are clipped to Slack's per-section character budget. The raw webhook
channel carries all of these fields verbatim; the console channel logs a one-line
summary (class: message (route) — N× in window).
AI probable-cause on exception alerts
When AI diagnosis is installed, every alert a new-exception or
an every-exception rule fires is enriched with a diagnosis — the
coordinator
runs a time-bounded, fail-safe diagnosis of the offending exception and the alerter attaches a
compact { cause, fix, confidence, model } summary to the payload. The rate rules carry no
single offending entry, so they are never enriched. Slack renders it as a
"Probable cause (AI)" section; the raw webhook carries it verbatim. The diagnose hook is
guarded, so a diagnosis failure (or a slow model) never blocks or breaks the alert — the alert
simply goes out without the section. Omit AI and alerts behave exactly as before.
Geo-enrichment
An exception alert can be enriched with a coarse geo location resolved from the
originating client IP — so a Slack alert reads Location: 🇺🇸 San Francisco, California, United States next to the route and user. Geo is opt-in and dependency-light: the
library ships no geo database and no HTTP client. You provide a geoLookup hook and own the
lookup (and its caching / rate-limiting); telescope only calls it and renders the result.
import { defineConfig } from '@adonis-agora/telescope/alerts'
import { lookupCity } from '#services/geoip' // your MaxMind / ip-api / Cloudflare wrapper
export default defineConfig({
channels: [{ type: 'slack', url: env.get('TELESCOPE_SLACK_WEBHOOK') }],
rules: [{ type: 'new-exception', window: '1h' }],
// Called ONLY when an exception alert actually fires AND carries a clientIp.
geoLookup: async (ip) => {
const city = await lookupCity(ip) // your provider
if (!city) return null // null simply omits the Location field
return {
city: city.name,
region: city.region,
country: city.country,
countryCode: city.isoCode, // ISO 3166-1 alpha-2 → renders a flag emoji
}
},
})The hook resolves an IP to an AlertGeoLocation — every field optional, so a partial result
(country only) still renders:
interface AlertGeoLocation {
city?: string
region?: string
country?: string
countryCode?: string // ISO 3166-1 alpha-2 (e.g. 'US'), used to render a flag emoji
}
// Sync or async; returning null (or throwing — swallowed) omits the Location field.
type GeoLookup = (ip: string) => AlertGeoLocation | null | Promise<AlertGeoLocation | null>The result lands on the geo field of the alert's ExceptionAlertContext (and thus on the
AlertPayload.exception), alongside the clientIp it was resolved from:
interface ExceptionAlertContext {
// …familyHash, class, message, stack, route, method, statusCode, userAgent, referer,
// componentStack, extra, client, durationMs, user, occurrences, isNew, entryId…
clientIp: string | null // the originating IP; NEVER sourced from an untrusted body
geo: AlertGeoLocation | null // populated only when a geoLookup hook is configured AND clientIp is present
}Geo resolution runs only on a real fire — never during evaluation — and only when the
alert carries a clientIp, so the common no-fire path pays nothing. For a browser-reported
client_exception the IP is the one the ingestion
endpoint filled in server-side; for a server exception it's the recorded request IP. The hook
is fully guarded: a null return, a throw, or a slow provider simply omits the Location
field — it never blocks or breaks the alert. The library never caches or rate-limits the
hook, so add that inside it if your provider needs it.
The Slack channel renders geo as a Location context field (formatSlackMessage
composes 🇺🇸 City, Region, Country, skipping absent or duplicated parts); the raw
webhook channel carries the geo object verbatim.
Notable exports
- Channels:
slackChannel,webhookChannel,consoleChannel,customChannel; typesAlertChannel,ChannelFetch,ConsoleSink. formatSlackMessage; typesSlackMessage,SlackChannelOptions.Alerter,ExceptionPoller,NewExceptionTracker,DEFAULT_MAX_FAMILIES.AlerterService— the interval metric-threshold rule evaluator with raise/resolve; typesAlerterServiceDeps,MetricSource.durationToMs;defineConfig,resolveConfig,DEFAULT_RULES.- Types:
AlertRule,AlertMetric,AlertPayload,AlertDiagnosis,ExceptionAlertContext,ResolvedAlerts,ChannelSpec,ChannelConfig,TelescopeAlertsConfig. - Geo-enrichment types:
GeoLookup(the IP→location hook) andAlertGeoLocation(its result), both re-exported from@adonis-agora/telescope/alerts.
@adonis-agora/telescope-ui
The observability console SPA — a React single-page app with ten sections (overview, entries + live tail, traces & waterfall, Pulse, exception groups, live queues, live schedules, exports, CPU profiles, extension dashboards), a command palette and a light/dark theme, served by a thin AdonisJS provider under the same prefix and behind the same auth guard as the core JSON API it consumes.
@adonis-agora/telescope/ai
AI-assisted exception diagnosis subpath of @adonis-agora/telescope — turns an exception entry (plus its related trace entries) into a structured cause/fix/confidence diagnosis via the Anthropic Claude API, cached by exception family hash so the same error is never diagnosed twice.