Agora
Concepts

Capture & correlation

The mental model behind Telescope — what a watcher is, the uniform Entry shape every recording produces, the generic diagnostics spine that records all library events, exception auto-capture, and how trace correlation works without coupling to @adonis-agora/context.

Everything Telescope shows starts as a capture: a watcher observes something happening (a request finishing, a query running, a library publishing an event), builds a RecordInput, and hands it to the store. The store stamps it into an Entry. This page is the model that ties those pieces together.

Watchers

A watcher is anything that turns a runtime event into entries. Telescope ships its core watchers in @adonis-agora/telescope and more in the @adonis-agora/telescope/watchers subpath:

WatcherSubpathEntry typeRecords
Request@adonis-agora/telescoperequestEach inbound HTTP request — method, url, status, duration, and optionally the body.
Exception@adonis-agora/telescopeexceptionAny error thrown by the HTTP pipeline (auto), or recorded manually.
Diagnostics@adonis-agora/telescopediagnosticEvery agora:<lib>:<event> publish.
Logs@adonis-agora/telescope or /watcherslogEvery line written through the AdonisJS logger.
Lucid query@adonis-agora/telescope/watchersqueryEach db:query — SQL, bindings, duration, connection.
Mail@adonis-agora/telescope/watchersmailEach mail:sent — mailer, from, to, subject.
Cache@adonis-agora/telescope/watcherscache@adonisjs/cache hit / miss / write / delete / clear.
HTTP client@adonis-agora/telescope/watchershttp-clientEach OUTBOUND call made through an instrumented fetch.
Queue@adonis-agora/telescope/watchersjobEach queue job execution.
Events@adonis-agora/telescope/watcherseventEvery event emitted through the core emitter.
Redis@adonis-agora/telescope/watchersredisEach Redis command.
Profiling@adonis-agora/telescope/watchersprofileA timed code section you wrapped with profile() / startProfile().
Schedule@adonis-agora/telescope/watchersscheduled_taskOne scheduled-task run you recorded with scheduleTask() / recordScheduledRun().
Client errors@adonis-agora/telescopeclient_exceptionA browser-reported error, ingested over HTTP.
CPU profiling@adonis-agora/telescope/cpu_profilingcpu_profileA V8 sampling capture rendered as a flamegraph.

There is one more entry in @adonis-agora/telescope/watchers that isn't a watcher at all: queue-manager publishes the driver behind the dashboard's live queue console. It records nothing — it reads and acts on queues that already exist.

The built-in request watcher runs as server middleware; the diagnostics and logs watchers are started by a provider at boot; the per-technology watchers subscribe to the AdonisJS event emitter. They all share one rule:

A watcher must never break the thing it observes. Every recording path is fire-and-forget and fully guarded — a missing store, a throwing record, or a rejected promise is swallowed (and warn-logged), never thrown into the request, query, or send it is watching.

The Entry

Every capture, regardless of source, becomes the same shape — that uniformity is what lets one store, one query API, and one dashboard handle requests, queries, exceptions and arbitrary library events alike.

interface Entry<TContent = unknown> {
  id: string
  type: string                  // 'request' | 'diagnostic' | 'query' | 'exception' | ...
  familyHash: string | null     // stable grouping key — "the same kind of thing"
  content: TContent             // type-specific payload
  tags: string[]                // searchable labels, e.g. 'lib:billing', 'status:500'
  sequence: number              // monotonic record order within this process
  durationMs: number | null     // operation duration, when known
  origin: BatchOrigin           // 'http' | 'queue' | 'schedule' | 'cli' | 'manual'
  traceId: string | null        // active trace id at record time
  createdAt: Date
}

A watcher does not build a full Entry; it builds a RecordInput (just type, content, and optional familyHash / tags / durationMs / traceId / origin). The store fills in id, sequence, createdAt, and resolves traceId / origin from context when omitted.

The built-in EntryType constant names every recorded type:

Constanttype valueRecorded by
Requestrequestthe request middleware
Diagnosticdiagnosticthe diagnostics watcher
Queryquerythe Lucid query watcher
Jobjobthe queue watcher
Exceptionexceptionthe exception watcher, or recordException
ClientExceptionclient_exceptionthe client-error endpoint
Mailmailthe mail watcher
Cachecachethe cache watcher
Redisredisthe redis watcher
Eventeventthe events watcher
Loglogthe logs watcher
HttpClienthttp-clientthe http-client watcher
Profileprofilethe profiling watcher's profile() helpers
ScheduledTaskscheduled_taskthe schedule watcher's helpers
CpuProfilecpu_profilethe CPU profiling feature

