Aviary
Packages

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.

Core captures requests and exceptions on its own. Everything else is a watcher you add to the watchers array. Each is its own package — install the ones that match your stack. Every watcher records in the caller's async context, so each entry lands in the active request/job batch automatically (see Capture & correlation). Recording failures are always swallowed: a Telescope error can never change the outcome of the thing it's watching.


Query watchers

MikroORM — @dudousxd/nestjs-telescope-mikro-orm

Captures every executed SQL query, correlates it to the request that triggered it, tags slow queries, and ships a pure N+1 detector. (The same package ships a storage adapter.) Requires MikroORM v7+.

MikroORM v7 stores its logger in a private field set once at Configuration construction time — runtime replacement isn't possible. So the host must wire loggerFactory before constructing the ORM; that factory is the capture mechanism.

That timing is also the wrinkle: MikroORM.init() — and the logger it constructs — can run before Nest DI has built TelescopeService, so a record callback closing over an injected service isn't always available yet. telescopeMikroOrmLogger's record parameter is now optional: omit it and the logger lazily resolves telescopeRecord from core at each logQuery call instead of at construction, so binding order stops mattering —

import { MikroORM } from '@mikro-orm/core';
import { telescopeMikroOrmLogger } from '@dudousxd/nestjs-telescope-mikro-orm';

MikroORM.init({
  debug: ['query'],
  loggerFactory: telescopeMikroOrmLogger(), // no `record` — resolves the global sink lazily
});

telescopeRecord (exported from core, alongside setTelescopeRecordSink) is a free-function record sink for exactly this kind of boot-time integration — it mirrors telescopeDump's pattern (see Request context). TelescopeService's constructor binds the sink automatically and onApplicationShutdown clears it; a call made before binding (or after shutdown) is dropped, not buffered — there's no unbounded queue replaying boot noise once the module comes up. An explicit record function, when passed, still takes precedence over the global sink.

If you'd rather wire the callback explicitly (or need TelescopeService for other reasons at that point), use forRootAsync so it can be injected first:

import { TelescopeModule, TelescopeService } from '@dudousxd/nestjs-telescope';
import { MikroOrmQueryWatcher, telescopeMikroOrmLogger } from '@dudousxd/nestjs-telescope-mikro-orm';
import { MikroOrmModule } from '@mikro-orm/nestjs';

@Module({
  imports: [
    TelescopeModule.forRoot({
      authorizer: () => true,           // restrict in production
      watchers: [new MikroOrmQueryWatcher()],
    }),
    MikroOrmModule.forRootAsync({
      inject: [TelescopeService],
      useFactory: (telescope: TelescopeService) => ({
        // ...your driver, dbName, entities
        debug: ['query'],
        loggerFactory: telescopeMikroOrmLogger(
          (input) => telescope.record(input),
          { slowMs: 100 },              // queries >= 100ms get a 'slow' tag
        ),
      }),
    }),
  ],
})
export class AppModule {}

Queries recorded this way inherit the active ALS batch, so each one correlates to its request. The package also exports detectNPlusOne(batchEntries, threshold) — a pure function (no I/O) that returns every query template run at least threshold times in a batch.

Capture only happens while debug is on, but debug also echoes every query to stdout. To feed Telescope without the console noise, keep debug: ['query'] and pass silent: true — it swaps MikroORM's writer for a no-op so queries still flow into Telescope while nothing is printed:

loggerFactory: telescopeMikroOrmLogger(
  (input) => telescope.record(input),
  { slowMs: 100, silent: true },   // record into Telescope, no stdout echo
),

TypeORM — @dudousxd/nestjs-telescope-typeorm

TypeORM lets you supply a custom Logger at DataSource construction; this package ships one whose logQuery records each statement. Because the logger runs in the query's async context, queries correlate to the active batch via ALS — exactly like MikroORM.

import { TelescopeModule, TelescopeService } from '@dudousxd/nestjs-telescope';
import { telescopeTypeOrmLogger } from '@dudousxd/nestjs-telescope-typeorm';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TelescopeModule.forRoot({
      authorizer: () => true, // restrict in production
    }),
    TypeOrmModule.forRootAsync({
      inject: [TelescopeService],
      useFactory: (telescope: TelescopeService) => ({
        // ...your connection options, entities
        logging: true, // or ['query'] — required, else TypeORM never calls logQuery
        logger: telescopeTypeOrmLogger((input) => telescope.record(input)),
      }),
    }),
  ],
})
export class AppModule {}

TypeORM's logQuery carries no execution time, so normal queries record durationMs: null; slow queries surfaced via logQuerySlow (gated by maxQueryExecutionTime) record a real duration and a slow tag. Pass { slowMs } to set the threshold the package uses.

