nestjs-query-profiler
v0.1.0
Published
Detect N+1 queries in NestJS applications with TypeORM or Prisma
Maintainers
Readme
nestjs-query-profiler
Detect N+1 queries in NestJS applications. Works with TypeORM and Prisma. Shows you the line in your code that caused the problem — not a wall of framework internals.
What it does
Groups queries fired within a single request by their normalized shape. When the same shape fires threshold or more times, it warns with the call stack trimmed to your code:
[QueryProfiler] (req-abc) 12 queries in 43ms
N+1 3x SELECT * FROM "USER" WHERE ID = ? (15ms total)
at UserService.findAll (/src/user/user.service.ts:24:18)
at UserController.list (/src/user/user.controller.ts:12:28)It also reports slow queries when slowQueryThresholdMs is set:
[QueryProfiler] 1 query in 820ms
SLOW 812ms SELECT * FROM "REPORT" WHERE tenant_id = ?Install
npm install nestjs-query-profilerPeer dependencies — install whichever ORM you use:
npm install @nestjs/common @nestjs/core rxjs reflect-metadata
npm install typeorm # TypeORM users
npm install @prisma/client # Prisma usersSetup
1. Register the module
// app.module.ts
import { NestjsQueryProfilerModule } from 'nestjs-query-profiler';
@Module({
imports: [
NestjsQueryProfilerModule.forRoot({
threshold: 2,
reporter: 'console',
enabledEnvironments: ['development', 'test'],
}),
],
})
export class AppModule {}The module is global and automatically installs the request middleware and response interceptor across all routes.
Fastify users: the auto-wired middleware uses
forRoutes('*')which only works with the default Express adapter. If you use@nestjs/platform-fastify, setautoInstall: falseand applyQueryProfilerMiddlewaremanually withforRoutes('(.*)').
2a. TypeORM adapter
Wire the logger into your DataSource. The PROFILER_OPTIONS token gives you the resolved config via NestJS DI:
import { TypeOrmModule } from '@nestjs/typeorm';
import { PROFILER_OPTIONS } from 'nestjs-query-profiler';
import { TypeOrmQueryProfilerLogger } from 'nestjs-query-profiler/adapters/typeorm';
import type { ProfilerConfig } from 'nestjs-query-profiler';
TypeOrmModule.forRootAsync({
useFactory: (config: ProfilerConfig) => ({
type: 'postgres',
// ... connection options
maxQueryExecutionTime: 1000, // required for slow query tracking (see note below)
logger: new TypeOrmQueryProfilerLogger(config),
}),
inject: [PROFILER_OPTIONS],
})Slow query tracking with TypeORM: TypeORM only provides query duration for slow queries — those exceeding its own
maxQueryExecutionTimesetting. Set this on yourDataSourceto enable duration tracking. Queries that run faster thanmaxQueryExecutionTimealways report0ms. N+1 detection (which counts occurrences, not duration) is unaffected.
2b. Prisma adapter
Call installPrismaAdapter when you create your PrismaClient. It returns a new extended client — use this returned value for all queries:
import { installPrismaAdapter } from 'nestjs-query-profiler/adapters/prisma';
import { PROFILER_OPTIONS } from 'nestjs-query-profiler';
import type { ProfilerConfig } from 'nestjs-query-profiler';
import { PrismaClient } from '@prisma/client';
// In a NestJS provider:
@Injectable()
export class PrismaService {
readonly client: PrismaClient;
constructor(@Inject(PROFILER_OPTIONS) config: ProfilerConfig) {
this.client = installPrismaAdapter(new PrismaClient(), config) as PrismaClient;
}
}Requires Prisma >=4.7. Duration is measured per-operation, so slowQueryThresholdMs works without any additional setup.
Configuration
NestjsQueryProfilerModule.forRoot({
// Warn when the same query shape fires this many times in one request.
// Default: 2
threshold: 2,
// Suppress warnings for queries whose normalized key matches any entry.
// Accepts strings (substring match) or regular expressions.
// Default: []
ignore: ['SELECT 1', /^SELECT.*FROM sessions/],
// Only enable detection in these NODE_ENV values.
// Use ['*'] to always enable regardless of environment.
// Default: ['development', 'test']
enabledEnvironments: ['development', 'test'],
// Output format. 'console' prints colored output; 'json' writes
// newline-delimited JSON to stdout (useful in CI). Pass any object
// implementing IReporter for custom output.
// Default: 'console'
reporter: 'console',
// Console log method used for output.
// Default: 'warn'
logLevel: 'warn',
// Maximum number of user-code stack frames shown per violation.
// Default: 5
maxStackFrames: 5,
// Report slow queries even when no N+1 is detected. Set to 0 to disable.
// Default: 0
slowQueryThresholdMs: 500,
// Register the interceptor as APP_INTERCEPTOR automatically.
// Set to false to apply QueryProfilerInterceptor manually.
// Default: true
autoInstall: true,
})Async configuration
NestjsQueryProfilerModule.forRootAsync({
// autoInstall is resolved here, before the factory runs.
autoInstall: true,
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
threshold: config.get<number>('QUERY_THRESHOLD', 2),
enabledEnvironments: [config.get('NODE_ENV')],
slowQueryThresholdMs: config.get<number>('SLOW_QUERY_MS', 0),
}),
inject: [ConfigService],
})Custom reporter
Implement IReporter to route violations anywhere — Datadog, Slack, a database:
import type { IReporter, RequestSummary, ProfilerConfig } from 'nestjs-query-profiler';
class DatadogReporter implements IReporter {
report(summary: RequestSummary, _config: ProfilerConfig) {
for (const v of summary.violations) {
datadogMetrics.increment('db.n1.detected', {
query: v.normalizedKey,
occurrences: v.occurrences,
});
}
}
}
NestjsQueryProfilerModule.forRoot({ reporter: new DatadogReporter() })JSON reporter (CI)
NestjsQueryProfilerModule.forRoot({ reporter: 'json' })Each request summary is written to stdout as a single line of JSON. Pipe it to any log aggregator or jq:
node dist/main | jq 'select(.violations | length > 0)'Platform support
Express (default): works out of the box with no extra configuration.
Fastify: set autoInstall: false and apply the middleware manually in your root module:
import { QueryProfilerMiddleware } from 'nestjs-query-profiler';
@Module({
imports: [
NestjsQueryProfilerModule.forRoot({ autoInstall: false, ... }),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(QueryProfilerMiddleware).forRoutes('(.*)');
}
}Examples
The example/ directory contains ready-to-run demos:
| Command | Description |
|---------|-------------|
| pnpm run typeorm | N+1 detection with TypeORM + SQLite (standalone, no NestJS) |
| pnpm run prisma | N+1 detection with Prisma + SQLite (standalone, no NestJS) |
| pnpm run nestjs | Full NestJS app with controller/service/module — middleware + interceptor auto-wired |
| pnpm run features | Standalone script showcasing ignore patterns, pause/resume, custom reporter, and JSON output |
Each demo steps through multiple scenarios (N+1 bad pattern, eager-fixed pattern, slow query) with annotated console output.
How it works
- Middleware opens an
AsyncLocalStoragecontext at the start of each request. All async work spawned by the handler inherits this context automatically. - ORM adapters record each query into the context store. The call stack is captured synchronously at emission time — before any
await— so it correctly points to the service or repository that issued the query. - Normalization strips runtime values from queries to produce a stable grouping key. TypeORM SQL is normalized by regex (string literals and numeric values replaced with
?). Prisma uses the structuredaction on Modelform (e.g.,findMany on User), which is provider-agnostic. - Interceptor reads the store after the handler resolves via
finalize(), which fires on both success and error paths. It calls the reporter if any query shape hit the threshold or a slow query was detected.
License
MIT
