@adonis-agora/telescope/watchers
The watchers subpath of @adonis-agora/telescope — record every Lucid SQL query (sql, bindings, duration, connection), every email sent, @adonisjs/cache hit/miss/write/delete events, outbound fetch calls, and AdonisJS logger output, each correlated to the active request trace.
The core records requests, exceptions and diagnostics. The @adonis-agora/telescope/watchers
subpath adds the watchers that hook into specific AdonisJS subsystems through the event
emitter — most importantly a Lucid query watcher that records every SQL statement, plus
mail and cache watchers. Each records into the same store as everything else and is
fully guarded.
Install
Everything lives in the one @adonis-agora/telescope package — there is nothing extra to install.
Enable the feature when configuring telescope:
npm i @adonis-agora/telescope
node ace configure @adonis-agora/telescope # then pick "Watchers" at the promptSelecting Watchers registers @adonis-agora/telescope/watchers_provider in adonisrc.ts and
publishes config/telescope_watchers.ts. No middleware is registered — the provider
resolves the application emitter at boot and subscribes each enabled watcher to it.
Configuration
import { defineConfig } from '@adonis-agora/telescope/watchers'
export default defineConfig({
enabled: true,
watchers: ['query'], // default: the verified Lucid query watcher only
query: { slowMs: 500, captureBindings: false, normalize: true },
httpClient: { slowMs: 1000, ignoreHosts: [] },
profiling: { slowMs: 100 },
schedule: { slowMs: 1000 },
queueManager: { queues: ['default', 'emails'] },
})| Key | Default | Description |
|---|---|---|
enabled | true | Master switch; false starts no per-technology watcher. |
watchers | ['query'] | Which watchers run — any of 'query', 'mail', 'cache', 'http-client', 'logs', 'queue', 'events', 'redis', 'profiling', 'schedule', 'queue-manager'. |
query | see below | Lucid query watcher tuning — slowMs (500), captureBindings (false), ignoreConnections, normalize (true). |
httpClient | see below | http-client watcher tuning — slowMs (1000), ignoreHosts, captureBodies (false). |
profiling | see below | profiling watcher tuning — slowMs (100), minDurationMs (0). |
schedule | see below | schedule watcher tuning — slowMs (1000). |
queueManager | see below | Live Queue Manager config — queues (string[], required to get anything), adapter (optional adapter name). |
Only the Lucid query watcher is on by default — it's the one whose event contract is
verified against the installed @adonisjs/lucid types. Everything else is opt-in.
The provider also accepts the watchers config under a watchers_config key on the main
config/telescope.ts, falling back to defaults — but a dedicated
config/telescope_watchers.ts is the published, recommended path.
Query watcher
The headline watcher. It subscribes to Lucid's db:query event (the same one
db.prettyPrint consumes) and records each SQL statement as a query entry, correlated
to the active request trace.
interface QueryEntryContent {
sql: string
bindings: unknown[]
durationMs: number | null // from Lucid's process.hrtime() tuple
connection: string // e.g. 'primary'
method: string | null // 'select', 'insert', ...
inTransaction: boolean | null
traceId: string | null
}Tags: connection:<name>, method:<method>, model:<model> when Lucid reports them, and
slow when the query is at/above query.slowMs (default 500ms — this feeds the Pulse
slow-query card). The familyHash is a queryFamilyHash(sql) — the SQL normalized to a
template (literals replaced by ?, whitespace collapsed) and FNV-1a hashed — so every
execution of select * from users where id = ? rolls up into one family regardless of the id.
Set query.normalize: false to hash the raw SQL verbatim (no grouping across literals), and
query.ignoreConnections to drop specific connections (e.g. the telescope store's own).
Bindings routinely carry PII / secrets (emails, tokens, password hashes), so by default they
are redacted to [REDACTED] placeholders (arity preserved) and only the normalized SQL
template is kept. Set query.captureBindings: true to record the real bound values.
Lucid only emits db:query when the connection's debug flag is on or a db:query
listener exists at query-report time. Subscribing this watcher is enough to make Lucid
report; setting debug: true in your config/database.ts connection guarantees it.
Mail watcher
Records every email sent via @adonisjs/mail's mail:sent event.
interface MailEntryContent {
mailer: string | null // e.g. 'smtp'
from: string | null
to: string[]
subject: string | null
traceId: string | null
}Tagged mailer:<name>; familyHash is the mailer name. It reads the composed message
defensively — @adonisjs/mail carries it as a Message instance (toJSON() → envelope)
or a plain object, and the watcher handles both, degrading to null/[] for anything
missing.
@adonisjs/mail is not installed in this repository, so the mail:sent payload shape
could not be verified against its types. The watcher reads every field defensively, but
treat the recorded shape as best-effort and verify against your installed mail version.
Cache watcher
Records @adonisjs/cache activity — one cache entry per hit / miss / write / delete /
clear event.
interface CacheEntryContent {
operation: 'hit' | 'miss' | 'write' | 'delete' | 'clear'
key: string | null
store: string | null // e.g. 'redis'
traceId: string | null
}Tagged cache:<operation> and store:<store>; familyHash is operation:key (or just
operation when no key). The default event-name → operation map is exported as
CACHE_EVENTS (cache:hit, cache:miss, cache:written, cache:deleted,
cache:cleared).
@adonisjs/cache is not installed in this repository, so these event names and payloads
could not be verified against its types. They follow the documented cache:* convention
but are best-effort. If a future version diverges, pass a custom map to the
CacheWatcher constructor.
HTTP-client watcher
Records every outbound HTTP call as an http-client entry by wrapping the Node global
fetch. Each call is timed; method, sanitized url, host, status and duration are captured
and correlated to the active request trace.
interface HttpClientEntryContent {
method: string // upper-cased, e.g. 'GET'
url: string // userinfo stripped, sensitive query values masked
host: string | null // e.g. 'api.stripe.com', null for a relative url
statusCode: number | null // null on a network (transport) failure
durationMs: number
traceId: string | null
}Tagged http-client, host:<host>, failed (5xx or network error — a 4xx is a valid
response, not a transport failure) and slow (≥ slowMs, default 1000ms). The familyHash
is the method + host + id-normalized path (e.g. GET api.stripe.com/v1/charges/:id), so
calls to the same endpoint group regardless of ids.
It never alters the host's call: it awaits the real fetch, records fire-and-forget, and
always re-throws a network error. The wrapper is idempotent process-wide and stop()
restores the original fetch on shutdown. Configure it via httpClient.slowMs (the slow
threshold), httpClient.ignoreHosts (exact, case-insensitive hosts whose calls are not
recorded), and httpClient.captureBodies (record request/response body sizes in bytes —
never the bytes — default false). The exported instrumentFetch(fetch, options?) /
markInternalFetch helpers let you wrap a custom fetch instance by hand.
The captured url is sanitized before storage — userinfo (user:pass@) is stripped and
sensitive query-param values (token, secret, api_key, …) are masked. URL string
leaves are out of reach of the central key-based redaction, so the watcher scrubs them
itself; everything else in content still passes through the central redaction layer.
Telescope's own internal outbound fetches (marked via markInternalFetch) are skipped, so
the watcher never records — or recurses on — itself.
Logs watcher
Records AdonisJS logger output as log entries, correlated to the active request trace.
interface LogEntryContent {
level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
message: string | null // null when the call carried only an object
context: Record<string, unknown> | null // bounded copy of the merging object's fields
traceId: string | null
}Tagged log and log:<level>; familyHash is log:<level>. The watcher reads pino's
argument convention — (message, …) or (mergingObject, message?, …) — capturing the
message string and a bounded slice (first 50 keys) of the merging object. Structured fields
pass through the central redaction layer, so sensitive values (password, token, …) are
masked. A minLevel option drops anything below the given level.
Rather than monkey-patching a global or a prototype, the watcher tees the level methods on
the one logger instance resolved from the container (@adonisjs/logger's Logger): each
teed method calls the original first (host output is untouched) then records. stop()
restores the originals, so the tap fully unwinds on shutdown.
Why instance-scoped teeing and not a pino transport: pino has no supported post-construction hook/transport for a live instance (transports/hooks are construction options), and telescope does not own the logger's construction. Teeing the resolved instance is the least-invasive option that captures logs written through the app logger, and it's fully reversible. A reentrancy guard prevents an infinite record→log→record loop.
Queue watcher
Records every @adonisjs/queue job execution as a job entry — the queue, job name,
payload, outcome, attempts and duration, correlated to the active trace. Nothing here imports
@adonisjs/queue, so with the package absent this watcher is a pure no-op.
interface JobEntryContent {
id: string | null // the job id, when the engine carried one
name: string | null // the job class, e.g. 'SendWelcomeEmail'
queue: string | null // the queue it ran on, e.g. 'default'
payload: unknown // the job payload (redaction applies downstream)
status: 'completed' | 'failed' | 'retrying'
attempts: number | null // how many attempts had been made
failureReason: string | null // set when status is 'failed' / 'retrying'
traceId: string | null
}Tagged queue, status:<status>, queue:<queue>, job:<name>, plus failed when the
attempt failed/retried and slow when the run is at/above the slow threshold (default
1000ms). The familyHash is <queue>:<name>, so every run of the same job on the same
queue rolls up into one family — feeding the slow-hotspot metrics cards.
How it hooks in
AdonisJS's queue engine is @boringnode/queue, which
publishes a node:diagnostics_channel tracing channel per execution attempt — the
channel name is exported as QUEUE_EXECUTE_CHANNEL ('boringqueue.job.execute'). A tracing
channel fires :start / :end / :asyncStart / :asyncEnd / :error sub-channels around
the traced async operation; the watcher subscribes to :asyncEnd, fired once the execution
settles with status / duration / error populated, and records one entry per attempt.
Subscribing is what flips the channel's hasSubscribers on — with no publisher (the peer
absent) nothing is recorded. Recording is fire-and-forget and fully guarded: a telescope
failure can never break or block a job.
import { buildJobEntry, QUEUE_EXECUTE_CHANNEL, type JobEntryContent } from '@adonis-agora/telescope/watchers'
// The exported `buildJobEntry(message, slowMs?)` maps a raw tracing message to the
// telescope RecordInput — exposed so the entry shape can be asserted in a unit test
// without a real diagnostics publish.@adonisjs/queue (and its @boringnode/queue engine) is not installed in this repository,
so the channel name and payload shape are sourced from the engine's tracing_channels.ts
rather than verified against installed types. The engine is pre-1.0, so this surface can
drift; the watcher reads every field defensively and no-ops entirely when nobody publishes.
Live Queue Manager
A live control surface for @adonisjs/queue — list configured queues (with a pending
count), look up a job by id, retry it, and enqueue a new one — surfaced in the dashboard's
Queues section. This is a genuinely different capability from the queue watcher above: the
watcher records past job executions as entries; the Live Queue Manager reaches into the
live queue to inspect/act on it right now. Enable it by adding 'queue-manager' to
watchers and setting queueManager.queues:
export default defineConfig({
watchers: ['query', 'queue-manager'],
queueManager: { queues: ['default', 'emails'] },
})This is a smaller console than its BullMQ-backed NestJS sibling, on purpose.
@adonisjs/queue's engine is @boringnode/queue, whose
public Adapter interface (checked across every published version, 0.5.0 through the current
0.7.1) supports far less than BullMQ:
- Supported: a single job lookup by id (
getJob), retrying a failed job by id (retryJob), a pending-only count per queue (sizeOf), and enqueueing a new job (push/pushOn). - NOT supported by the engine at all — not faked here: listing/paginating jobs BY STATE
(there is no
getJobs/listJobs/findByStatus), removing a job, promoting a delayed job, per-state counts (active/delayed/failed/completed), or enumerating queue names (they are just strings passed at dispatch time — there is no registry to discover, which is whyqueueManager.queuesmust be configured explicitly).
The dashboard's Queues section reflects this honestly: a queue's active/delayed/failed/
completed counts render as — (unknown, not zero), and there is no per-state job table —
only "look up a job by id". The API advertises exactly what it can do via a capabilities
array (GET <path>/api/queues/live → { queues, capabilities: { actions } }), computed from
what the resolved @boringnode/queue adapter actually exposes, not guessed.
Mutations (retry, enqueue) are disabled by default — they act on real jobs. Enable
them in config/telescope_ui.ts:
export default defineConfig({
queueActions: { enabled: true },
})@boringnode/queue's top-level queue service is resolved from the container's 'queue'
binding, and this driver calls .use(adapterName?) on it to get a concrete adapter — the
standard AdonisJS "Manager" convention (db.connection(), mail.use(), …). That specific
method was not directly confirmed against @boringnode/queue's public .d.ts (unlike
getJob/retryJob/sizeOf/push/pushOn, which were); if your queue service doesn't
expose .use(), the driver falls back to treating the service itself as the adapter, and
degrades to "not configured" if neither shape matches.
Events watcher
Records every event emitted through the core @adonisjs/core Emitter as an event
entry — the event name and its payload, correlated to the active trace. One wildcard
listener (emitter.onAny(...)) captures the lot.
interface EventEntryContent {
name: string // the emitted event name, or the class name for class-based events
payload: unknown // the single emitted payload (redaction applies downstream)
traceId: string | null
}Tagged event and event:<name>; familyHash is event:<name>, so every emit of the same
event groups. A class-based event is coerced to its constructor name. The watcher degrades
gracefully — if the resolved emitter has no onAny, start() is a silent no-op rather than
a throw — and recording is fire-and-forget and fully guarded, so it can never break an emit.
The default ignore-list
Some Adonis surfaces already have a dedicated telescope watcher, so capturing their events
here would double-record. The watcher therefore ships an ignore-list, exported as
DEFAULT_IGNORED_EVENTS:
import { DEFAULT_IGNORED_EVENTS } from '@adonis-agora/telescope/watchers'
// ['db:query', 'mail:sent'] — the query and mail watchers already record these.Names in the set (matched against the resolved event name) are skipped. To watch a different
set, construct the watcher yourself with an explicit list — new EventsWatcher(['user:*ignored*'])
— or pass [] to capture everything (including the events other watchers already cover).
The onAny contract is mirrored structurally (an EmitterAnyLike with an onAny returning
an unsubscribe function) rather than imported, so the watcher carries no build-time coupling
to a specific @adonisjs/events / emittery version and stays unit-testable with a plain
double.
Redis watcher
Records every Redis command issued through @adonisjs/redis as a redis entry — the
command, its arguments, the connection and the round-trip duration, correlated to the active
trace. Like the queue watcher it never imports the package it observes, so with @adonisjs/redis
absent it no-ops.
interface RedisEntryContent {
command: string // upper-cased, e.g. 'GET'
args: unknown[] // the command arguments, in order (redaction applies downstream)
connection: string | null // the connection name, e.g. 'main'
durationMs: number | null // round-trip ms, or null when not awaitable
traceId: string | null
}Tagged redis, redis:<command> and connection:<name>; familyHash is redis:<command>,
so every GET (etc.) groups into one family.
How it hooks in
@adonisjs/redis exposes the raw ioredis client on each
connection().ioConnection, and ioredis funnels every command through
sendCommand(command) — so wrapping that single method captures everything, including
pipelined and multi commands. The watcher is constructed with the resolved @adonisjs/redis
manager: at start() it instruments every already-active connection and arms the manager's
'connection' event so connections created later are instrumented too. Patching is
per-client and idempotent (branded with a shared Symbol.for, so two package copies never
double-wrap), the original sendCommand is always called and its result returned/thrown
unchanged, and stop() restores every original.
import { buildRedisEntry, type RedisEntryContent } from '@adonis-agora/telescope/watchers'
// buildRedisEntry(command, connection, durationMs) maps a captured command to a
// RecordInput — exported for unit tests without a live ioredis client.The watcher records exactly what each wrapped client does — including telescope's own
storage commands if the lucid/redis store shares that
connection. Give telescope storage a dedicated Redis connection to keep its bookkeeping out
of the timeline.
@adonisjs/redis is not installed in this repository, so its connection surface
(ioConnection / ioSubscriberConnection / activeConnections) is sourced from its types
rather than verified. The watcher narrows every access defensively and no-ops on a null
manager.
Profiling watcher
Records user-instrumented timing spans as profile entries. There is no event to tap, so
this watcher is driven by two opt-in helpers you call around a code section — nothing is
captured until the watcher is enabled and a helper is called.
import { profile, startProfile } from '@adonis-agora/telescope/watchers'
// Scoped: times `fn`, records completed/failed, re-throws on error.
const total = await profile('checkout', async (span) => {
const cart = await loadCart()
span.mark('cart-loaded') // an in-span checkpoint
return await charge(cart)
})
// Manual: start, mark, end/fail yourself.
const span = startProfile('report')
span.mark('queried')
span.end()interface ProfileEntryContent {
label: string // the span label, e.g. 'checkout'
durationMs: number // total wall-clock duration
status: 'completed' | 'failed'
marks: { label: string; atMs: number }[] // in-order checkpoints, offset from start
failureReason: string | null // set when status is 'failed'
traceId: string | null
}Tagged slow at/above profiling.slowMs (default 100ms); spans shorter than
profiling.minDurationMs (default 0) are discarded. Recording is fire-and-forget and fully
guarded — a telescope failure can never break the code being profiled. While the watcher is
disabled, profile() / startProfile() are a zero-cost no-op (the closure still runs).
Schedule watcher
Records each scheduled-task run as a scheduled_task entry — the task name, cron/interval
expression, duration and outcome. AdonisJS ships no first-party scheduler, and the community
schedulers emit nothing on the app emitter, so — rather than fabricate an event — this
watcher's integration point is an explicit wrapper.
import { scheduleTask, recordScheduledRun } from '@adonis-agora/telescope/watchers'
// Wrap a scheduled closure — times the run, records completed/failed, always re-throws.
await scheduleTask('prune-sessions', () => sessions.pruneExpired(), {
schedule: '0 * * * *',
kind: 'cron',
})
// …or record an outcome you already have (from an existing scheduler callback).
recordScheduledRun({ name: 'digest', status: 'completed', durationMs: 812, kind: 'cron' })interface ScheduleEntryContent {
name: string
schedule: string | null // the cron/interval expression, when supplied
kind: 'cron' | 'interval' | 'custom'
durationMs: number
status: 'completed' | 'failed'
attempts: number | null
failureReason: string | null
traceId: string | null
}familyHash is schedule:<name>, so repeated runs of the same task roll up and feed the
slow-hotspot metrics cards; a run at/above schedule.slowMs (default 1000ms) is tagged slow.
Like profiling, the helpers are a no-op until the provider starts the watcher, so schedule
capture stays strictly opt-in.
In the Agora ecosystem, @adonis-agora/durable also bridges its scheduled/cron runs onto the
diagnostics bus, which the diagnostics watcher records — this watcher covers plain
application-level scheduled work.
Live Schedules — registering what EXISTS, not just what ran
scheduleTask()/recordScheduledRun() answer "what already ran?" — they say nothing about
"what schedules exist and when will they next fire?", because AdonisJS gives telescope no
registry of that to read (there is no @Cron()-style decorator scanning to hook, unlike
@nestjs/schedule's SchedulerRegistry). registerSchedule() closes that gap the same way
the rest of this watcher works: explicit, not discovered. Call it once per scheduled task,
next to wherever you actually wire it into your scheduler:
import { registerSchedule, scheduleTask } from '@adonis-agora/telescope/watchers'
registerSchedule({ name: 'prune-sessions', schedule: '0 * * * *', kind: 'cron' })
// However you actually schedule it — shown here with adonisjs-scheduler:
scheduler.call(() =>
scheduleTask('prune-sessions', () => Session.pruneExpired(), { schedule: '0 * * * *' }),
).hourly()The dashboard's Schedules section (GET <path>/api/schedules/live) lists every
registered schedule with a computed next-run time — via the OPTIONAL
cron-parser peer, the same library
@adonis-agora/durable already depends on for its own cron scheduling, reused here for
ecosystem consistency rather than picking a second one — joined against the most recent
matching scheduled_task entry for last-run status/duration.
npm i cron-parser # optional; without it nextRunAt is always null (not an error)Three more functions round out the registry:
| Function | What it does |
|---|---|
unregisterSchedule(name) | Drops one registration. Use it when a schedule is conditional (a feature flag, a tenant that turned it off) so the console stops promising a run that will never come. A name that was never registered is a no-op. |
listRegisteredSchedules() | Everything currently registered, each with its computed nextRunAt — the same list the console renders, readable from an ace command or a health endpoint of your own. Returns [] when the watcher is disabled. |
toRegisteredSchedule(registration, nowMs) | Turns one registration into its resolved form (kind, schedule, timezone, nextRunAt) without registering it — handy for previewing "when would this fire?" before wiring it up. |
nextCronRunMs(expression, fromMs, timezone?) | The bare next-run computation over a cron expression, as epoch ms. Returns null when cron-parser isn't installed or the expression doesn't parse — it never throws. |
Every one of these is a no-op or an empty result when the schedule watcher is disabled, so
they are safe to call unconditionally.
There is deliberately no "active/running" column, unlike the NestJS sibling's
ScheduledTask.running (which reads a real CronJob.running flag off @nestjs/schedule's
internals). AdonisJS has no equivalent object to read a running/stopped flag off — faking one
(e.g. always true) would be actively misleading, so it is omitted rather than guessed.
cron-parser has shipped two incompatible top-level shapes across its major versions: v4
exposes parseExpression(expr, opts) directly; v5 moved it to
CronExpressionParser.parse(expr, opts). registerSchedule's next-run computation tries both,
so either major version works — the same detection @adonis-agora/durable applies to its own
scheduler, so one installed copy of either major serves both packages.
Building your own watcher
Every watcher implements the same tiny Watcher contract over a structural EmitterLike:
interface Watcher {
readonly type: string
start(emitter: EmitterLike): void // subscribe
stop(): void // fully unsubscribe
}Record through the exported safeRecord(input, source) helper — it resolves the runtime
store, backfills the active trace id, and swallows every failure so your watcher can never
break the path it observes. The custom watcher guide
walks through a full example.
Peer dependencies
@adonisjs/lucid, @adonisjs/mail, @adonisjs/cache and cron-parser are declared optional
peers — install only the ones whose watcher you enable. With none installed the package still
loads; those watchers simply have nothing to subscribe to.
@adonisjs/queue and @adonisjs/redis are not declared at all, optional or otherwise.
Their watchers never import them: the queue watcher listens on a Node diagnostics channel the
queue engine publishes on, the redis watcher wraps the command method on a manager it is handed,
and the Live Queue Manager calls an adapter structurally. So they cost you nothing when the
packages are absent — the queue and events watchers idle on a channel nobody feeds, the redis
watcher no-ops on a null manager, the Live Queue Manager reports configured: false, and
Live Schedules' nextRunAt is always null — and equally, npm will never install them for you.
Add them to your own package.json when you want them.
The queue, events and redis watchers currently expose no config/telescope_watchers.ts
knobs — enabling them in the watchers array is enough, and they run on their defaults
(the queue slow threshold is 1000ms; the events watcher uses DEFAULT_IGNORED_EVENTS). Their
constructor options (QueueWatcherOptions.slowMs, the EventsWatcher ignore-list) are still
available when you wire a watcher by hand rather than through the provider.
Storage drivers
Telescope's config-driven storage — the in-memory ring buffer and the SQL-backed Lucid driver built into the core with the storage factory, plus the Lucid migration, JSON-text columns, integer epoch timestamps, and per-driver options.
Pulse health rollup
Pulse is Telescope's aggregated "at a glance" health rollup — throughput, request error rate and latency percentiles, slowest entries, slow route/outgoing/job hotspots, N+1 suspects, top exception families, cache hit rate, and load-by-user — computed on demand from stored entries and served at <path>/api/metrics/pulse, from the headless getHealth API, and via the MCP get_health tool.