Aviary
Packages

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 touch telescope_entries (it can't alter your other tables), and it avoids a self-capture loop where every INSERT INTO telescope_entries would be recorded as a query.
  • Self-healing schema at boot. init() runs ensureDatabase() + schema.update({ safe: true }) — additive only: creates the table if missing, adds missing columns, never drops anything. ensureSchema: false disables it.
  • Lifecycle. init() is awaited by TelescopeModule at 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 ioredis
import 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 ioredis client it was given (close() is a no-op). Manage the connection's lifecycle and quit() yourself.

  • Pruning is explicit. There's no per-key TTL — old entries are removed by the core pruner via the prune config (e.g. prune: { after: '24h' }).

  • keyPrefix. Every key is namespaced; the default prefix is telescope:. 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 client
import { 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. hot is any StorageProvider — 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 on id, batch_id and trace_id. Every pod writes its own files.
  • cold.archive({ types, batchSize?, maxBatchesPerCycle? }) returns the archive config 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, or retention: { default?, perType? } on parquetColdStore (hour granularity), which the prune cycle applies.
  • Compaction merges closed hours (older than closedAfter, default 2h) into files of about targetFileMb, at most maxHoursPerRun (24) per run, under the prune lease. compaction: false turns it off.
  • clear() clears both tiers, including every cold file.
  • DuckDB engine (optional). engine: { duckdb: { memoryLimit, threads, localCacheMb } } with @duckdb/node-api installed. It never loads httpfs: 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/client
import { 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() creates telescope_entries (ReplacingMergeTree, partitioned by day, ordered by (type, created_at, id)) and telescope_rollups (SummingMergeTree) if missing. Rename them with table / rollupTable.
  • Retention changes apply at boot. The TTL's fingerprint is kept in the table comment; when retention changes, init() runs ALTER 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 configure prune, it does nothing.
  • Writes use async_insert=1, wait_for_async_insert=1 (asyncInsert: false to opt out).
  • Bring your own client. Pass client (a @clickhouse/client instance) instead of url; the provider then never closes it. clientOptions passes TLS, compression and other settings through when the provider creates the client.
  • New-exception dedup across pods (markFamilySeen) uses a KeeperMap table (telescope_seen_families, rename with seenFamiliesTable), written in keeper_map_strict_mode so 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 markFamilySeen out, so the alert falls back to deduplicating per pod — exactly as on any store without it.

On this page