Skip to content

Repository files navigation

nest-debug-panel

A debug panel for NestJS. See everything that happens inside every request.

npm version license node


You hit an endpoint and it takes 800ms. Was it the database? A cache miss? That one external API call? Instead of sprinkling console.log everywhere, open /__debug and look:

GET  /users          32ms   200   5 SQL queries
GET  /orders/42     181ms   200   1 SQL query · slow
GET  /boom            8ms   500   exception

Click any request and you get the full story: every SQL query with timing, Redis commands, outgoing HTTP calls, the exception with its stack trace, memory usage, and a timeline of the whole request from start to finish.

One module import. No changes to your business logic. Disabled in production by default.

Quick start

Install it:

npm install nest-debug-panel

Register it in your root module, ideally first in the list so its interceptor wraps everything:

// app.module.ts
import { Module } from '@nestjs/common';
import { DebugModule } from 'nest-debug-panel';

@Module({
  imports: [
    DebugModule.forRoot(), // on when NODE_ENV !== 'production'
    // ...your other modules
  ],
})
export class AppModule {}

Start your app and open:

http://localhost:<your-port>/__debug

That's it. Hit any endpoint of your API and watch it appear in the dashboard (it refreshes every 2 seconds). If the list stays empty, make sure NODE_ENV isn't production, pass enabled: true explicitly, or set NEST_DEBUG_PANEL_ENABLED=true in your environment (see Enabling & disabling).

Works with Node.js 18+, NestJS 9/10/11, and both Express and Fastify. No runtime dependencies.

What gets captured

For every request: method, URL, query and route params, headers (sensitive ones redacted), body, the authenticated user, IP, response status, response body and size, and total duration.

On top of that:

  • Database queries with SQL text, parameters, and per-query timing, plus total SQL time, the slowest query, duplicate-query groups, and N+1 detection
  • Redis commands with arguments and timing (ioredis and node-redis)
  • Outgoing HTTP calls through Axios, fetch, or Nest's HttpService
  • Exceptions with name, message, stack trace, and how long the request ran before failing
  • Memory: heap and RSS deltas per request, event-loop delay
  • A timeline that lays all of the above in order, plus your own custom marks
  • Socket.io events — inbound @SubscribeMessage handlers captured like a request, with all the SQL/Redis/HTTP they run (see below)
  • Background jobs — BullMQ processors, microservice consumers and scheduled tasks captured like a request, with everything they run (see below)

Socket.io events

If your app uses NestJS WebSocket gateways (@WebSocketGateway + @SubscribeMessage), every incoming event is captured automatically — just like HTTP. You don't touch your gateways or add any decorator; DebugModule.forRoot() is all it takes. (NestJS doesn't apply global interceptors to gateways, so the panel attaches itself to each gateway at startup for you.)

Each event runs inside the same tracing context, so every query it runs shows up automatically, with N+1 detection, timeline and all — plus the event name, namespace, socket id, rooms, handshake (redacted), payload and acknowledgement.

Socket events appear in the same list as HTTP requests, tagged with a WS badge; use the All / HTTP / Socket filter at the top to narrow down. Set sockets: false in forRoot() to turn socket capture off.

Capture is on by default. For rare cases the automatic attachment can't reach (e.g. a single handler, or a gateway created outside the module scan), the @TrackSocketEvents() decorator is exported as an explicit opt-in — normally you won't need it.

Background jobs

Background work is captured the same way as requests — no annotations, no per-queue wiring. Each run becomes its own profile with everything it did (SQL/Redis/HTTP, N+1, timeline, exceptions), plus the queue, job name, id, attempt, payload (redacted) and return value. Runs appear in the Jobs monitor.

