Storage adapters
Persist Telescope entries in your own database (MikroORM — MySQL/SQLite), share one store across replicas (Redis), tier hot entries into Parquet on S3, or run on ClickHouse. Same StorageProvider contract everywhere.
The default store is per-process SQLite. Swap it by passing a storage provider to forRoot. See Storage for the SPI and Scaling storage for which adapter fits which volume; this page is the wiring for each adapter. Every adapter here passes the same conformance suite.
MikroORM (MySQL / SQLite) — @dudousxd/nestjs-telescope-mikro-orm
Persists entries to your application's own database through MikroORM — so you can use your existing MySQL (or SQLite) for Telescope instead of standing up Redis. No migration, no manual table setup. Hand it your existing MikroORM:
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { MikroOrmStorageProvider } from '@dudousxd/nestjs-telescope-mikro-orm';
import { MikroORM } from '@mikro-orm/core';
@Module({
imports: [
// your normal MikroOrmModule.forRoot({ ... }) — nothing to add there
TelescopeModule.forRootAsync({
inject: [MikroORM],
useFactory: (orm: MikroORM) => ({
// borrows the host's connection config; opens its OWN scoped connection
storage: new MikroOrmStorageProvider(orm),
}),
}),
],
})
export class AppModule {}For high write volume, put Telescope on a separate database by passing explicit connection options instead of the host MikroORM:
import { MySqlDriver } from '@mikro-orm/mysql';
new MikroOrmStorageProvider({
driver: MySqlDriver,
clientUrl: process.env.TELESCOPE_DATABASE_URL,
// ensureSchema: false, // opt out of boot-time schema sync (you manage it via migration)
});How it works:
- Dedicated, scoped connection. The provider opens its own MikroORM that knows only
TelescopeEntry. The schema diff can therefore only ever touchtelescope_entries(it can't alter your other tables), and it avoids a self-capture loop where everyINSERT INTO telescope_entrieswould be recorded as a query. - Self-healing schema at boot.
init()runsensureDatabase()+schema.update({ safe: true })— additive only: creates the table if missing, adds missing columns, never drops anything.ensureSchema: falsedisables it. - Lifecycle.
init()is awaited byTelescopeModuleat startup;close()closes the owned connection at shutdown. You call neither. - Keyset pagination is newest-first; the cursor encodes a
(createdAt, id)position, so paging resumes even after older entries are pruned.
This same package ships the MikroORM query watcher and N+1 detector — see Watchers.
Redis (shared, multi-instance) — @dudousxd/nestjs-telescope-redis
The store to pick when you run multiple replicas: all replicas pointed at the same Redis share one store, so the dashboard aggregates entries across the whole deployment instead of one process. Requires Redis 6+ and ioredis v5+.
pnpm add @dudousxd/nestjs-telescope-redis ioredisimport Redis from 'ioredis';
import { RedisStorageProvider } from '@dudousxd/nestjs-telescope-redis';
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
const storage = new RedisStorageProvider(new Redis(process.env.REDIS_URL));
TelescopeModule.forRoot({ storage });Notes:
-
The host owns the connection. The provider never closes the
ioredisclient it was given (close()is a no-op). Manage the connection's lifecycle andquit()yourself. -
Pruning is explicit. There's no per-key TTL — old entries are removed by the core pruner via the
pruneconfig (e.g.prune: { after: '24h' }). -
keyPrefix. Every key is namespaced; the default prefix istelescope:. Override it to share one Redis across apps:new RedisStorageProvider(redis, { keyPrefix: 'myapp:telescope:' });
Tiered: hot store + Parquet cold tier — @dudousxd/nestjs-telescope-parquet
Keeps the store you already have as a minutes-deep hot tier and moves older entries into Parquet files on S3 or a directory instead of deleting them. Reads merge both tiers on the same newest-first cursor. Pure JavaScript by default (hyparquet): nothing native, nothing downloaded at runtime.
pnpm add @dudousxd/nestjs-telescope-parquet
# S3 location: you already have @aws-sdk/client-s3; Telescope uses your clientimport { S3Client } from '@aws-sdk/client-s3';
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { MikroOrmStorageProvider } from '@dudousxd/nestjs-telescope-mikro-orm';
import { MikroORM } from '@mikro-orm/core';
import { parquetColdStore, tieredStorage } from '@dudousxd/nestjs-telescope-parquet';
const cold = parquetColdStore({
location: { s3: { bucket: 'observability', prefix: 'telescope/v1', client: new S3Client({}) } },
// or: location: { dir: '/var/lib/telescope' },
compaction: { every: '15m', targetFileMb: 128 },
});
TelescopeModule.forRootAsync({
inject: [MikroORM],
useFactory: (orm: MikroORM) => ({
storage: tieredStorage({ hot: new MikroOrmStorageProvider(orm), cold }),
prune: { after: '5m', intervalMs: 15_000 }, // how long entries stay hot
archive: cold.archive({ types: ['request', 'query', 'exception', 'job'] }), // what moves cold
}),
});Notes:
- Any hot store.
hotis anyStorageProvider— MikroORM, Redis, SQLite. The tiered store has exactly the hot store's optional capabilities (prune lease, bounded deletes, rollups, shared new-exception dedup). - Layout.
<prefix>/type=<type>/dt=<yyyy-mm-dd>/hour=<hh>/<instanceId>-<uuid>.parquet, each file sorted by(created_at, id)in 10,000-row groups, with statistics and bloom filters onid,batch_idandtrace_id. Every pod writes its own files. cold.archive({ types, batchSize?, maxBatchesPerCycle? })returns thearchiveconfig with the cold store as sink, defaulting to 5,000 × 20 per type per cycle. Types not listed are pruned without archiving.- Retention. A bucket lifecycle rule per
type=<type>/prefix, orretention: { default?, perType? }onparquetColdStore(hour granularity), which the prune cycle applies. - Compaction merges closed hours (older than
closedAfter, default2h) into files of abouttargetFileMb, at mostmaxHoursPerRun(24) per run, under the prune lease.compaction: falseturns it off. clear()clears both tiers, including every cold file.- DuckDB engine (optional).
engine: { duckdb: { memoryLimit, threads, localCacheMb } }with@duckdb/node-apiinstalled. It never loadshttpfs: S3 objects are fetched through your client into a bounded local cache. Boot fails if it is configured but not installed. See Scaling storage before enabling it in a hardened image.
ClickHouse — @dudousxd/nestjs-telescope-clickhouse
One columnar store for high volume and long retention. Retention is the table's TTL, so there is no pruner to run.
pnpm add @dudousxd/nestjs-telescope-clickhouse @clickhouse/clientimport { ClickHouseStorageProvider } from '@dudousxd/nestjs-telescope-clickhouse';
TelescopeModule.forRoot({
storage: new ClickHouseStorageProvider({
url: process.env.CLICKHOUSE_URL, // HTTP interface, self-hosted or ClickHouse Cloud
username: process.env.CLICKHOUSE_USER,
password: process.env.CLICKHOUSE_PASSWORD,
database: 'observability',
retention: { default: '24h', perType: { exception: '90d', durable: '30d' } },
}),
// no `prune`: retention is the table's TTL
});Notes:
- Schema at boot.
init()createstelescope_entries(ReplacingMergeTree, partitioned by day, ordered by(type, created_at, id)) andtelescope_rollups(SummingMergeTree) if missing. Rename them withtable/rollupTable. - Retention changes apply at boot. The TTL's fingerprint is kept in the table comment; when
retentionchanges,init()runsALTER TABLE … MODIFY TTL, otherwise it leaves the table alone. prune*are no-ops that report 0, and there is no prune lease. If you do configureprune, it does nothing.- Writes use
async_insert=1, wait_for_async_insert=1(asyncInsert: falseto opt out). - Bring your own client. Pass
client(a@clickhouse/clientinstance) instead ofurl; the provider then never closes it.clientOptionspasses TLS, compression and other settings through when the provider creates the client. - New-exception dedup across pods (
markFamilySeen) uses aKeeperMaptable (telescope_seen_families, rename withseenFamiliesTable), written inkeeper_map_strict_modeso exactly one replica pages for a new exception family per window. It works in two modes:- With ClickHouse Keeper or ZooKeeper configured on the server, plus
<keeper_map_path_prefix>in its config (ClickHouse Cloud has both),init()creates the table and the alert pages once per deployment. - Without them, creating the table fails; the provider logs one warning saying how to enable it and leaves
markFamilySeenout, so the alert falls back to deduplicating per pod — exactly as on any store without it.
- With ClickHouse Keeper or ZooKeeper configured on the server, plus
Watchers
Query watchers (MikroORM, TypeORM, Prisma) and behavioral watchers (mail, cache, schedule, events, logs, redis, model) — each a small package you add to the watchers array.
Queue managers
Horizon-style live queue consoles — BullMQ (browse + retry/remove/promote/retry-all) and SQS (depth + DLQ inspection + redrive), all behind a default-deny mutation gate.