Client-error ingestion
A public, opt-in POST endpoint browsers report front-end errors to — recorded as client_exception entries through Telescope's normal redaction / sampling / prune pipeline — protected by a body byte cap, an in-memory per-IP token bucket, an optional authorize hook, and the overload guard's shed flag.
Telescope already captures every server-side exception. Client-error ingestion extends
that to the browser: a public POST endpoint your front end reports its own errors to, recorded
as client_exception entries that flow through the exact same pipeline as server exceptions —
family-hash grouping, the failed / client / user:<id> tags, redaction, sampling, pruning,
and the dashboard timeline.
It is part of the core, configured on config/telescope.ts.
Disabled by default. A public, unauthenticated ingestion surface is opt-in. Unlike a
controller that is always mounted and 404s while off, the AdonisJS provider only registers
the route when clientErrors.enabled is true — so a disabled endpoint genuinely does not
exist and a probe can't even tell it is wired.
Configuration
import { defineConfig } from '@adonis-agora/telescope'
export default defineConfig({
clientErrors: {
enabled: true,
path: '/telescope/client-errors',
maxBodyBytes: 32_768,
rateLimit: { perMinute: 60 },
// authorize: (ctx) => ctx.request.header('x-app-token') === process.env.CLIENT_ERR_TOKEN,
},
})| Key | Default | Description |
|---|---|---|
enabled | false | Master switch — no route is registered until this is true. |
path | /telescope/client-errors | Route path the POST endpoint mounts on. |
maxBodyBytes | 32_768 (32 KB) | Hard cap on the accepted body size; a larger body is rejected 413 before validation. |
rateLimit | { perMinute: 60 } | Per-IP token bucket; over the limit returns 429. |
authorize | unset | Optional gate run first; return false to reject 403 (a throw is a denial). |
The ingestion pipeline
POST <path> runs a short-circuiting pipeline; each stage rejects and stops:
authorizehook (if set) — runs first, before any work, so a host can require a session cookie or a shared header. A throw is treated as a denial (fail closed) →403.- Body byte cap — a body over
maxBodyBytesis rejected413before structural validation, so a hostile browser can't make telescope validate a huge payload. - Per-IP rate limit — the token bucket,
429when exhausted. - Structural validation —
400on a malformed body (no echo of the bad input). - Overload shed — while the overload guard
has paused ingestion, the entry is dropped so a lagging event loop is never made worse —
but still answers
204so the browser treats it as accepted and does not retry-storm. - Record — a
client_exceptionentry with a family hash fromname+message+ top stack frame (mirroring server exceptions) and the composing tags, then204 No Content.
The request body
Only message is required; every other field is optional, type-checked, and length-capped:
interface ClientExceptionContent {
message: string // required — the one field we insist on
name: string | null // error class/name (e.g. 'TypeError'); feeds the family hash
stack: string | null // JS stack; its top frame also feeds the family hash
componentStack: string | null // React component stack (from an error boundary)
url: string | null // page URL where the error happened
userAgent: string | null // reporting browser's UA
user: unknown // host-supplied identity → pivoted into a `user:<id>` tag
extra: Record<string, unknown> // free-form context; redacted + bounded like any content
}A minimal browser reporter:
window.addEventListener('error', (event) => {
navigator.sendBeacon(
'/telescope/client-errors',
JSON.stringify({
message: event.message,
name: event.error?.name ?? null,
stack: event.error?.stack ?? null,
url: location.href,
userAgent: navigator.userAgent,
}),
)
})The rate limiter
The per-IP token bucket is the only state the endpoint keeps, and it is deliberately bounded:
each IP gets a bucket of at most perMinute tokens refilling continuously, and the map is
capped (default 10,000 tracked IPs, oldest-evicted) so an attacker rotating source IPs can't
grow it without limit.
The limiter is per-process: in a multi-instance deployment the effective limit is
perMinute × instances. It is cheap abuse-dampening on a public surface, not a hard global
quota (a shared limiter would need a cross-process store). A misconfigured zero/negative rate
is clamped to "allow 1/min" so the endpoint never locks out every caller forever.
Where the entries show up
A recorded client_exception is a first-class error entry, not a side channel:
- Alerts — the poller reads it alongside server exceptions, so an
every-exceptionornew-exceptionrule pages on a front-end error exactly like a back-end one. - Metrics and Pulse — it counts in the exception groups and in the overview's recent
failures. Asking
getStatsfor either exception type returns both. - Dashboard — it appears in the entries list under its own
client_exceptiontype, and in the exception groups alongside server errors.
The grouped Exceptions view and the overview span both types, and a row deep-links into
the entries list filtered by that row's type — so clicking a browser error lands on
client_exception, not on an empty exception list.
Filtering the entries list by hand still means what it says: type: exception is server
exceptions only. Browser errors live under client_exception.
Who the error is attributed to
The recorded user comes from two places, in this order:
- The session, via the
@adonis-agora/contextuserRef()— resolved server-side on this very request, because the endpoint sits behind your normal middleware stack. - The request body, when the browser sent a
userand the context has nothing.
The order is a trust decision, not a preference. The endpoint is public: anything in the
body is a claim, and a caller could post someone else's id. The context is derived from
the session on the server, so it wins whenever both are present. The body claim is still
honoured for anonymous pages and for hosts without @adonis-agora/context, where a
self-reported id beats no attribution at all.
Until 0.13.0 the body was the only source. No front-end reporter ships the logged-in user by
default, so in practice every client_exception recorded user: null — the dashboard's
User column blank even on a fully authenticated session. If yours are still blank, check
that @adonis-agora/context is installed and that something in your middleware chain sets
the user reference on it.
Notable exports
Exported from the core (@adonis-agora/telescope):
ClientErrorIngestor— the framework-light handler; typesClientErrorHttpContext,ClientErrorRequest,ClientErrorResponse,ClientErrorIngestorDeps.ClientErrorRateLimiter,DEFAULT_MAX_TRACKED_IPS.validateClientErrorBody,userIdentityTag; typesClientErrorValidation,ClientExceptionContent.resolveClientErrors,storeRecorder; the defaultsDEFAULT_CLIENT_ERRORS_PATH,DEFAULT_MAX_BODY_BYTES,DEFAULT_RATE_LIMIT_PER_MINUTE; typesClientErrorsConfig,ResolvedClientErrorsConfig.
@adonis-agora/telescope/mcp
The Model Context Protocol subpath of @adonis-agora/telescope — a stateless JSON-RPC endpoint that lets a coding agent (Claude Code, Cursor, …) query the app's captured telemetry with six read tools (list entries, get entry, get trace, get waterfall, Pulse health, AI diagnose), behind the same auth guard as the dashboard.
Advanced / programmatic
The lower-level store-decorator and helper exports of @adonis-agora/telescope — bounded redaction (redactBounded / RedactingTelescopeStore / compileRedactSpec), tail-sampling (SamplingTelescopeStore / passesSampling / resolveSampling), and the live-stream bus (EntryEvents / StreamingTelescopeStore / streamEntries) — for wiring a custom store chain by hand.