Setup depends on your process layout:

  • Jobs run in the same process as the panel (e.g. everything under one npm start): nothing to do — DebugModule.forRoot() is all you need, and jobs show up right next to requests and sockets.
  • Jobs run in a separate worker process (the common production layout — an API process and a dedicated worker, e.g. a worker.module.ts bootstrapped by its own main.ts/worker.ts): a NestJS module tree is only instrumented in the process that actually loads it, so DebugModule.forRoot() must be imported in the worker's own root module too — not just the API's. Adding it there does not require an HTTP server; a standalone NestFactory.createApplicationContext(WorkerModule) bootstrap works fine. Once both processes load it, set NEST_DEBUG_PANEL_STORAGE=redis in both and one panel shows requests, sockets and jobs together — one env var on top of the import, using the Redis you already run for BullMQ (full setup just below in Multi-process apps).

What's captured automatically, with zero extra code:

Library / pattern Auto How
@nestjs/bullmq (@Processor + WorkerHost) ✅ always the processor class is wrapped at startup
@nestjs/microservices (@MessagePattern / @EventPattern) ✅ always consumers already flow through the interceptor
@nestjs/schedule (@Cron / @Interval / @Timeout) ✅ always decorated methods are wrapped at startup
bee-queue / Agenda (registered as a DI provider) ✅ best-effort the queue's registrar is wrapped so your handler is traced

Turn it off with jobs: false, skip one processor with @DebugIgnore(), or drop payloads with captureJobData: false.

The only case needing a line of code is a worker created entirely outside Nest's DI (e.g. a bare new Worker(...)), or legacy @nestjs/bull. Annotate the handler with @TrackJob(), or wrap it with trackJob():

import { TrackJob, trackJob } from 'nest-debug-panel';

// As a decorator on any method that handles a job:
@TrackJob({ queue: 'emails', jobName: 'welcome' })
async handle(job: Job) { /* ... */ }

// Or functionally, around any handler:
new Worker('emails', (job) =>
  trackJob({ library: 'bullmq', queue: 'emails', jobName: job.name }, () => doWork(job), job.data),
);

Multi-process apps (API + worker)

Most production NestJS apps run the HTTP API and the BullMQ worker as two separate processes (e.g. src/main.ts bootstrapping AppModule, and a separate src/worker.ts bootstrapping WorkerModule). Getting this right needs two things, and missing either one is the most common reason the Jobs list looks empty:

1. Import DebugModule.forRoot() in every process's own root module — including the worker's. The panel only instruments the module tree it's actually loaded into. If your worker's root module (e.g. WorkerModule) never imports it, nothing in that process is ever wrapped, no matter what else you configure. This is true even when the worker has no HTTP server — DebugModule.forRoot() works the same inside a standalone NestFactory.createApplicationContext(...) bootstrap:

// worker.module.ts — the worker's OWN root module, separate from AppModule
import { Module } from '@nestjs/common';
import { DebugModule } from 'nest-debug-panel';

@Module({
  imports: [
    DebugModule.forRoot(),
    // ...your BullMQ / processor modules
  ],
})
export class WorkerModule {}

2. Point both processes at the same Redis, so the API's panel can see what the worker captured — each process still has its own in-memory store by default, and in-memory is never shared across processes. Set the same environment variable in both:

NEST_DEBUG_PANEL_STORAGE=redis    # shared Redis store (reads NEST_DEBUG_PANEL_REDIS_URL, or REDIS_URL)
# (unset, or =memory)             # default: in-memory, per-process

NEST_DEBUG_PANEL_STORAGE=redis on its own is not enough — it selects the mode, it does not supply a connection. The panel still needs a Redis address from NEST_DEBUG_PANEL_REDIS_URL, REDIS_URL, or REDIS_HOST. If none of those is in your .env, it logs a warning and silently falls back to in-memory, and the Jobs list stays empty. Most apps already have one of them; if not, add one.

You already run Redis for BullMQ, so there's nothing new to install. With both pieces in place — the import in the worker's module, and the env var in both processes — one panel shows requests, sockets and the worker's jobs together.

  • The connection is auto-discovered from the Redis variables you already have (see Choosing which Redis below) — so most apps need no new variable at all.
  • Fail-safe: if no Redis address is found, or Redis is unreachable, it falls back to in-memory with a warning — it never breaks boot, but the panel then only shows that one process.
  • The worker doesn't need to serve HTTP — it just writes captures to Redis; the API process serves the panel and reads them back.
  • Single-process app (everything under one npm start)? You don't need any of this — the default in-memory store already shows jobs alongside requests.

