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.
The in-memory and Lucid stores are two implementations of one small interface. To put
entries in Mongo, Redis, ClickHouse, or anywhere else, implement the same six methods and
pass an instance as store. This guide builds a MongoDB store.
The contract
import type { TelescopeStore, Entry, RecordInput, EntryQuery } from '@adonis-agora/telescope'
interface TelescopeStore {
record<TContent>(input: RecordInput<TContent>): Promise<Entry<TContent>>
get(id: string): Promise<Entry | null>
list(query?: EntryQuery): Promise<Entry[]> // always newest-first
count(): Promise<number>
prune(olderThan: Date, keepLast?: number): Promise<number>
clear(): Promise<void>
}Two responsibilities are easy to miss and the same in every store:
recordresolvestraceId/origin. When the caller omitstraceId, read it fromcurrentTraceId(); whenoriginisn't a validBatchOrigin, default to'manual'.listis newest-first and AND-composes every setEntryQueryfield.
Full example — a Mongo-backed store
import { randomUUID } from 'node:crypto'
import type { Collection } from 'mongodb'
import {
type TelescopeStore, type Entry, type RecordInput, type EntryQuery,
type BatchOrigin, currentTraceId, isBatchOrigin,
} from '@adonis-agora/telescope'
export class MongoTelescopeStore implements TelescopeStore {
private sequence = 0
constructor(private readonly col: Collection) {}
async record<TContent>(input: RecordInput<TContent>): Promise<Entry<TContent>> {
const traceId = input.traceId !== undefined ? input.traceId : currentTraceId()
const origin: BatchOrigin = isBatchOrigin(input.origin) ? input.origin : 'manual'
const entry: Entry<TContent> = {
id: randomUUID(),
type: input.type,
familyHash: input.familyHash ?? null,
content: input.content,
tags: input.tags ?? [],
sequence: this.sequence++,
durationMs: input.durationMs ?? null,
origin,
traceId,
createdAt: new Date(),
}
await this.col.insertOne({ ...entry, createdAtMs: entry.createdAt.getTime() })
return entry
}
async get(id: string): Promise<Entry | null> {
const doc = await this.col.findOne({ id })
return doc ? hydrate(doc) : null
}
async list(query: EntryQuery = {}): Promise<Entry[]> {
const filter: Record<string, unknown> = {}
if (query.type !== undefined) filter.type = query.type
if (query.tag !== undefined) filter.tags = query.tag // array contains
if (query.familyHash !== undefined) filter.familyHash = query.familyHash
if (query.traceId !== undefined) filter.traceId = query.traceId
if (query.before !== undefined) filter.createdAtMs = { ...(filter.createdAtMs as object), $lt: query.before.getTime() }
if (query.after !== undefined) filter.createdAtMs = { ...(filter.createdAtMs as object), $gt: query.after.getTime() }
if (query.search !== undefined) {
const rx = new RegExp(escapeRegExp(query.search), 'i')
filter.$or = [{ tags: rx }, { contentText: rx }] // see note below
}
let cursor = this.col.find(filter).sort({ createdAtMs: -1, sequence: -1 })
// `{ page, size }` in, skip/limit out — `page` is 1-based, so the offset is `(page - 1) * size`.
if (query.size !== undefined) {
cursor = cursor.skip((Math.max(1, query.page ?? 1) - 1) * query.size).limit(query.size)
}
return (await cursor.toArray()).map(hydrate)
}
async count(): Promise<number> {
return this.col.countDocuments()
}
async prune(olderThan: Date, keepLast?: number): Promise<number> {
const cutoff = olderThan.getTime()
if (keepLast === undefined) {
return (await this.col.deleteMany({ createdAtMs: { $lt: cutoff } })).deletedCount ?? 0
}
const doomed = await this.col.find({ createdAtMs: { $lt: cutoff } })
.sort({ createdAtMs: -1, sequence: -1 }).project({ id: 1 }).toArray()
const ids = doomed.slice(keepLast).map((d) => d.id)
if (ids.length === 0) return 0
return (await this.col.deleteMany({ id: { $in: ids } })).deletedCount ?? 0
}
async clear(): Promise<void> {
await this.col.deleteMany({})
}
}
function hydrate(doc: Record<string, unknown>): Entry {
return {
id: String(doc.id), type: String(doc.type),
familyHash: (doc.familyHash as string | null) ?? null,
content: doc.content, tags: Array.isArray(doc.tags) ? (doc.tags as string[]) : [],
sequence: Number(doc.sequence), durationMs: (doc.durationMs as number | null) ?? null,
origin: isBatchOrigin(doc.origin) ? doc.origin : 'manual',
traceId: (doc.traceId as string | null) ?? null,
createdAt: new Date(Number(doc.createdAtMs)),
}
}
function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }How it works — the load-bearing lines
traceId/originresolution inrecordmirrors the built-in stores exactly:input.traceId !== undefined ? input.traceId : currentTraceId()(so an explicitnullis respected, and an omitted one reads context), andisBatchOrigin(input.origin) ? ... : 'manual'. Skip this and trace correlation silently breaks.createdAtMsas an integer makesbefore/after/pruneinteger comparisons and the newest-first sort stable — the same choice the Lucid store makes. Sorting bycreatedAtMs desc, sequence descgives a deterministic order even when two entries share a millisecond.prunewithkeepLastfinds the doomed (older-than-cutoff) ids newest-first, drops the firstkeepLast, and deletes the rest — so you can keep "the most recent N regardless of age".
search must match the entry's serialized content. Mongo can't regex a nested object
directly, so persist a flattened contentText: JSON.stringify(content) alongside content
on record and regex that — the same "search over JSON text" approach the Lucid store uses.
Wiring it in
import { defineConfig } from '@adonis-agora/telescope'
import { MongoTelescopeStore } from '#telescope/mongo_store'
import { entriesCollection } from '#telescope/mongo'
export default defineConfig({
store: new MongoTelescopeStore(entriesCollection),
})The core provider uses your instance instead of the in-memory store, and everything
downstream — TelescopeService, the dashboard, alerts, AI — works against it unchanged,
because they all only know the contract.
The contract is intentionally minimal — no rollups, no cursors. If your backend has
cheap server-side aggregation, you can compute topFamilies/topTags there, but the
default TelescopeService already derives them from list, so it's optional.
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.
Building an extension
Step-by-step — package a sibling library's observability into a Telescope extension that contributes a navigable entry type, server-side data providers, and a declarative dashboard page, then register it in config — with no React and nothing Telescope-internal.