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
| Adapter | Store | Use it when | Package |
|---|---|---|---|
| SQLite | embedded file | Local dev, single process (the default). | built into core |
| MikroORM | MySQL / SQLite | You already run MikroORM and want Telescope in your own DB — no Redis to stand up. | -mikro-orm |
| Redis | shared Redis | Multi-instance / multi-pod: every replica reads and writes one store, so the dashboard aggregates the whole cluster. | -redis |
| In-memory | process heap | Tests — 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 touchtelescope_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'sloggerFactorywired), everyINSERT INTO telescope_entrieswould be captured as a query, recorded, flushed, and re-captured. - Additive schema sync at boot. On
init()the provider runsensureDatabase()+schema.update({ safe: true })— it createstelescope_entriesif missing and adds any missing columns, and never drops a table or column. SetensureSchema: falseto 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
typesare archived; every other type prunes normally. - For each archived type the pruner fetches entries older than that type's cutoff and streams them to
sinkinbatchSizechunks. 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
maxBatchesPerCyclebatches 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.
Capture & correlation
How watchers, batches, and AsyncLocalStorage turn scattered events into one navigable flow — the request and everything it caused, in capture order.
Performance
Why capture doesn't slow your app — request capture off the response path, ~microsecond query capture, rollup-backed reads, and the /health endpoint that surfaces Telescope's own cost.