Choosing which Redis

With NEST_DEBUG_PANEL_STORAGE=redis, the connection is resolved in this order — the first one found wins:

# Source Use it when
1 NEST_DEBUG_PANEL_REDIS_URL You want to name the instance explicitly. Overrides everything below.
2 REDIS_URL You have one Redis and it's already in this variable. Nothing to add.
3 REDIS_HOST + REDIS_PORT (default 6379) + REDIS_PASSWORD + REDIS_DB You configure Redis in parts rather than as a URL. REDIS_HOST is what triggers this path — port/password/db alone are not enough.

If your app has more than one Redis, set NEST_DEBUG_PANEL_REDIS_URL — otherwise auto-discovery picks whatever is in REDIS_URL / REDIS_HOST, which in most projects is the cache instance, not the one you meant:

NEST_DEBUG_PANEL_STORAGE=redis
NEST_DEBUG_PANEL_REDIS_URL=redis://queue-redis:6379/5   # wins; REDIS_URL is ignored entirely

A different database number (/5 above) is the easiest way to keep debug profiles off your live keyspace while reusing an instance you already run. Either way the store is namespaced under a nest-debug-panel: key prefix and every profile carries a 1-hour TTL, so it stays out of the way of your application's keys.

Use the same connection and prefix in every process — the API and the worker only see each other's captures if they read and write the same keys.

Prefer to hand over a client you already manage — including a specific one out of several — instead of a URL? Pass the storage driver directly and it takes precedence over all env vars:

import IORedis from 'ioredis';
import { DebugModule, RedisStorage } from 'nest-debug-panel';

const queueRedis = new IORedis(process.env.QUEUE_REDIS_URL);

DebugModule.forRoot({
  // reuses your connection; the panel never closes a client it didn't open
  storage: new RedisStorage({ client: queueRedis, prefix: 'nest-debug-panel', ttlSeconds: 3600 }),
});

Database, Redis and HTTP capture is automatic

At startup the panel scans your app's providers and instruments what it recognizes. In most projects you install the package and queries just show up:

It finds You get
PrismaClient (or a service extending it) every operation with timing, counts, N+1 detection
ioredis / node-redis client every command with args and timing
TypeORM DataSource every SQL statement with params and timing
HttpService / axios instance every outgoing call with status and timing

You can turn this off with autoInstrument: false. A few things still need one line of manual setup, simply because they're constructor options in the library itself:

  • Prisma raw SQL text. On Prisma 7+ with a driver adapter (e.g. @prisma/adapter-pg) auto-instrumentation captures raw SQL with zero config — it wraps the adapter directly, so you get the actual query text, params and timing without touching how the client is created. Each row is tagged with the ORM model and operation that produced it, so you see the SQL and its User.findMany context together (like Laravel Telescope / Django Silk). On older Prisma (no driver adapter) raw SQL still comes from query events, which require the client be created with log: [{ emit: 'event', level: 'query' }]; without either, you'll see User.findMany (12ms) instead of the SQL, and a one-time hint tells you how to enable it.
  • Mongoose, Drizzle and Knex hook in at construction time, so wire them explicitly (two lines each, shown below).
  • Clients created outside Nest's DI container.

Configuration

Everything is optional. These are the defaults:

