Storage
The TelescopeStore contract every watcher records through and the query API reads from — the config-driven driver model (the in-memory ring buffer and the SQL-backed Lucid store), the EntryQuery filter model, retention and pruning.
A store is the one thing that sits between capture and query: watchers record
into it, TelescopeService reads out of it. Telescope defines a single small contract
for it, ships two drivers selected from config, and lets you swap in a custom one
without touching anything else.
The TelescopeStore contract
Every store — the built-in ring buffer, the Lucid driver, or one you write — implements the same six methods:
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>
}record is the only write path. It fills in id, sequence, createdAt, and
resolves traceId / origin from the ambient context when the caller omits them, then
returns the persisted Entry. Everything else is a read or a maintenance operation.
This is the trimmed Adonis port of aviary's NestJS StorageProvider — no opaque
keyset cursors and no built-in rollup tables. The EntryQuery uses a simple
before / after / page / size instead. Aggregates (topFamilies, topTags) are computed
in TelescopeService over list, which is plenty for the in-memory and single-node
SQL stores this ships with.
Choosing a driver
Storage is config-driven. Build named drivers with the storage factory and pick
the active one with store:
import { defineConfig, storage } from '@adonis-agora/telescope'
export default defineConfig({
store: 'memory',
stores: {
memory: storage.memory({ limit: 1000 }),
lucid: storage.lucid({ connection: 'pg' }),
},
})Each factory returns a lazy thunk — listing it in stores costs nothing, and a driver's
peer dependency (@adonisjs/lucid for lucid) is only imported when it is the active
one. Two drivers ship in the box: memory and lucid.
Querying
list (and therefore TelescopeService.list) takes an EntryQuery. Every set field
is an AND predicate, so they compose; results are always newest-first.
interface EntryQuery {
type?: string // 'request', 'diagnostic', 'query', 'exception', ...
tag?: string // exact tag match, e.g. 'lib:billing'
familyHash?: string // exact grouping key
traceId?: string // entries on one trace
before?: Date // strictly older than (keyset-ish pagination)
after?: Date // newer than
search?: string // case-insensitive substring over JSON content + tags
page?: number // 1-based page number (default 1)
size?: number // page size — how many entries to return
}page / size are the pagination pair every @adonis-agora/* library uses — the
same shape as @adonis-agora/filter's
FilterInput, matched structurally (filter is not a dependency). The store computes the
row offset as (page - 1) * size, so { page: 2, size: 25 } is the second page of 25.
search matches against the entry's JSON-serialized content and its tags, so a
request matches by url, a diagnostic by event, a query by SQL — all from one box.
await telescope.list({ type: 'query', search: 'users', size: 50 })
await telescope.list({ tag: 'status:500', after: oneHourAgo })
await telescope.list({ familyHash: 'billing:invoice-paid' })The memory driver
storage.memory({ limit }) (the default store: 'memory') is InMemoryTelescopeStore —
a bounded ring buffer:
- Newest-first ordering (entries are
unshifted, so index 0 is the most recent). - An id index for O(1)
get. searchover JSON content + tags.- A hard
limitcap (default 1000) — past it, the oldest entries are evicted so an unbounded process can never OOM.
import { defineConfig, storage } from '@adonis-agora/telescope'
export default defineConfig({
store: 'memory',
stores: {
memory: storage.memory({ limit: 5000 }), // raise the cap; oldest beyond this are evicted
},
})The memory driver is lost on every restart and is per-process (each worker keeps
its own buffer). It is ideal for development and tests, and a reasonable default for a
single long-lived process — but for production, durable, cross-query storage, use the
lucid driver below.
The lucid driver
storage.lucid({ connection? }) is the production storage answer — a SQL-backed
TelescopeStore on AdonisJS Lucid, so entries survive restarts and stay queryable from
your database on any Lucid dialect (sqlite / Postgres / MySQL). @adonisjs/lucid is an
optional peer, imported lazily only when this driver is selected.
import { defineConfig, storage } from '@adonis-agora/telescope'
export default defineConfig({
store: 'lucid',
stores: {
lucid: storage.lucid(), // or storage.lucid({ connection: 'pg' })
},
})It implements the exact same contract, so TelescopeService, the dashboard, alerts and
the AI diagnoser all work against it unchanged. A few design notes:
contentandtagsare stored as JSON text and round-tripped on read — portable across every dialect.created_atis stored as epoch milliseconds (a plain integer), so newest-first ordering and age-based pruning are driver-agnostic integer comparisons — no timezone or string-format ambiguity.sequenceis seeded fromMAX(sequence)on first use, so it keeps climbing monotonically across restarts.- Indexes back the hot read paths (
created_at,type,trace_id,family_hash). - Single pooled connection. Writes are serialized (single-flight), so the store holds at most one connection from the pool at a time — a watcher-event burst can't exhaust the app's DB pool.
- Integer durations.
duration_msis rounded to an integer at the store (it's an INTEGER column), so watchers timing withperformance.now()never produce a fractional value Postgres would reject.
The driver needs a table. node ace configure @adonis-agora/telescope publishes the
create_telescope_entries_table migration; run node ace migration:run. See
Storage drivers for the schema, migration, and options.
Retention and pruning
Telescope's core already ships a background pruner: set prune: { after: '24h', keepLast?, intervalMs? } in config/telescope.ts and it deletes stale entries on a timer (default
every 60s) — no scheduler wiring required.
No store auto-prunes by age on its own (eviction in the memory driver is by count, not
time) — the background pruner above is what drives it. Calling store.prune yourself, as
below, is only needed for bespoke retention logic outside that schedule:
// Delete everything older than 7 days.
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
await store.prune(cutoff)
// ...but always keep the newest 500 of the doomed set, regardless of age.
await store.prune(cutoff, 500)prune(olderThan, keepLast?) deletes entries older than olderThan; when keepLast
is set, the newest N of the matched-and-doomed entries are retained. It returns the
number deleted. Wire it into an @adonisjs/scheduler job (or a cron) for hands-off
retention.
Writing your own store
Implement the six-method contract and pass an instance to store. A Mongo, Redis, or
ClickHouse store is just record + list + the maintenance methods. The
custom storage guide walks through a full
implementation.
import { defineConfig } from '@adonis-agora/telescope'
import { MyMongoStore } from '#telescope/mongo_store'
export default defineConfig({ store: new MyMongoStore(mongo) })A store decides where entries live. Extensions decide what new things the dashboard can show.
Capture & correlation
The mental model behind Telescope — what a watcher is, the uniform Entry shape every recording produces, the generic diagnostics spine that records all library events, exception auto-capture, and how trace correlation works without coupling to @adonis-agora/context.
Extensions
The declarative extension SPI — how a sibling library contributes navigable entry types, declarative dashboard pages (the panel IR), and server-side data providers to Telescope without forking it or shipping any React.