Prisma — @dudousxd/nestjs-telescope-prisma

Captures every SQL statement Prisma executes (SQL + duration + params) for a full query log and slow-query visibility. Construct the client with query event logging, then hand it to the watcher:

import { PrismaClient } from '@prisma/client';
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { PrismaQueryWatcher } from '@dudousxd/nestjs-telescope-prisma';

const prisma = new PrismaClient({ log: [{ emit: 'event', level: 'query' }] });

TelescopeModule.forRoot({ watchers: [new PrismaQueryWatcher(prisma, { slowMs: 500 })] });

No request/job correlation (Prisma engine limit)

prisma.$on('query', cb) fires detached from the caller's async context — the engine emits query events on its own channel, after the fact. So Prisma query entries are orphaned: they don't correlate to the request/job that issued them, and per-batch N+1 detection doesn't apply. They're still captured because a full query log plus slow-query visibility is valuable on its own. If per-request query correlation matters, prefer the MikroORM or TypeORM adapter.


Behavioral watchers

Mail — @dudousxd/nestjs-telescope-mail

Wraps a nodemailer transporter's sendMail to capture every email — sender, recipients, subject, a short body preview, and sent/failed status — correlated to the request or job that sent it.

import { MailWatcher } from '@dudousxd/nestjs-telescope-mail';

TelescopeModule.forRoot({ watchers: [new MailWatcher(transporter, { mailer: 'ses' })] });

On a rejected send it records status: 'failed' (tagged failed) and re-throws — your error handling is untouched. The patch is per-transporter and idempotent.

Cache — @dudousxd/nestjs-telescope-cache

Captures cache hits and misses. Three modes: zero-config (new CacheWatcher() auto-discovers the standard @nestjs/cache-manager CACHE_MANAGER and patches its get/set), explicit (pass a specific Cache), and an escape hatch for custom caches like BentoCache via an instrument(emit, ctx) hook.

import { CacheWatcher } from '@dudousxd/nestjs-telescope-cache';

TelescopeModule.forRoot({ watchers: [new CacheWatcher()] }); // auto-discovers CACHE_MANAGER

The philosophy: official libraries are auto-instrumented out of the box; any other cache becomes instrumentable through instrument — Telescope never has to special-case every cache library. If CACHE_MANAGER isn't registered, the watcher logs a one-line warning and no-ops.

@nestjs/cache-manager is optional

@nestjs/cache-manager is an optional peer dependency. It is imported lazily, on demand, only on the zero-config auto-discovery path that resolves CACHE_MANAGER. So BentoCache-only (and any other custom-cache-only) hosts that use the instrument(emit, ctx) escape hatch — or that pass an explicit Cache — do not need to install @nestjs/cache-manager at all. Loading the watcher never triggers a runtime resolution of the peer.

Schedule — @dudousxd/nestjs-telescope-schedule

Wraps @nestjs/schedule @Cron / @Interval / @Timeout handlers so each run opens a schedule batch — correlating the queries and exceptions that task emits — and records a job entry for the run itself (outcome + duration).

import { ScheduleWatcher } from '@dudousxd/nestjs-telescope-schedule';

@Module({
  imports: [
    // Import order does not matter.
    TelescopeModule.forRoot({ watchers: [new ScheduleWatcher({ slowMs: 1000 })] }),
    ScheduleModule.forRoot(),
  ],
})
export class AppModule {}

No ordering caveat

Instrumentation is installed when the watcher is constructed — while your @Module metadata is still being built, before NestFactory.create and therefore before ScheduleExplorer captures its handler references at onModuleInit. Import Telescope wherever you like. (Earlier versions patched provider prototypes at onApplicationBootstrap and asked you to import Telescope first; that never worked — a timer-driven @Cron recorded nothing either way.)

Events — @dudousxd/nestjs-telescope-events

Attaches a single wildcard listener (emitter.onAny) to the app's @nestjs/event-emitter EventEmitter2 singleton and records one event entry per emit (name, payload, listener count), correlated to the request/job that emitted it. It resolves the emitter from the Nest container — no wiring.

import { EventsWatcher } from '@dudousxd/nestjs-telescope-events';

TelescopeModule.forRoot({ watchers: [new EventsWatcher()] });

Logs — @dudousxd/nestjs-telescope-logs

Captures Nest Logger output. Zero-config: just add the watcher. By default LogsWatcher tees the @nestjs/common Logger facade — every new Logger(Ctx) instance log and every Logger.log(...) static is recorded, with no host changes:

import { LogsWatcher } from '@dudousxd/nestjs-telescope-logs';

TelescopeModule.forRoot({ watchers: [new LogsWatcher()] });

