Custom watcher
Build your own Telescope watcher — implement the Watcher contract over the AdonisJS event emitter, record through the guarded safeRecord helper with trace correlation, and register it so it starts and stops with your app.
A watcher turns a runtime event into Telescope entries. The built-ins cover requests, exceptions, diagnostics, Lucid queries, mail and cache — but anything that emits an event (or that you can wrap) can be a watcher. This guide builds one end to end.
The contract
A watcher implements the tiny Watcher interface from @adonis-agora/telescope/watchers, over a
structural EmitterLike (a real @adonisjs/events emitter satisfies it):
import type { Watcher, EmitterLike } from '@adonis-agora/telescope/watchers'
interface Watcher {
readonly type: string // the telescope entry type you record under
start(emitter: EmitterLike): void // subscribe; returns nothing
stop(): void // fully unsubscribe — never throws into the app
}
interface EmitterLike {
on(event: string, listener: (data: unknown) => void): () => void // returns an unsubscribe fn
}emitter.on(...) returns an unsubscribe function (the real Adonis emitter, built on
emittery, does exactly this) — keep it and call it in stop().
You record through safeRecord(() => input, source), the guarded helper: it resolves the live
runtime store, backfills the active @adonis-agora/context trace id when you don't set one, and
swallows every failure so your watcher can never break the path it observes. The builder
function runs only when the entry will be recorded, so a paused or disabled Telescope builds
nothing. Passing a plain RecordInput also works, at the cost of building it every time.
Example — a queue-job watcher
Say you emit a queue:processed event when a background job finishes. Record each as a
job entry (a reserved core type):
import { EntryType, type RecordInput, currentTraceId } from '@adonis-agora/telescope'
import { safeRecord, type Watcher, type EmitterLike } from '@adonis-agora/telescope/watchers'
interface QueueProcessedEvent {
queue: string
jobId: string
durationMs: number
failed: boolean
}
interface JobEntryContent {
queue: string
jobId: string
failed: boolean
durationMs: number
traceId: string | null
}
export class QueueWatcher implements Watcher {
readonly type = EntryType.Job
private unsubscribe: (() => void) | null = null
start(emitter: EmitterLike): void {
if (this.unsubscribe) return // idempotent
this.unsubscribe = emitter.on('queue:processed', (data) => this.handle(data))
}
stop(): void {
this.unsubscribe?.()
this.unsubscribe = null
}
private handle(data: unknown): void {
if (!isQueueEvent(data)) return // validate defensively
safeRecord(() => buildJobEntry(data), 'QueueWatcher')
}
}
function isQueueEvent(data: unknown): data is QueueProcessedEvent {
return typeof data === 'object' && data !== null
&& typeof (data as QueueProcessedEvent).queue === 'string'
&& typeof (data as QueueProcessedEvent).jobId === 'string'
}
function buildJobEntry(event: QueueProcessedEvent): RecordInput<JobEntryContent> {
const traceId = currentTraceId()
return {
type: EntryType.Job,
familyHash: event.queue, // group by queue
durationMs: event.durationMs,
traceId,
tags: [`queue:${event.queue}`, ...(event.failed ? ['failed'] : [])],
content: { queue: event.queue, jobId: event.jobId, failed: event.failed, durationMs: event.durationMs, traceId },
}
}How it works
type = EntryType.Jobrecords under a stable, reserved core type, so it shows up in the dashboard's type filter and works withlist({ type: 'job' }).isQueueEventrejects anything that doesn't structurally match — a watcher must tolerate junk on the channel without throwing.safeRecord(() => ..., 'QueueWatcher')does the dangerous part safely: a missing store, a throwing builder orrecord, or a rejected promise is caught and logged with the source label, never propagated into the job. The builder is skipped entirely while recording is off or paused.familyHash: event.queuemakestopFamilies(10, 'job')answer "busiest queues", and thefailedtag makeslist({ tag: 'failed' })a one-liner.currentTraceId()correlates the job to whatever request enqueued it, when context flows through.
Registering it
Start the watcher in a provider's boot() and stop it in shutdown(), resolving the
application emitter from the container:
import type { ApplicationService } from '@adonisjs/core/types'
import type { EmitterLike } from '@adonis-agora/telescope/watchers'
import { QueueWatcher } from '#telescope/queue_watcher'
export default class AppProvider {
private watcher = new QueueWatcher()
constructor(protected app: ApplicationService) {}
async boot() {
const emitter = (await this.app.container.make('emitter')) as unknown as EmitterLike
this.watcher.start(emitter)
}
async shutdown() {
this.watcher.stop()
}
}You don't need to wire your watcher into @adonis-agora/telescope/watchers' config — that config
only controls the built-in watchers that package ships (query, mail, cache,
http-client, logs, queue, events, redis, profiling, schedule and
queue-manager). A custom watcher is just a class you start yourself, recording through the
same runtime store.
Keep start/stop idempotent and make stop unsubscribe everything you subscribed. A
watcher that double-subscribes records duplicate entries; one that leaks subscriptions
keeps recording after shutdown.
Wrapping something that doesn't emit
No event to listen to? Wrap the call site and record around it — the same safeRecord
applies. The only rule is the universal one: record in a way that can never change the
behaviour or failure mode of the thing you're instrumenting (record in a finally, swallow
your own errors, re-throw theirs untouched).
Guides
Task-oriented recipes for Telescope — write a custom watcher, implement a custom storage backend, build a dashboard extension, shape what gets captured with tags and redaction, correlate by request context, and add AI exception diagnosis.
Custom storage adapter
Implement the TelescopeStore contract against any backend — a full MongoDB-backed store covering record, get, list with every EntryQuery filter, count, prune with keepLast, and clear, plus the trace/origin resolution every store owes its callers.