Agora
Packages

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.

config/telescope.ts
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.

OptionDefaultDescription
limit1000Hard 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.

config/telescope.ts
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

OptionDefaultDescription
connection(default)@adonisjs/lucid connection name. Omit for the default one.
tableNametelescope_entriesTable to read/write (override on a name collision).
autoCreateTablefalseRun the DDL on first use (tests/scripts; prefer a migration).
maxEntriesunsetAdvisory 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:run

The published migration:

database/migrations/xxxx_create_telescope_entries_table.ts
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 async TelescopeStore contract (record / get / list / count / prune / clear).
  • createTelescopeTable(db, options?) — idempotent DDL helper.
  • createTableStatements(tableName?, dialect?) — the raw CREATE TABLE + index statements. Pass the Lucid dialect name ('mysql') to get backtick-quoted identifiers and plain CREATE INDEX (MySQL has no IF NOT EXISTS for 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; autoCreateTable reads the dialect from the connection and issues DDL for it.
  • JSON-text columns. content and tags are stored as JSON text and round-tripped on read — portable everywhere.
  • Integer timestamps. created_at is epoch milliseconds, so newest-first ordering and age-based pruning are integer comparisons with no timezone/format ambiguity. list orders by created_at desc, sequence desc as a deterministic tiebreaker.
  • Monotonic sequence. Seeded from MAX(sequence) on first use, so it keeps climbing across restarts.
  • Tag matching. tag filters match the quoted token inside the JSON array text, so lib:bill never matches lib: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's record() 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 one console.warn per 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: true records 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 BY over time buckets, topFamilies a GROUP BY family_hash … LIMIT, and topTags reads only the tags column in pages of 5,000 rows. list({ omitContent: true }) selects every column but content.
  • Bounded prune. prune deletes 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. With keepLast it finds the boundary row with one ORDER BY … LIMIT 1 OFFSET keepLast.
  • Integer durations. duration_ms is rounded to an integer at the store (it's an INTEGER column), so watchers timing with performance.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.

config/telescope.ts
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.

On this page

Partner

Your product as the official integration for Telescope, right here.

Become a partner →