The patch calls the original first, so console output and any custom logger behind the facade are untouched. It's idempotent process-wide and never throws into your log call.

Fallback — explicit logger. Hosts that bypass the Logger facade (logging straight through their app logger) can install TelescopeConsoleLogger instead; it logs to the console and forwards each line to Telescope's sink:

import { NestFactory } from '@nestjs/core';
import { LogsWatcher, TelescopeConsoleLogger } from '@dudousxd/nestjs-telescope-logs';

const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(new TelescopeConsoleLogger());
// auto-patch + explicit coexist; a single log still records exactly once.
TelescopeModule.forRoot({ watchers: [new LogsWatcher()] });

Pass new LogsWatcher({ autoPatch: false }) to capture only via TelescopeConsoleLogger. Both modes can run together — a shared re-entrancy guard dedupes a facade call that reaches both paths, so one log records once.

Telescope's own log contexts (Telescope, Recorder, LogsWatcher, EventsWatcher) are skipped so the watcher never records its own lines or feeds a loop.

Level-aware sampling. warn / error / fatal log entries count as errors for tail-sampling, so you can sample logs hard and never lose them:

TelescopeModule.forRoot({ sampling: { log: { rate: 0.1, keepErrors: true } } });

Programmatic log emit. Both capture paths above hook Nest's Logger. Hosts that log through something else entirely — a raw pino / winston pipeline, a framework logger, a background worker that never touches @nestjs/common — can push lines into Telescope directly with emitTelescopeLog. It forwards a LogSinkInput to the sink that LogsWatcher installs, so the log entry lands in the active request/job batch just like an auto-captured one:

import { emitTelescopeLog } from '@dudousxd/nestjs-telescope-logs';

// Anywhere in your own logging pipeline:
emitTelescopeLog({
  level: 'warn',                 // Nest log level: 'log' | 'warn' | 'error' | 'debug' | 'verbose' | 'fatal'
  message: 'Payment retry exhausted',
  context: 'BillingService',     // logger context, or null
});

LogSinkInput is the line's shape — { level, message, context }, where message is already stringified and context is null when none. emitTelescopeLog is a no-op until LogsWatcher has wired the sink, so calling it from a module that also runs outside a Telescope-enabled app never crashes and never records. The shared re-entrancy guard applies here too: emitting a log while Telescope is already recording one self-suppresses, so a logging side effect of capture can't spiral into a loop.

setTelescopeLogSink is internal

The sink itself is installed by LogsWatcher's lifecycle. setTelescopeLogSink(fn | null) is exported for that wiring (and for tests) — hosts don't call it directly; add LogsWatcher to watchers and use emitTelescopeLog to push lines.

Redis commands — @dudousxd/nestjs-telescope-redis-watcher

Monkey-patches a wrapped ioredis client's sendCommand, recording one redis entry per command (command, args, durationMs), correlated to the request/job that issued it. Two construction forms: wrap a client you hold, or resolve one lazily via instrument.

import { RedisCommandWatcher } from '@dudousxd/nestjs-telescope-redis-watcher';

TelescopeModule.forRoot({ watchers: [new RedisCommandWatcher(ioredisClient)] });

Pass a dedicated/observed client. If you share one ioredis with Telescope's own Redis storage provider, those storage commands get captured too — noise.

Models — @dudousxd/nestjs-telescope-mikro-orm-watcher

Captures every MikroORM entity lifecycle change (create / update / delete) as a model entry (action, entity, id, changes), correlated to the request or job that triggered the flush. It registers an EventSubscriber on the EntityManager's event manager.

import { MikroOrmModelWatcher } from '@dudousxd/nestjs-telescope-mikro-orm-watcher';

TelescopeModule.forRoot({ watchers: [new MikroOrmModelWatcher()] });

Telescope's own storage entities (TelescopeEntry / TelescopeRollup) are skipped by class name, so persisting an entry never records another model entry.

Beyond per-entity changes, the watcher also records the flush and transaction lifecycle with measured durations, keyed per EntityManager so concurrent units of work stay isolated:

  • On onFlush / afterFlush it records a { kind: 'flush' } entry carrying the elapsed durationMs.
  • On beforeTransactionStart + afterTransactionCommit / afterTransactionRollback it records a { kind: 'transaction', outcome } entry (outcome is 'committed' or 'rolled-back') with the elapsed durationMs.

When you also run @dudousxd/nestjs-telescope-otel, these flush and transaction entries map to the telescope_mikroorm_flush_total / telescope_mikroorm_flush_duration_ms and telescope_mikroorm_transaction_total / telescope_mikroorm_transaction_duration_ms metrics.

On this page