Agora
Packages

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

config/telescope.ts
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,
  },
})
KeyDefaultDescription
enabledfalseMaster switch — no route is registered until this is true.
path/telescope/client-errorsRoute path the POST endpoint mounts on.
maxBodyBytes32_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.
authorizeunsetOptional 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:

  1. authorize hook (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.
  2. Body byte cap — a body over maxBodyBytes is rejected 413 before structural validation, so a hostile browser can't make telescope validate a huge payload.
  3. Per-IP rate limit — the token bucket, 429 when exhausted.
  4. Structural validation400 on a malformed body (no echo of the bad input).
  5. 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 204 so the browser treats it as accepted and does not retry-storm.
  6. Record — a client_exception entry with a family hash from name + message + top stack frame (mirroring server exceptions) and the composing tags, then 204 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-exception or new-exception rule 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 getStats for either exception type returns both.
  • Dashboard — it appears in the entries list under its own client_exception type, 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:

  1. The session, via the @adonis-agora/context userRef() — resolved server-side on this very request, because the endpoint sits behind your normal middleware stack.
  2. The request body, when the browser sent a user and 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; types ClientErrorHttpContext, ClientErrorRequest, ClientErrorResponse, ClientErrorIngestorDeps.
  • ClientErrorRateLimiter, DEFAULT_MAX_TRACKED_IPS.
  • validateClientErrorBody, userIdentityTag; types ClientErrorValidation, ClientExceptionContent.
  • resolveClientErrors, storeRecorder; the defaults DEFAULT_CLIENT_ERRORS_PATH, DEFAULT_MAX_BODY_BYTES, DEFAULT_RATE_LIMIT_PER_MINUTE; types ClientErrorsConfig, ResolvedClientErrorsConfig.

On this page