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:
| Watcher | Subpath | Entry type | Records |
|---|---|---|---|
| Request | @adonis-agora/telescope | request | Each inbound HTTP request — method, url, status, duration, and optionally the body. |
| Exception | @adonis-agora/telescope | exception | Any error thrown by the HTTP pipeline (auto), or recorded manually. |
| Diagnostics | @adonis-agora/telescope | diagnostic | Every agora:<lib>:<event> publish. |
| Logs | @adonis-agora/telescope or /watchers | log | Every line written through the AdonisJS logger. |
| Lucid query | @adonis-agora/telescope/watchers | query | Each db:query — SQL, bindings, duration, connection. |
@adonis-agora/telescope/watchers | mail | Each mail:sent — mailer, from, to, subject. | |
| Cache | @adonis-agora/telescope/watchers | cache | @adonisjs/cache hit / miss / write / delete / clear. |
| HTTP client | @adonis-agora/telescope/watchers | http-client | Each OUTBOUND call made through an instrumented fetch. |
| Queue | @adonis-agora/telescope/watchers | job | Each queue job execution. |
| Events | @adonis-agora/telescope/watchers | event | Every event emitted through the core emitter. |
| Redis | @adonis-agora/telescope/watchers | redis | Each Redis command. |
| Profiling | @adonis-agora/telescope/watchers | profile | A timed code section you wrapped with profile() / startProfile(). |
| Schedule | @adonis-agora/telescope/watchers | scheduled_task | One scheduled-task run you recorded with scheduleTask() / recordScheduledRun(). |
| Client errors | @adonis-agora/telescope | client_exception | A browser-reported error, ingested over HTTP. |
| CPU profiling | @adonis-agora/telescope/cpu_profiling | cpu_profile | A 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:
| Constant | type value | Recorded by |
|---|---|---|
Request | request | the request middleware |
Diagnostic | diagnostic | the diagnostics watcher |
Query | query | the Lucid query watcher |
Job | job | the queue watcher |
Exception | exception | the exception watcher, or recordException |
ClientException | client_exception | the client-error endpoint |
Mail | mail | the mail watcher |
Cache | cache | the cache watcher |
Redis | redis | the redis watcher |
Event | event | the events watcher |
Log | log | the logs watcher |
HttpClient | http-client | the http-client watcher |
Profile | profile | the profiling watcher's profile() helpers |
ScheduledTask | scheduled_task | the schedule watcher's helpers |
CpuProfile | cpu_profile | the 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 ofselect * 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:
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:
- subscribes to every channel already registered, and
- 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 iterateschannelsfor current names and adds tolistenersfor future ones, then subscribes via the Node builtin.Symbol.for('@agora/context:accessor')→ the request'straceId()(plustenantId(),userRef(),get()). The store readstraceId()at record time, and the request watcher falls back touserRef()for attribution when the host's auth guard exposes no synchronousctx.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:
ctx.auth.user— the@adonisjs/authconvention: a synchronous property the guard fills in once it has authenticated.userRef()from@adonis-agora/context— for hosts whose guard is asynchronous. A guard that only resolves throughawait getUser()has nothing onctx.auth.userat 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 undercontent.context, for detail you want to read on the entry but not filter by.user— for hosts where neitherctx.auth.usernoruserRef()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.
Getting Started
Install @adonis-agora/telescope, configure it into your AdonisJS app, read entries back from the headless API, then layer on persistent storage, per-technology watchers, and the dashboard.
Storage
The TelescopeStore contract every watcher records through and the query API reads from — the config-driven driver model (the in-memory ring buffer and the SQL-backed Lucid store), the EntryQuery filter model, retention and pruning.