Agora
Packages

@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 prompt

Selecting 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

config/telescope_watchers.ts
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'] },
})
KeyDefaultDescription
enabledtrueMaster 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'.
querysee belowLucid query watcher tuning — slowMs (500), captureBindings (false), ignoreConnections, normalize (true).
httpClientsee belowhttp-client watcher tuning — slowMs (1000), ignoreHosts, captureBodies (false).
profilingsee belowprofiling watcher tuning — slowMs (100), minDurationMs (0).
schedulesee belowschedule watcher tuning — slowMs (1000).
queueManagersee belowLive 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:

config/telescope_watchers.ts
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 why queueManager.queues must 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:

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:

FunctionWhat 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.

On this page