Profile and CpuProfile are easy to confuse: the first is a timing span you instrumented by hand, the second is a real V8 sampling capture with a flamegraph.

Entry['type'] is a plain string, not a union over that list, so a custom watcher or an extension can record its own types without waiting for a release.

familyHash — grouping "the same thing"

familyHash is what makes topFamilies() and the dashboard's "busiest events" and exception groupings work. Two entries share a family when they are the same kind of thing:

  • Diagnostics group by lib:event (billing:invoice-paid).
  • Queries group by SQL template — the same query with literals replaced by ?, FNV-1a hashed, so every execution of select * from users where id = ? rolls up regardless of the id.
  • Exceptions group by name:message:topStackFrame — see below.

Exception capture

The request middleware does double duty: besides recording the request entry, it catches any error the downstream pipeline throws, records it as an exception entry, then re-throws the original error untouched. You get exception capture with zero app changes.

interface ExceptionEntryContent {
  name: string             // error class (TypeError, Error, ...)
  message: string
  stack: string | null
  method: string | null    // when recorded inside a request
  url: string | null
  traceId: string | null
}

The exception family hash deliberately includes the top stack frame:

exceptionFamilyHash({ name, message, stack })  // → `${name}:${message}:${topFrame}`

Why the top frame? name + message alone over-groups (the same generic message from unrelated call sites collapses into one family), while a host that embeds ids in messages under-groups. Pinning to the first frame is the pragmatic middle ground used by most error trackers — the same key the alerts new-exception dedup and the AI diagnoser cache rely on, so it must be deterministic across processes.

Manual capture for non-HTTP paths

Queue workers, ace commands, and your app/exceptions/handler.ts aren't wrapped by the middleware. Capture there with recordException — it reads the active store from the live store with nothing to inject, is a no-op when Telescope is off, and never throws:

app/exceptions/handler.ts
import { recordException } from '@adonis-agora/telescope'

async report(error: unknown, ctx: HttpContext) {
  recordException(error, { method: ctx.request.method(), url: ctx.request.url() })
  return super.report(error, ctx)
}

The diagnostics watcher

