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 8f2a failed at this exact step in this exact service”. Zero dependencies beyond node: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 (guardsinterceptorspipes → 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 propagation

Step 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/cats

Response:

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.

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.

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/999

Response:

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/s where 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 traceparent header and span IDs. They subsume request IDs and add propagation across more transports.
  • Per-request DB transactions or per-request cached values: use Scope.REQUEST providers or a transactional outbox. AsyncLocalStorage is for observability context, not business state.

Gotchas

Common errors

SymptomLikely cause
getTraceId() returns undefined in the controllerTraceMiddleware not registered, or registered for the wrong path. Use forRoutes('*')
getTraceId() returns undefined in an exception filterFilter bound with useGlobalFilters(new X()) in a hybrid app. Gateways/microservices skip it. Use APP_FILTER
getTraceId() returns undefined in a queue consumerHTTP 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 logsUsed enterWith() instead of run(). Switch to run()
Custom logger context prefix never appearsTraceLogger registered without Scope.TRANSIENT
Outbound axios calls don’t include x-request-idInterceptor registered on a fresh axios instance, not on HttpService.axiosRef
Trace ID changes mid-requestA library is calling als.run() on its own. Audit middlewares; only TraceMiddleware should call run()

See also