Storage drivers
Telescope's config-driven storage — the in-memory ring buffer and the SQL-backed Lucid driver built into the core with the storage factory, plus the Lucid migration, JSON-text columns, integer epoch timestamps, and per-driver options.
Storage is not a separate package — it lives in @adonis-agora/telescope as a config-driven
driver, selected in config/telescope.ts with the storage factory. Three drivers ship:
memory (the default, bounded ring buffer), lucid (a persistent, SQL-backed store) and
tiered (a hot driver plus a Parquet cold tier). All implement the same TelescopeStore contract, so TelescopeService, the dashboard,
alerts and the AI diagnoser all work against either one unchanged.
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 — its peer dependency is only imported when that driver
is the active one. storage.lucid imports @adonisjs/lucid only when store: 'lucid'.
The memory driver
storage.memory({ limit }) is the zero-config default — a bounded in-process ring buffer.
It is lost on restart and lives per-process; ideal for development and tests.
| Option | Default | Description |
|---|---|---|
limit | 1000 | Hard cap on retained entries. Oldest evicted past it (ring buffer). |
The lucid driver
storage.lucid() is the production answer: a TelescopeStore backed by AdonisJS Lucid,
so entries persist and stay queryable straight from your database on any dialect
(sqlite / Postgres / MySQL). @adonisjs/lucid is an optional peer of the core,
imported lazily only when this driver is selected.
import { defineConfig, storage } from '@adonis-agora/telescope'
export default defineConfig({
store: 'lucid',
stores: {
lucid: storage.lucid(), // default connection
// lucid: storage.lucid({ connection: 'pg', tableName: 'tscope' }),
},
})Options
| Option | Default | Description |
|---|---|---|
connection | (default) | @adonisjs/lucid connection name. Omit for the default one. |
tableName | telescope_entries | Table to read/write (override on a name collision). |
autoCreateTable | false | Run the DDL on first use (tests/scripts; prefer a migration). |
maxEntries | unset | Advisory cap for scheduled prune trimming (no auto-evict). |
Schema
The lucid driver needs a table. node ace configure @adonis-agora/telescope publishes the
migration stub alongside the config file:
node ace configure @adonis-agora/telescope # publishes config/telescope.ts + the migration
node ace migration:runThe published migration:
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'telescope_entries'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.string('id').primary().notNullable()
table.string('type').notNullable()
table.string('family_hash').nullable()
table.text('content').notNullable()
table.text('tags').notNullable()
table.integer('sequence').notNullable()
table.integer('duration_ms').nullable()
table.string('origin').notNullable()
table.string('trace_id').nullable()
table.bigInteger('created_at').notNullable()
table.index(['created_at'])
table.index(['trace_id'])
table.index(['type', 'created_at', 'sequence'])
table.index(['family_hash', 'created_at'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}If your app ran an earlier copy of this migration (with single-column type and
family_hash indexes), add the two composite indexes with a migration of your own:
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'telescope_entries'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.index(['type', 'created_at', 'sequence'])
table.index(['family_hash', 'created_at'])
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropIndex(['type', 'created_at', 'sequence'])
table.dropIndex(['family_hash', 'created_at'])
})
}
}With autoCreateTable there is nothing to do: the schema fingerprint changes and the store
adds the new indexes on its next boot.
For tests and scripts, you can skip the migration: pass autoCreateTable: true (the store
runs idempotent CREATE TABLE IF NOT EXISTS DDL on first use), or call the exported
createTelescopeTable(db) helper directly. Production should still prefer the versioned
migration.
The second table
autoCreateTable also creates a small companion table, telescope_schema_meta. It holds one
row per entries table it manages — the table's name, a fingerprint of the schema the store last
reconciled, and when — and exists purely so a boot that finds a matching fingerprint can skip
re-issuing the (idempotent but chatty) CREATE TABLE and four CREATE INDEX statements. A
fresh database, a schema change, or a Telescope upgrade that alters the shape produces a
mismatch, the DDL runs once, and the fingerprint is re-cached.
You will see it in your database if you use autoCreateTable; it is safe to drop (it is
recreated, and the next boot simply pays for the DDL once). It is not created when you run
the published migration and leave autoCreateTable off — with a migrated schema there is
nothing to reconcile. The name is exported as SCHEMA_META_TABLE_NAME if you need to exclude
it from a backup or a schema diff.
Programmatic API
For tests, scripts, or a hand-wired store instance, the core also exports:
LucidTelescopeStore— implements the asyncTelescopeStorecontract (record/get/list/count/prune/clear).createTelescopeTable(db, options?)— idempotent DDL helper.createTableStatements(tableName?, dialect?)— the rawCREATE TABLE+ index statements. Pass the Lucid dialect name ('mysql') to get backtick-quoted identifiers and plainCREATE INDEX(MySQL has noIF NOT EXISTSfor indexes); without it the statements use double-quoted identifiers, for sqlite and Postgres.DEFAULT_TABLE_NAME—'telescope_entries'.
import db from '@adonisjs/lucid/services/db'
import { LucidTelescopeStore } from '@adonis-agora/telescope'
const store = new LucidTelescopeStore(db, { tableName: 'tscope', autoCreateTable: true })Inside a provider's boot(), resolve app.container.make('lucid.db') instead — the
services/db façade's default export is undefined until app.booted(), which runs after
provider boot.
Design notes
- Dialect-agnostic. It uses Lucid's async query builder directly, so it works on every
dialect Lucid supports (sqlite / Postgres / MySQL) — no synchronous driver handle, no
dialect lock-in. Filters quote columns through identifier bindings, so MySQL needs no
ANSI_QUOTES;autoCreateTablereads the dialect from the connection and issues DDL for it. - JSON-text columns.
contentandtagsare stored as JSON text and round-tripped on read — portable everywhere. - Integer timestamps.
created_atis epoch milliseconds, so newest-first ordering and age-based pruning are integer comparisons with no timezone/format ambiguity.listorders bycreated_at desc, sequence descas a deterministic tiebreaker. - Monotonic sequence. Seeded from
MAX(sequence)on first use, so it keeps climbing across restarts. - Tag matching.
tagfilters match the quoted token inside the JSON array text, solib:billnever matcheslib:billing. - Batched writes, single pooled connection. Every entry recorded in one tick of the event
loop goes out in one multi-row
INSERT(at most 90 rows and 4 MB per statement), one statement at a time, so the store holds at most one pooled connection for writing — a watcher-event burst can't exhaust the app's DB pool.record()resolves once its row is written. If a batch is rejected, its rows are retried one at a time, so only the bad row'srecord()fails. - Bounded write buffer. At most 10,000 entries wait to be written. Past that, new entries
are dropped and counted on
store.droppedCount, with oneconsole.warnper overflow. - Its own queries are not recorded. Every query the store runs is excluded from the query
watcher, so sharing the app's connection with
debug: truerecords neither the store's INSERTs nor the dashboard's reads. - Dashboard aggregates in SQL. The store implements every
optional fast path: the throughput
chart is a
GROUP BYover time buckets,topFamiliesaGROUP BY family_hash … LIMIT, andtopTagsreads only thetagscolumn in pages of 5,000 rows.list({ omitContent: true })selects every column butcontent. - Bounded prune.
prunedeletes oldest-first in batches of 5,000 rows, each statement carrying a few bound values, so a backlog larger than the dialect's bind limit still prunes. WithkeepLastit finds the boundary row with oneORDER BY … LIMIT 1 OFFSET keepLast. - 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 store never auto-evicts on record (that would add a query per write). Bound table
growth by running prune on a schedule
— e.g. an @adonisjs/scheduler job calling store.prune(cutoff, keepLast).
The tiered driver
storage.tiered({ hot, cold, hotFor }) keeps a hot driver (usually storage.lucid()) minutes
deep and moves older entries, on each prune cycle, to storage.parquet({ location, engine }):
hourly Parquet files in a directory or an S3 bucket, read with hyparquet (pure JS, the default)
or DuckDB (opt-in). hyparquet, hyparquet-writer, hyparquet-compressors,
@duckdb/node-api and @aws-sdk/client-s3 are optional peers, each imported only when its
option is selected.
export default defineConfig({
store: 'tiered',
stores: {
tiered: storage.tiered({
hot: storage.lucid(),
cold: storage.parquet({ location: { dir: 'storage/telescope' } }),
hotFor: '5m',
}),
},
prune: { after: '7d' }, // moves entries to cold; `after` is the cold tier's retention
})The layout, read routing, the DuckDB lockdown, measurements and every option are on Scaling storage.
Writing a different backend
The shipped drivers are two implementations of one small contract — a Mongo, Redis, or
ClickHouse store is the same six methods, passed as a store instance. See the
custom storage guide.
@adonis-agora/telescope
The headless core — the request, exception and diagnostics watchers, the uniform Entry model, the TelescopeStore contract and in-memory ring buffer, the TelescopeService query API, the extension SDK, and the structural readers for context and diagnostics.
@adonis-agora/telescope/watchers
The watchers subpath of @adonis-agora/telescope — record every Lucid SQL query (sql, bindings, duration, connection), every email sent, @adonisjs/cache hit/miss/write/delete events, outbound fetch calls, and AdonisJS logger output, each correlated to the active request trace.