DebugModule.forRoot({
  enabled: process.env.NODE_ENV !== 'production', // NEST_DEBUG_PANEL_ENABLED env var overrides this
  maxRequests: 200,             // how many profiles to keep; oldest are evicted
  captureRequestBody: true,
  captureResponseBody: true,
  captureHeaders: true,
  captureMemory: true,
  captureSql: true,
  captureRedis: true,
  captureHttp: true,
  captureLogs: true,            // capture console.* emitted during a request (Logs monitor)
  sockets: true,                // capture socket.io gateway events (automatic, no per-gateway setup)
  jobs: true,                   // capture background jobs / messages / scheduled runs (automatic)
  captureJobData: true,         // capture the job payload (job.data), redacted
  autoInstrument: true,         // scan providers and hook them automatically
  slowQueryThreshold: 100,      // ms; queries at or above get flagged
  slowRequestThreshold: 500,    // ms; requests at or above get flagged
  nPlusOneThreshold: 5,         // repeated SELECTs before the N+1 warning fires
  routePrefix: '/__debug',
  ignore: ['/health', '/docs*', /^\/webhooks\//],
  runtimeIgnore: true,          // let the dashboard manage its own ignore list (Ignored button)
  redactKeys: ['password', 'secret', 'token', ...],
  redactHeaders: ['authorization', 'cookie', 'set-cookie', 'x-api-key'],
  maxBodyLength: 65536,         // bytes kept per captured body
  getUser: (req) => (req as any).user,
  authorize: (req) => true,     // gate the dashboard, e.g. admins only
  storage: undefined,           // custom DebugStorage instance; mode is set via NEST_DEBUG_PANEL_STORAGE
  plugins: [],
});

forRootAsync({ imports, useFactory, inject, routePrefix }) works too. Note that routePrefix must be static in async mode, because routes are registered before async factories run.

Enabling & disabling

The panel resolves its on/off state with this precedence (first match wins):

  1. NEST_DEBUG_PANEL_ENABLED environment variable — when set to a recognized boolean, it overrides everything below, so you can flip the panel on or off without changing code.
  2. The enabled option passed to forRoot() / forRootAsync().
  3. Default — on when NODE_ENV !== 'production', off otherwise.
NEST_DEBUG_PANEL_ENABLED=true   # force ON anywhere — even in production
NEST_DEBUG_PANEL_ENABLED=false  # force OFF anywhere — even in development
# (unset)                       # fall back to the `enabled` option, then NODE_ENV

Accepted values are case-insensitive: true / 1 / yes / on enable it, false / 0 / no / off disable it. Anything unrecognized (or an unset/empty var) is ignored, so the option and NODE_ENV default still apply. When disabled, the interceptor passes every request straight through, nothing is instrumented or stored, and the dashboard routes return 404.

To exclude routes from profiling, use the ignore option ('/health', globs like '/static/*', or RegExps), put @DebugIgnore() on a controller or handler, or add a rule from the dashboard — see Ignoring noisy endpoints next. The panel's own routes are always excluded.

Ignoring noisy endpoints

Behind a load balancer, /health gets hit every second or two. In a 200-profile buffer that means 190 health checks and 10 real requests — the ones you opened the panel to look at are already evicted.

Click Ignored in the topbar to fix that without a redeploy. Add /health, and matching requests are dropped at capture time, so the buffer fills with traffic you care about instead:

In the dialog What it does
Add Start ignoring a path. /health also covers /health/db; * matches anything (/v1/*/ping).
Edit Change the pattern or its note.
on toggle Stop applying a rule without deleting it — useful when you want one look at that endpoint.
Delete Capture it again.

A match ends the request's journey through the panel then and there — nothing is counted, stored or reported for it, so an ignored endpoint costs a few string comparisons and nothing else.

Two things worth knowing:

  • Rules apply to future requests only. Health checks already in the list stay there — hit Clear once after adding the rule.
  • Rules are saved in the active storage driver. With the default in-memory store they last as long as the process; with NEST_DEBUG_PANEL_STORAGE=redis they're shared by every process and survive restarts, so adding /health in the API panel also stops the worker from capturing it (within ~15 seconds, the shared-store refresh interval). With the default in-memory store there is nothing to refresh, so the panel does no background work at all.

Patterns from the ignore option appear in the same list marked read-only — one place to see every reason a request might be missing. To turn editing off entirely and keep the dialog as a viewer, set runtimeIgnore: false.

The rules are plain HTTP, so scripts and provisioning can use them too:

curl -X POST     'localhost:3000/__debug/filters?pattern=/health&note=k8s%20probe'
curl -X PATCH    'localhost:3000/__debug/filters/<id>?enabled=false'
curl -X DELETE   'localhost:3000/__debug/filters/<id>'
curl -H 'accept: application/json' 'localhost:3000/__debug?feed=filters'

Works with any ORM, any database

Nothing database-specific lives in the core. Adapters hook the ORM rather than the database driver, so the database underneath doesn't matter: PostgreSQL, MySQL, SQLite, SQL Server, MongoDB, anything.

ORM / query builder Adapter Raw SQL Timing
Prisma PrismaPlugin
TypeORM TypeOrmPlugin
Sequelize SequelizePlugin
Knex (and Objection.js, Bookshelf) KnexPlugin
Mongoose MongoosePlugin operations + args
Drizzle DrizzlePlugin

Every adapter is fail-open (a broken hook never breaks a query), has zero dependency on the ORM package itself, and costs nothing outside profiled requests.

Prisma

// debug.plugins.ts — one shared instance
import { PrismaPlugin } from 'nest-debug-panel';
export const prismaPlugin = new PrismaPlugin();
// app.module.ts
DebugModule.forRoot({ plugins: [prismaPlugin] })
// prisma.service.ts
const client = new PrismaClient({
  log: [{ emit: 'event', level: 'query' }],   // raw SQL + params + duration
});
prismaPlugin.attach(client);
export const db = client.$extends(prismaPlugin.extension());

Why two steps? Prisma emits raw query events from its engine, outside the request's async context. The extension runs inside it and ties the two together. Skip step one and you still get operation-level events from the extension alone.

TypeORM

const typeormPlugin = new TypeOrmPlugin();
// DebugModule.forRoot({ plugins: [typeormPlugin] })
typeormPlugin.attach(dataSource); // after DataSource.initialize()

Sequelize

const sequelizePlugin = new SequelizePlugin();
// DebugModule.forRoot({ plugins: [sequelizePlugin] })
sequelizePlugin.attach(sequelize); // your existing `logging` option keeps working

Mongoose

const mongoosePlugin = new MongoosePlugin();
// DebugModule.forRoot({ plugins: [mongoosePlugin] })
mongoosePlugin.attach(mongoose); // the imported mongoose instance

Knex / Objection.js / Bookshelf

const knexPlugin = new KnexPlugin();
// DebugModule.forRoot({ plugins: [knexPlugin] })
knexPlugin.attach(knex);

Drizzle

const drizzlePlugin = new DrizzlePlugin();
// DebugModule.forRoot({ plugins: [drizzlePlugin] })
const db = drizzle(pool, { logger: drizzlePlugin.logger() });

Using something else? Implement the DebugPlugin interface and call recorder.recordSql(...) from whatever query hook your ORM exposes. All types are exported from the package root.

Redis

import { RedisPlugin } from 'nest-debug-panel';
export const redisPlugin = new RedisPlugin();

// DebugModule.forRoot({ plugins: [redisPlugin] })
redisPlugin.attach(ioredisClient); // ioredis and node-redis v4+

Every command (GET, SET, DEL, HSET, ...) is recorded with its arguments, duration, and any error.

HTTP clients

import { AxiosPlugin, FetchPlugin } from 'nest-debug-panel';

const axiosPlugin = new AxiosPlugin();
// DebugModule.forRoot({ plugins: [axiosPlugin, new FetchPlugin()] })

axiosPlugin.attach(this.httpService.axiosRef); // Nest HttpService
axiosPlugin.attach(axiosInstance);             // or any axios instance
// FetchPlugin patches globalThis.fetch and restores it on shutdown

Captured: URL, method, status, duration, request size, response size, errors.

Custom timeline marks

Inject DebugContextService anywhere and annotate the timeline yourself:

constructor(private readonly debug: DebugContextService) {}

async handle() {
  this.debug.mark('Cache warm-up', 4.2);        // shows on the timeline
  this.debug.setCustom('tenantId', tenant.id);  // stored on the profile
}

The API behind the dashboard

The dashboard is plain HTML served by the package, but the same routes speak JSON. Browsers get HTML, everything else gets JSON:

Route Method Returns
/__debug GET request list
/__debug/:id GET full profile with timeline, SQL, Redis, HTTP, exception, memory, headers, body, response
/__debug DELETE clears history
/__debug?feed=filters GET ignore rules + whether they're editable
/__debug/filters POST add an ignore rule (pattern, optional note)
/__debug/filters/:id PATCH update pattern, note or enabled
/__debug/filters/:id DELETE delete one rule

Rule writes accept a JSON body or query parameters, so they work even if the host app boots without a body parser. A rejected rule answers 400 with { ok: false, error } — the reason is safe to show to a user.

Build your own frontend or tooling on top of it if you like.

Security

  • Off in production automatically, unless you set enabled: true or NEST_DEBUG_PANEL_ENABLED=true (see Enabling & disabling). When off, the interceptor passes requests straight through and the debug routes return 404.

  • Not affected by your app's global interceptors or rate limiter. The panel writes responses straight to the adapter, so a global response envelope ({ hasErrors, data, errors }) can't corrupt its HTML or JSON — and its routes opt out of @nestjs/throttler, so the dashboard's 2-second poll never trips a global ThrottlerGuard and gets Too Many Requests back. Both are automatic; there is nothing to configure. If your ThrottlerModule uses custom throttler names (anything other than default, short, medium or long), add a skipIf for the panel's prefix, since the skip flag is per name:

    ThrottlerModule.forRoot({
      throttlers: [{ name: 'api', ttl: 60_000, limit: 60 }],
      skipIf: (ctx) => (ctx.switchToHttp().getRequest().url ?? '').startsWith('/__debug'),
    })
  • Sensitive body keys and headers are redacted before anything is stored.

  • Gate the dashboard with authorize: (req) => req.user?.isAdmin === true.

  • Profiles live in process memory by default and never leave your machine.

Storage

The default store is an in-memory ring buffer that keeps the latest maxRequests profiles — perfect for a single-process app.

  • Running an API + worker as separate processes? Set NEST_DEBUG_PANEL_STORAGE=redis so one panel shows everything — see Multi-process apps (API + worker).
  • More than one Redis in your app? Point the panel at the one you want with NEST_DEBUG_PANEL_REDIS_URL — see Choosing which Redis.
  • Bringing your own driver? Implement the five-method DebugStorage interface (save/find/list/clear/count) and pass it as storage — a database or file store works the same way.

Example app

The repo ships a small demo with an endpoint for every feature:

npm run example
# open http://localhost:3000/__debug

Try /users, POST /users (redaction), /n-plus-one (N+1 warning), /slow (slow flags), /external (fetch capture), and /boom (exception).

How it works

DebugModule.forRoot()
 ├─ DebugInterceptor (global)      builds a profile per request, runs the
 │                                 handler inside AsyncLocalStorage
 ├─ DebugContextService            per-request context + recorder API
 ├─ AutoInstrumentService          scans providers, hooks what it recognizes
 ├─ PluginManager                  registers plugins, dispatches lifecycle hooks
 ├─ DebugStorage                   pluggable persistence (ring buffer default)
 └─ DebugController                JSON API + server-rendered dashboard

No global mutable state; every request gets an isolated context. Express and Fastify are both covered by the integration test suite. And everything is fail-open: if anything inside the panel ever breaks, profiling is skipped for that request and your app keeps running as if the package wasn't there.

Contributing

Contributions are welcome — bug reports, adapters for other ORMs, docs, or fixes.

npm install
npm test        # Jest suite
npm run build   # type-check + compile

Open a pull request against master with tests for any change in src/. Keep new instrumentation fail-open, and use plain commit messages (no feat:/fix: prefixes).

Full guide: CONTRIBUTING.md · Code of Conduct · report a vulnerability via SECURITY.md.

License

MIT © Rakibul Islam

About

Zero-config request profiler & debug dashboard for NestJS, raw SQL queries with N+1 detection, Redis, outgoing HTTP calls, exceptions, memory and a per-request timeline. Works with Prisma, TypeORM, Sequelize, Knex, Drizzle & Mongoose.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages