Ahead of the release
npm install @evanion/nestjs-correlation-id gives you 2.0.0. These pages document main, which has changes that release does not.
Getting started
Register the module and the middleware
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import {
CorrelationIdMiddleware,
CorrelationModule,
} from '@evanion/nestjs-correlation-id';
@Module({
imports: [CorrelationModule.forRoot()],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
}
}forRoot() provides CorrelationService and the resolved configuration; the
middleware opens a correlation context per request.
forRoot() registers a global module, so importing it once in the root module is
enough. Call it once — a second forRoot() registers a second configuration
provider under the same token, and the last import wins.
Register the middleware early. Everything downstream of it — guards, interceptors, controllers, and anything they await — runs inside the context; anything applied before it does not.
What the middleware does per request
- Reads the configured header, case-insensitively. Node lowercases incoming header names, so only the response casing depends on how you spelled it.
- If a value arrived and passes
validate, that value is the correlation id. Otherwise it calls the generator. - Writes the id onto
req.headerswhen the request carried none, so anything reading the header rather than the service — a proxy, an access logger, a framework-level request logger — sees the same id. A header that arrived is left exactly as it came in, including onevalidaterejected. - Sets the response header, unless something has already set it.
- Runs the rest of the request inside
CorrelationService.run(id, next).
The generator runs only when nothing usable arrived, so a counter- or sequence-backed generator is not advanced for an id that gets discarded.
Reading the id
CorrelationService is a singleton. Inject it like any other provider:
import { CorrelationService } from '@evanion/nestjs-correlation-id';
import { Injectable } from '@nestjs/common';
@Injectable()
export class OrdersService {
constructor(private readonly correlation: CorrelationService) {}
async place(order: Order) {
this.logger.log({ correlationId: this.correlation.getCorrelationId() });
}
}getCorrelationId() is synchronous and returns undefined when there is no
correlation context. Outside one there genuinely is no correlation id, so it does
not invent one.
Nothing about injecting the service changes the scope of the provider holding it.
Adding it to logs
import { CorrelationService } from '@evanion/nestjs-correlation-id';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { IncomingMessage, ServerResponse } from 'node:http';
import * as Sentry from '@sentry/node';
@Injectable()
export class SentryTagMiddleware implements NestMiddleware {
constructor(private readonly correlation: CorrelationService) {}
use(_req: IncomingMessage, _res: ServerResponse, next: () => void) {
const correlationId = this.correlation.getCorrelationId();
if (correlationId) Sentry.setTag('correlationId', correlationId);
next();
}
}configure(consumer: MiddlewareConsumer) {
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
consumer.apply(SentryTagMiddleware).forRoutes('*');
}Order matters: CorrelationIdMiddleware is what opens the context, so anything
reading the id has to be applied after it.
Forwarding on outgoing calls
import { HttpModule } from '@nestjs/axios';
import { withCorrelation } from '@evanion/nestjs-correlation-id';
@Module({
imports: [HttpModule.registerAsync(withCorrelation())],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}Use HttpService as usual. It stays a singleton: the header is attached by an
axios request interceptor that reads the current context at the moment the
request is made, rather than being baked into the options object when the
factory runs.
That keeps the whole chain singleton-scoped. Options
computed at factory time would have to come from a request-scoped provider, and
HttpService — and every provider holding it — would become request-scoped with
it.
withCorrelation() takes ordinary HttpModuleOptions:
HttpModule.registerAsync(withCorrelation({ timeout: 5000 }));It needs CorrelationModule.forRoot() somewhere in the application. Without it,
Nest fails at boot:
Nest can't resolve dependencies of the HTTP_MODULE_OPTIONS (?)When there is no correlation context, no header is attached. An outgoing call from a cron job that never opened one carries none.
Work with no request behind it
Queue consumers, cron jobs, scripts — anything with no incoming request — opens a context itself:
await this.correlation.run(this.correlation.generate(), () =>
this.processJob(job),
);run returns whatever the callback returns, so an async callback’s promise comes
straight back out. Everything the callback awaits, schedules or calls sees the
id, and overlapping contexts stay isolated.
To carry an id that arrived on the job payload rather than minting one:
await this.correlation.run(
job.correlationId ?? this.correlation.generate(),
() => this.processJob(job),
);Validate a payload-supplied id the same way the middleware validates a header-supplied one — see the validator. An id from a queue reaches the same log lines a header-supplied one does.
Replacing the id
this.correlation.setCorrelationId('some_correlation_id');It throws outside a correlation context, rather than writing somewhere nothing will read:
setCorrelationId() was called outside a correlation context. Apply
CorrelationIdMiddleware, or wrap the work in CorrelationService.run().setCorrelationId changes the id the rest of the request sees. The response
header keeps the id the middleware wrote when the request arrived.
Why @evanion/nestjs-correlation-id has nothing to click
Every other section on this site puts a control under the reader’s hand on its demonstration page. This one does not, and the reason is the package rather than the budget: the demonstrable unit is two running services and one header between them, and nothing inside a documentation page can be the second process. A simulated caller would prove something about the simulation.
apps/shop-api in this repository is the real pair. It applies
CorrelationIdMiddleware to every route, and the shop’s storefront reads the
id back off the response, so the runnable version of this page is the shop with
both processes up.