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.
Optional fast paths
On top of the six required methods, a store may implement four optional reads. The
dashboard detects each one by the method being present, and falls back to scanning list
when it is absent, so a store without them is correct, just slower on a large table:
| Method | Serves | Fallback when absent |
|---|---|---|
listTraceIds({ size, page?, before?, after? }) | the traces screen: a page of distinct trace ids, most recently seen first | scans entries (up to the metrics scan cap) and groups them by traceId |
countByBucket({ after, before, bucketMs, type? }) | the throughput chart: per-bucket, per-type counts | lists the window without content and buckets it in JS |
countFamilies({ limit, type? }) | topFamilies: busiest familyHash values, ties to the most recently seen | lists the whole table without content and counts in JS |
countTags({ limit, prefix? }) | topTags: most common tags, ties to the most recently seen | lists the whole table without content and counts in JS |
The memory and lucid drivers implement all four. The redaction, sampling, streaming and
OTel decorators the provider wraps around your store expose each method only when the store
underneath has it.
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. Three drivers ship in the box: memory, lucid, and tiered (a hot store plus Parquet
files for everything older — see Scaling storage).
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
omitContent?: boolean // hint: the caller does not read `content`
}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.
omitContent is a hint, not a filter. Window-wide aggregations (Pulse, per-type stats) set
it and then fetch the content of the few entries they display. A store may answer it by
returning content: null without loading or parsing the column (the lucid driver does);
a store that ignores it still answers correctly.
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,trace_id,(type, created_at, sequence),(family_hash, created_at)). - Single pooled connection. Writes are batched into multi-row inserts and serialized, so the store holds at most one connection from the pool for writing — 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. The lucid driver deletes oldest-first in batches of 5,000 rows, each
statement carrying a handful of bound values, so a backlog of any size prunes without hitting
the database's bind-parameter limit. Wire it into an @adonisjs/scheduler job (or a cron) for hands-off
retention.
Writing your own store
Implement the six-method contract (plus any of the optional fast paths
your backend can answer cheaply) 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) })Outgrowing one table
When a single telescope_entries table can't keep up with your write rate or the retention you
want, keep it as the hot tier and move older entries to Parquet with storage.tiered(). The
Scaling storage page lays out the whole ladder, the
signals for each step, and the config.
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.
Scaling storage
Grow Telescope's storage with your volume — from the in-memory default to your app's database, a dedicated one, and a tiered store that keeps minutes of data hot and everything older as Parquet on disk or S3. Each step is a config change.