Agora
Concepts

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:

MethodServesFallback when absent
listTraceIds({ size, page?, before?, after? })the traces screen: a page of distinct trace ids, most recently seen firstscans entries (up to the metrics scan cap) and groups them by traceId
countByBucket({ after, before, bucketMs, type? })the throughput chart: per-bucket, per-type countslists the window without content and buckets it in JS
countFamilies({ limit, type? })topFamilies: busiest familyHash values, ties to the most recently seenlists the whole table without content and counts in JS
countTags({ limit, prefix? })topTags: most common tags, ties to the most recently seenlists 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:

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 — 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.
  • search over JSON content + tags.
  • A hard limit cap (default 1000) — past it, the oldest entries are evicted so an unbounded process can never OOM.
config/telescope.ts
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.

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

  • content and tags are stored as JSON text and round-tripped on read — portable across every dialect.
  • created_at is 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.
  • sequence is seeded from MAX(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_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 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.

On this page

Partner

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

Become a partner →