Agora
Guides

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):

app/telescope/queue_watcher.ts
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.Job records under a stable, reserved core type, so it shows up in the dashboard's type filter and works with list({ type: 'job' }).
  • isQueueEvent rejects 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 or record, 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.queue makes topFamilies(10, 'job') answer "busiest queues", and the failed tag makes list({ 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:

providers/app_provider.ts
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).

On this page

Partner

Your product as the official integration for Telescope, right here.

Become a partner →