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. Two drivers ship:
memory (the default, bounded ring buffer) and lucid (a persistent, SQL-backed store).
Both 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(['type'])
table.index(['trace_id'])
table.index(['family_hash'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}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?)— the rawCREATE TABLE+ index statements.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.
- 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. - 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 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).
Writing a different backend
The two 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.