Stamp every request with a unique ID at the edge, propagate it through guards, interceptors, pipes, the handler, error responses, log lines, and outbound HTTP calls. Turns “an error happened” into “request
8f2afailed at this exact step in this exact service”. Zero dependencies beyondnode:async_hooks.
How it works
AsyncLocalStorage (from node:async_hooks) is a per-async-context store: anything inside the als.run(store, callback) callback (and any await chain it spawns) sees the same store. A middleware opens the context once per request; every downstream layer (guards → interceptors → pipes → handler → exception filters) reads from the same store without anyone passing it as a parameter.
flowchart LR A[Request in] --> M["TraceMiddleware<br/>als.run({traceId}, ...)"] M --> rest[Guards → Interceptors → Pipes → Handler → Filters] rest --> R[Response with<br/>x-request-id header] rest -. logs .-> L[Logger reads traceId<br/>from store] rest -. outbound .-> H[HttpService propagates<br/>x-request-id header]
Setup
No npm install. AsyncLocalStorage is in Node’s standard library since 12.17. Optional: @nestjs/axios if you want outbound HTTP propagation (covered below).
npm install --save @nestjs/axios axios # optional, for outbound propagationStep 1: middleware opens the context
// trace/trace-context.ts
import { AsyncLocalStorage } from "node:async_hooks"
export interface TraceStore {
traceId: string
}
export const traceStorage = new AsyncLocalStorage<TraceStore>()
export const getTraceId = (): string | undefined => traceStorage.getStore()?.traceId// trace/trace.middleware.ts
import { randomUUID } from "node:crypto"
import { Injectable, NestMiddleware } from "@nestjs/common"
import { NextFunction, Request, Response } from "express"
import { traceStorage } from "./trace-context"
@Injectable()
export class TraceMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const inbound = req.headers["x-request-id"]
const traceId = (typeof inbound === "string" && inbound) || randomUUID()
res.setHeader("x-request-id", traceId)
traceStorage.run({ traceId }, () => next())
}
}// app.module.ts
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"
import { TraceMiddleware } from "./trace/trace.middleware"
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(TraceMiddleware).forRoutes("*")
}
}Why middleware, not a guard or interceptor? Middleware is the only layer that runs before guards and can wrap next() in als.run() so every downstream layer sees the store. Opening the context anywhere later means earlier layers see undefined.
Request:
curl -i http://localhost:3000/catsResponse:
HTTP/1.1 200 OK
x-request-id: 8f2a4c6e-7d10-4f4e-b9a1-2c5c1d9c6f8a
content-type: application/json
[]When the client sends an inbound X-Request-ID, it’s echoed back instead of replaced.
Trust inbound
X-Request-IDonly from trusted upstreamsThe middleware above echoes any client-supplied header. If your service sits directly on the public internet, attackers can poison your logs (
X-Request-ID: admin-action-success) or collide IDs deliberately to confuse incident response. Behind a reverse proxy / API gateway you control, accept the inbound value; consider stripping it at the proxy if you want to mint fresh IDs only there.
Step 2: logger that injects the trace ID into every line
The point of a trace ID is to find it in logs without sprinkling it through every Logger.log() call. Subclass Nest’s ConsoleLogger:
// trace/trace-logger.service.ts
import { ConsoleLogger, Injectable, Scope } from "@nestjs/common"
import { getTraceId } from "./trace-context"
@Injectable({ scope: Scope.TRANSIENT })
export class TraceLogger extends ConsoleLogger {
protected formatPid(pid: number): string {
const traceId = getTraceId()
const base = super.formatPid(pid)
return traceId ? `${base}[${traceId.slice(0, 8)}] ` : base
}
}// main.ts
import { NestFactory } from "@nestjs/core"
import { AppModule } from "./app.module"
import { TraceLogger } from "./trace/trace-logger.service"
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true })
app.useLogger(app.get(TraceLogger))
await app.listen(3000)
}
bootstrap()Scope.TRANSIENT is required for custom loggers so each context that injects the logger gets its own instance. Source: Custom logger.
TraceLoggerMUST beScope.TRANSIENTIf
TraceLoggeris the defaultScope.DEFAULT(singleton), Nest reuses one instance app-wide and theformatPidoverride won’t pick up the per-injection context name. The official Custom logger docs spell this out: easy to miss until logs lose their context names.
Log line for a request that hit traceId = 8f2a4c6e-...:
[Nest] 12345 - 04/28/2026, 10:42:13 AM [Nest][8f2a4c6e] LOG [CatsController] list() called
When a log line is emitted outside any request (bootstrap, a cron tick), getTraceId() returns undefined and the prefix is omitted: no crash, no fake ID.
Step 3: exception filter includes the trace ID in error bodies
The exception filter runs outside the controller and is the last code that touches the response. Reading the trace ID from the store (not the request) keeps the filter platform-agnostic and works even if upstream code mutated the request.
// trace/trace-exception.filter.ts
import { ArgumentsHost, Catch, HttpException, HttpStatus, Logger } from "@nestjs/common"
import { BaseExceptionFilter } from "@nestjs/core"
import { Response } from "express"
import { getTraceId } from "./trace-context"
@Catch()
export class TraceExceptionFilter extends BaseExceptionFilter {
private readonly logger = new Logger(TraceExceptionFilter.name)
catch(exception: unknown, host: ArgumentsHost): void {
const traceId = getTraceId()
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const status =
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
const message =
exception instanceof HttpException ? exception.getResponse() : "Internal server error"
this.logger.error({ traceId, status, exception })
response.status(status).json({
statusCode: status,
traceId,
message,
timestamp: new Date().toISOString(),
})
}
}Register globally via the [[nestjs/fundamentals/global-providers|APP_FILTER provider]] (so DI gives BaseExceptionFilter the HttpAdapter it needs):
// app.module.ts (additions)
import { APP_FILTER } from "@nestjs/core"
import { TraceExceptionFilter } from "./trace/trace-exception.filter"
@Module({
providers: [{ provide: APP_FILTER, useClass: TraceExceptionFilter }],
})
export class AppModule {}Request that triggers a NotFoundException:
curl -i http://localhost:3000/cats/999Response:
HTTP/1.1 404 Not Found
x-request-id: 8f2a4c6e-7d10-4f4e-b9a1-2c5c1d9c6f8a
content-type: application/json
{
"statusCode": 404,
"traceId": "8f2a4c6e-7d10-4f4e-b9a1-2c5c1d9c6f8a",
"message": "Cat 999 not found",
"timestamp": "2026-04-28T10:42:13.456Z"
}The same traceId shows in the error body, the response header, and the log line: three correlation points that a support ticket can quote.
Step 4: interceptor that times the handler with the trace ID
// trace/timing.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common"
import { Observable, tap } from "rxjs"
import { getTraceId } from "./trace-context"
@Injectable()
export class TimingInterceptor implements NestInterceptor {
private readonly logger = new Logger(TimingInterceptor.name)
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now()
const handler = context.getHandler().name
return next.handle().pipe(
tap(() => {
this.logger.log({ traceId: getTraceId(), handler, ms: Date.now() - start })
}),
)
}
}Bind globally via APP_INTERCEPTOR. Same trace ID appears in the timing log and the request’s other log lines, so you can grep 8f2a4c6e to reconstruct the whole request.
Step 5: propagate to outbound HTTP calls
When your service calls another service, forward the trace ID so the next hop’s logs are searchable on the same value. Use an axios request interceptor on HttpService:
// trace/http-trace.module.ts
import { HttpModule, HttpService } from "@nestjs/axios"
import { Module, OnModuleInit } from "@nestjs/common"
import { getTraceId } from "./trace-context"
@Module({
imports: [HttpModule],
exports: [HttpModule],
})
export class HttpTraceModule implements OnModuleInit {
constructor(private readonly http: HttpService) {}
onModuleInit(): void {
this.http.axiosRef.interceptors.request.use((config) => {
const traceId = getTraceId()
if (traceId) {
config.headers.set("x-request-id", traceId)
}
return config
})
}
}Now any consumer that injects HttpService automatically forwards the inbound trace ID. The downstream service, running the same middleware, picks it up via req.headers['x-request-id'] and continues the chain.
Non-HTTP entry points
AsyncLocalStorage works the same way for microservice handlers, BullMQ consumers, websocket gateways, and cron jobs, but HTTP middleware doesn’t run there, so you have to open the context yourself. The pattern is identical:
// queues/emails.processor.ts (BullMQ example)
import { Processor, WorkerHost } from "@nestjs/bullmq"
import { randomUUID } from "node:crypto"
import { Job } from "bullmq"
import { traceStorage } from "../trace/trace-context"
@Processor("emails")
export class EmailsProcessor extends WorkerHost {
async process(job: Job<{ traceId?: string; to: string }>) {
const traceId = job.data.traceId ?? randomUUID()
return traceStorage.run({ traceId }, () => this.send(job))
}
private async send(job: Job<{ to: string }>) {
/* … */
}
}The producer side stores getTraceId() into the job payload when enqueuing; the consumer re-opens the context with that value. Same idea for Kafka consumers, scheduled tasks (@Cron), and anything else that doesn’t go through Express.
When to reach for it
- Microservice or multi-service architecture where one user action spans 2+ services.
- Production debugging where “find every log line for this user’s failed checkout” is a daily question.
- Async background work (queues, schedules) you want to correlate with the user request that triggered it.
- Any system with
> 100 req/swhere unstructured logs become unsearchable without correlation.
When not to
- Single-process monolith with low traffic and a single log stream:
[ip:port]already gives enough context. - You’re already using OpenTelemetry: prefer the OTel
traceparentheader and span IDs. They subsume request IDs and add propagation across more transports. - Per-request DB transactions or per-request cached values: use
Scope.REQUESTproviders or a transactional outbox.AsyncLocalStorageis for observability context, not business state.
Correlation ID vs trace ID
Think of a correlation ID as a sticker: you slap the same value on every log line for one request, then grep by it. Flat list of logs.
A trace ID is the same sticker plus a GPS tracker: every step also records
spanId,parentSpanId, and start/end timestamps, so a tracing backend can reconstruct the call tree (gateway → orders → inventory → DB) with timings per hop. The ID itself is identical; the spans are what’s added.
Question you want to answer What you need ”Show me all logs for that one failed checkout.” Correlation ID ”Which of the 8 services in that checkout was slow, and what called what?” Trace ID This recipe builds the correlation-ID flavor and exposes it under the
trace-idname because the wire format and the lookup workflow are the same. Distributed tracing with W3Ctraceparentand OpenTelemetry spans is a planned separate recipe.
Gotchas
Use
als.run(), notals.enterWith()
enterWith(store)continues the store for the entire synchronous execution and into the current async resource. With Express, the next request handled on the same event-loop turn can see the previous request’s store until it hits its ownenterWith()call.run(store, callback)scopes the store to the callback’s async tree and unwinds cleanly. Source: Node docs: enterWith.
Don't use
Scope.REQUESTproviders as a substituteRequest-scoped providers don’t run in passport strategies, gateways, or scheduled tasks, and they recreate the entire DI subtree per request (significant CPU and GC cost). The motivation for
AsyncLocalStorageis precisely to fix the cases whereScope.REQUESTfails or costs too much.
Generate IDs with
crypto.randomUUID(), notMath.random()Cryptographically random from day one means the ID is safe to use later as a deduplication key, idempotency token, or rate-limit bucket.
Math.random()works for log correlation but locks you out of those upgrades.
Old C++ bindings can drop the async context
Mainstream libraries (
pg,redis,mysql2, axios, undici, BullMQ, RxJS, native promises) preserve context. Old callback-style libraries that schedule work from native bindings without registering anAsyncResourcemay not. Symptom:getTraceId()returnsundefineddeep inside a third-party callback. Fix: wrap the entry point innew AsyncResource('your-name').runInAsyncScope(...). Rare on actively-maintained libraries.
The interceptor + filter both read from the store: that's the point
A [[nestjs/fundamentals/interceptors|
LoggingInterceptor]], a [[nestjs/fundamentals/exception-filters|TraceExceptionFilter]], a service buried five layers deep, and an outbound axios call all read the sametraceIdwithout any of them taking it as a parameter. That’s the valueAsyncLocalStorageadds over passing it on the request object.
Common errors
| Symptom | Likely cause |
|---|---|
getTraceId() returns undefined in the controller | TraceMiddleware not registered, or registered for the wrong path. Use forRoutes('*') |
getTraceId() returns undefined in an exception filter | Filter bound with useGlobalFilters(new X()) in a hybrid app. Gateways/microservices skip it. Use APP_FILTER |
getTraceId() returns undefined in a queue consumer | HTTP middleware doesn’t run for queue handlers. Open the context manually with traceStorage.run() in the processor |
getTraceId() returns undefined after await someThirdParty() | Library doesn’t preserve async context. Wrap with new AsyncResource('lib').runInAsyncScope(...) |
| Two concurrent requests show the same trace ID in logs | Used enterWith() instead of run(). Switch to run() |
| Custom logger context prefix never appears | TraceLogger registered without Scope.TRANSIENT |
Outbound axios calls don’t include x-request-id | Interceptor registered on a fresh axios instance, not on HttpService.axiosRef |
| Trace ID changes mid-request | A library is calling als.run() on its own. Audit middlewares; only TraceMiddleware should call run() |
See also
- Middleware: where the context is opened. The lifecycle reason this is the only correct layer.
- Exception filters: where the trace ID lands in the error body.
- Interceptors: where the trace ID prefixes timing and structured logs.
- Global pipes, guards, interceptors, and filters via DI: how
APP_FILTERandAPP_INTERCEPTORregister the trace-aware versions. - Request lifecycle hub: where in the pipeline each piece runs.
- Official: Async Local Storage recipe, Custom logger, HTTP module.
- Node:
AsyncLocalStorageAPI.