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.
Install it:
npm install nest-debug-panelRegister 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.
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'sHttpService - 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
@SubscribeMessagehandlers 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)
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 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.tsbootstrapped by its ownmain.ts/worker.ts): a NestJS module tree is only instrumented in the process that actually loads it, soDebugModule.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 standaloneNestFactory.createApplicationContext(WorkerModule)bootstrap works fine. Once both processes load it, setNEST_DEBUG_PANEL_STORAGE=redisin 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),
);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=redison its own is not enough — it selects the mode, it does not supply a connection. The panel still needs a Redis address fromNEST_DEBUG_PANEL_REDIS_URL,REDIS_URL, orREDIS_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.
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 entirelyA 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 }),
});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 itsUser.findManycontext 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 withlog: [{ emit: 'event', level: 'query' }]; without either, you'll seeUser.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.
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.
The panel resolves its on/off state with this precedence (first match wins):
NEST_DEBUG_PANEL_ENABLEDenvironment variable — when set to a recognized boolean, it overrides everything below, so you can flip the panel on or off without changing code.- The
enabledoption passed toforRoot()/forRootAsync(). - 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_ENVAccepted 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.
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=redisthey're shared by every process and survive restarts, so adding/healthin 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¬e=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'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.
// 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.
const typeormPlugin = new TypeOrmPlugin();
// DebugModule.forRoot({ plugins: [typeormPlugin] })
typeormPlugin.attach(dataSource); // after DataSource.initialize()const sequelizePlugin = new SequelizePlugin();
// DebugModule.forRoot({ plugins: [sequelizePlugin] })
sequelizePlugin.attach(sequelize); // your existing `logging` option keeps workingconst mongoosePlugin = new MongoosePlugin();
// DebugModule.forRoot({ plugins: [mongoosePlugin] })
mongoosePlugin.attach(mongoose); // the imported mongoose instanceconst knexPlugin = new KnexPlugin();
// DebugModule.forRoot({ plugins: [knexPlugin] })
knexPlugin.attach(knex);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.
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.
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 shutdownCaptured: URL, method, status, duration, request size, response size, errors.
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 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.
-
Off in production automatically, unless you set
enabled: trueorNEST_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 globalThrottlerGuardand getsToo Many Requestsback. Both are automatic; there is nothing to configure. If yourThrottlerModuleuses custom throttler names (anything other thandefault,short,mediumorlong), add askipIffor 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.
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=redisso 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
DebugStorageinterface (save/find/list/clear/count) and pass it asstorage— a database or file store works the same way.
The repo ships a small demo with an endpoint for every feature:
npm run example
# open http://localhost:3000/__debugTry /users, POST /users (redaction), /n-plus-one (N+1 warning), /slow (slow flags), /external (fetch capture), and /boom (exception).
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.
Contributions are welcome — bug reports, adapters for other ORMs, docs, or fixes.
npm install
npm test # Jest suite
npm run build # type-check + compileOpen 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.
MIT © Rakibul Islam