This is the keystone of the whole ecosystem. Rather than a bespoke watcher per library, one DiagnosticsWatcher records every event any @adonis-agora/* library emits through @adonis-agora/diagnostics — one diagnostic entry per agora:<lib>:<event> publish — so a library that adopts diagnostics appears in Telescope with no Telescope-specific code.

A recorded diagnostic preserves the producer's envelope verbatim:

interface DiagnosticEntryContent {
  v: number | null         // envelope schema version (null on a legacy envelope)
  lib: string              // 'billing'
  event: string            // 'invoice-paid'
  ts: number               // producer-stamped epoch millis
  traceId: string | null
  payload: unknown         // the library-defined payload, as-is
}

It is tagged lib:<lib>, event:<event>, and trace:<traceId> (when present), and its familyHash is lib:event — so topFamilies(10, 'diagnostic') answers "what are the busiest event kinds?".

How it subscribes to current and future channels

node:diagnostics_channel has no wildcard subscribe, so on start() the watcher:

  1. subscribes to every channel already registered, and
  2. registers a listener so any channel that appears later (a library's first emit) is subscribed too.

Subscribing also flips each producer's channel.hasSubscribers to true — which is exactly what makes @adonis-agora/diagnostics start building and publishing envelopes at all (zero overhead when nobody is listening).

@adonis-agora/billing  ──publish──▶  agora:billing:invoice-paid  (node:diagnostics_channel)


                              DiagnosticsWatcher.subscribe

                                        ▼  buildDiagnosticEntry()
                              TelescopeStore.record({ type: 'diagnostic', ... })

The subscriber runs synchronously inside node:diagnostics_channel, but the store is async. The watcher fires-and-forgets the record and swallows rejections — it can never block or break the emitting code path.

Cross-repo decoupling

Telescope is a separate repo and has no @adonis-agora/* dependencies — a hard dep would couple release cadences and can't be resolved across repos. Instead it reads two cross-copy-stable global slots structurally, defining the shapes locally (mirrored, never imported):

  • Symbol.for('@agora/diagnostics:registry'){ channels, listeners }. The diagnostics watcher iterates channels for current names and adds to listeners for future ones, then subscribes via the Node builtin.
  • Symbol.for('@agora/context:accessor') → the request's traceId() (plus tenantId(), userRef(), get()). The store reads traceId() at record time, and the request watcher falls back to userRef() for attribution when the host's auth guard exposes no synchronous ctx.auth.user (see below).

When those packages aren't installed, the readers return undefined: no trace correlation, no diagnostic entries — but the request watcher still works standalone.

Everything the diagnostics watcher captures here can ALSO be shipped as OTLP spans/logs to a self-hosted OTel Collector (→ Tempo/Loki/Grafana), turned on with one otel: { enabled: true } block — no per-library code. See OTel export.

Response status

A request entry's status is read from the response, and hosts disagree on how to expose it: AdonisJS has only getStatus() (the statusCode it wraps belongs to the Node ServerResponse, one level down), while Node/Express-style hosts have a statusCode property. The watcher tries getStatus() first and falls back to statusCode; a throwing accessor degrades to null rather than costing you the entry.

Before 0.12.0 only statusCode was read, so on AdonisJS every request entry recorded status: null — no status:<code> tag was ever emitted, and Pulse's requests.errorRate, which is derived from the 4xx/5xx breakdown, sat at 0% no matter how many requests were failing. If you are on an older version and your error rate looks suspiciously perfect, this is why.

User attribution

A request entry records the authenticated user as { id, email? } (never the full model) and tags it user:<id>. Two sources are tried, in order:

  1. ctx.auth.user — the @adonisjs/auth convention: a synchronous property the guard fills in once it has authenticated.
  2. userRef() from @adonis-agora/context — for hosts whose guard is asynchronous. A guard that only resolves through await getUser() has nothing on ctx.auth.user at record time, so those hosts publish the resolved reference into the request context instead and telescope reads it from there.

Without (2) an async-guard stack recorded user: null on every entry even for a fully authenticated session — the dashboard's User column and every user:<id> tag were silently empty. If you see that, check that @adonis-agora/context is installed and that something in your middleware chain sets the user reference on it.

Enriching a request entry

requestEnrichment on config/telescope.ts lets the app attach what only it knows to every request entry:

requestEnrichment: (ctx) => {
  // `header` is optional on the framework-agnostic ctx type — hence the `?.`
  const screen = ctx.request.header?.('x-screen')
  return typeof screen === 'string' ? { tags: [`screen:${screen}`] } : undefined
}

Three fields, all optional:

  • tags — how the dashboard filters, so this is where anything you want to slice by belongs: screen:…, tenant:…, feature-flag:…. Capped at 16 tags of 128 characters; blanks and non-strings are dropped.
  • context — free-form fields recorded under content.context, for detail you want to read on the entry but not filter by.
  • user — for hosts where neither ctx.auth.user nor userRef() applies. Wins over both.

The canonical use is correlating a front-end screen with the calls it makes: the browser sends its current page in a header (a beforeRequest hook on your HTTP client), the app turns it into a screen:<name> tag, and the entries list filters by it like any other tag.

The hook is synchronous by design. It runs on the recording path of every request, so an await here — a DB lookup for the user, say — would put host I/O between the response and the next request. Read what is already on the ctx. A throw is swallowed: enrichment is never a reason to lose an entry, let alone to break the request being observed.

Trace correlation

When @adonis-agora/context is present, every entry recorded inside a request carries the request's traceId, and producers stamp diagnostics with the same id. That is what makes byTrace(id) reconstruct the whole story:

// Every entry recorded during one request — the request, its queries,
// any diagnostics it published, and the exception that ended it.
await telescope.byTrace('abc123')

The store resolves traceId at record time: a watcher may pass it explicitly (the diagnostics watcher carries the producer-resolved id, since the producer knows its emitting context best), otherwise the store reads currentTraceId() from the context accessor. A null trace simply means "no active context" — common for CLI and queue work.

Capture is the foundation; Storage is where those entries live and how you query them back.

On this page