Aviary
Concepts

Storage

The StorageProvider SPI, the zero-config SQLite default, self-healing schema, and the adapter table — your DB, your store, the same contract everywhere.

Storage is pluggable to the core. The default store is a reference implementation of a public contract, not a privileged internal — so swapping it for your own database, Redis, or an in-memory store for tests is a one-line config change, and the API, dashboard, and pruner behave identically against any of them.

The StorageProvider SPI

Every store implements the same interface:

interface StorageProvider {
  store(entries: Entry[]): Promise<void>;          // batch write
  update(id: string, patch: Partial<Entry>): Promise<void>; // request entry completes
  find(id: string): Promise<EntryWithBatch | null>;
  get(query: EntryQuery): Promise<Page<Entry>>;    // filter by type/tag/batch/family/time
  batch(batchId: string): Promise<Entry[]>;        // the correlation view
  tags(prefix?: string): Promise<TagCount[]>;
  prune(olderThan: Date, keepLast?: number): Promise<number>;
  pruneScoped?(input: PruneScope): Promise<number>; // optional: per-type retention
  clear(): Promise<void>;
}

Reads are keyset-paginated newest-first (createdAt DESC, id DESC), so paging resumes correctly even after older entries are pruned out from under the cursor.

Want to write your own? The custom storage recipe implements every method end to end.

The zero-config SQLite default

Out of the box, core persists to an embedded SQLite store (better-sqlite3). No connection string, no migration, no table setup — it works on the first boot. It's the right default for local development and single-process apps.

It is per-process, though: each replica keeps its own SQLite file, so in a multi-instance deployment the dashboard only sees entries from the pod that served the request. When you scale out, move to a shared store.

Adapters

AdapterStoreUse it whenPackage
SQLiteembedded fileLocal dev, single process (the default).built into core
MikroORMMySQL / SQLiteYou already run MikroORM and want Telescope in your own DB — no Redis to stand up.-mikro-orm
Redisshared RedisMulti-instance / multi-pod: every replica reads and writes one store, so the dashboard aggregates the whole cluster.-redis
In-memoryprocess heapTests — InMemoryStorageProvider.-testing

The ORM adapter packages also ship the matching query watcher — they already sit on the ORM's event stream, so capture and persistence travel together.

Self-healing schema (MikroORM adapter)

The MikroOrmStorageProvider persists Telescope entries through MikroORM into your existing MySQL or SQLite — with no migration and no manual table setup. Two design choices make that safe:

  • A dedicated, scoped connection. The provider opens its own MikroORM that knows only TelescopeEntry. This matters for two reasons. First, the schema diff can only ever touch telescope_entries — it is structurally incapable of altering your other tables. Second, it avoids a self-capture loop: if storage writes ran on the host connection (which has the query watcher's loggerFactory wired), every INSERT INTO telescope_entries would be captured as a query, recorded, flushed, and re-captured.
  • Additive schema sync at boot. On init() the provider runs ensureDatabase() + schema.update({ safe: true }) — it creates telescope_entries if missing and adds any missing columns, and never drops a table or column. Set ensureSchema: false to opt out (e.g. when you provision the table via your own migration).

Want Telescope on a separate database (recommended for high write volume)? Pass explicit connection options instead of borrowing the host MikroORM. See the storage packages for both wirings.

Retention and pruning

There's no unbounded growth. The core pruner runs on a schedule and calls prune() on whatever store you've configured, driven by the prune config:

TelescopeModule.forRoot({ prune: { after: '24h' } });

The Redis adapter has no per-key TTL — pruning is explicit through this same path, so retention behaves the same regardless of backend.

Per-type retention

Different entry types have different value over time: a request is noise after an hour, but an exception is worth a week. prune.perType overrides the global cutoff for specific types — everything else keeps using after:

TelescopeModule.forRoot({
  prune: {
    after: '5m',            // global: requests, queries, cache ops…
    intervalMs: 60_000,
    perType: { exception: '7d' }, // exceptions live a week
  },
});

Each cycle the pruner runs one bulk delete for every non-overridden type at the global cutoff (type NOT IN (...)), plus one delete per overridden type at its own cutoff (type = ?). Per-type durations are validated at boot exactly like after — a bad value is a startup error, not a silent skip. Omitting perType reproduces the prior single-cutoff behaviour byte-for-byte.

Per-type retention uses a new optional pruneScoped() method on the storage contract. Every in-repo adapter (SQLite, MikroORM, Redis, in-memory) implements it. A third-party provider that predates it keeps working — the pruner falls back to the global cutoff for all types and logs a one-time warning.

Archiving before prune

Pruning deletes. When a type is worth keeping but not in the live store, hand its doomed entries to an archive.sink before the pruner deletes them — export to S3, a data lake, cold storage:

TelescopeModule.forRoot({
  prune: { after: '5m', perType: { exception: '7d' } },
  archive: {
    types: ['exception'],
    sink: async (entries) => { /* persist the batch durably */ },
    batchSize: 500,        // entries per sink call (default 500)
  },
});

The contract is archive, then delete, per type, per cycle:

  • Only listed types are archived; every other type prunes normally.
  • For each archived type the pruner fetches entries older than that type's cutoff and streams them to sink in batchSize chunks. It deletes the type only after the sink resolves.
  • If the sink throws, that type is not deleted this cycle — the doomed entries survive to be retried next cycle. The error is logged (once per cycle) and the rest of the prune continues. A failing sink can never crash the host or stall the pruner.
  • Work is bounded: at most maxBatchesPerCycle batches per type per cycle (default 10); any backlog is picked up next tick.

See the Archiving exceptions to S3 recipe for a copy-pasteable sink.

On this page