← Back to the section

Imagine: the logs show hundreds of records from different requests, all mixed together. How do you tell which of them belong to the specific request that failed with an error? For that, each request must carry its own identifier — requestId — and it must automatically land in every log record produced by that request.

In Java, MDC (Mapped Diagnostic Context) is used for this — a thread-local store where each thread keeps its own data. Node.js has no threads: the event loop is single, and all requests pass through it. AsyncLocalStorage is the Node.js analogue of MDC: a container that stores data separately for each request's "async tree".

What AsyncLocalStorage is

AsyncLocalStorage is a mechanism built into Node.js (the async_hooks module). Its essence: you run a function through als.run(store, fn), and everything called inside fn — including all awaits and nested asynchronous calls — sees store. Other requests get their own store and don't intersect.

This means requestId can be placed into ALS once at the start of a request, and after that it's available in any service, repository or logger — without passing it explicitly through arguments.

nestjs-pino does exactly this: on every incoming HTTP request it creates an ALS context with a unique requestId and a per-request logger that automatically adds requestId to every record.

Setting up nestjs-pino

Configuration via LoggerModule.forRoot in the root module:

// src/app.module.ts
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        genReqId: (req) => req.headers['x-request-id'] ?? randomUUID(),
        customSuccessMessage: () => 'request completed',
        customErrorMessage: (_req, res) => `request failed with status ${res.statusCode}`,
        redact: ['req.headers.authorization', '*.password', '*.email', '*.phone'],
        transport: process.env.NODE_ENV !== 'production'
          ? { target: 'pino-pretty', options: { colorize: true } }
          : undefined,
      },
    }),
  ],
})
export class AppModule {}

genReqId is the function that determines the requestId for each request. If the client sent X-Request-Id in a header — we use it (this is handy for tracing between services). Otherwise we generate a UUID. nestjs-pino also returns the requestId in the response so the client can use it when investigating a problem.

Using the logger in a service:

// src/order/order.service.ts
import { PinoLogger, InjectPinoLogger } from 'nestjs-pino';

@Injectable()
export class OrderService {
  constructor(
    @InjectPinoLogger(OrderService.name)
    private readonly logger: PinoLogger,
  ) {}

  async confirmOrder(orderId: string, customerId: string): Promise<void> {
    this.logger.info({ orderId, customerId }, 'confirming order');
    // requestId and trace_id are already in ALS — pino will add them automatically
  }
}

console.log and new Logger() from @nestjs/common know nothing about ALS — records made through them won't contain requestId. Use @InjectPinoLogger() everywhere.

trace_id and span_id from OpenTelemetry

Besides requestId, the logs need trace_id and span_id — the identifiers of the current trace. They're needed so that in Grafana/Tempo you can jump from a specific log record to the full call tree.

@opentelemetry/instrumentation-pino adds trace_id and span_id automatically to every pino record — nothing extra needs to be written. The key condition: tracing.ts must be imported as the very first line of main.ts, before everything else.

// src/tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';

const sdk = new NodeSDK({
  serviceName: process.env.OTEL_SERVICE_NAME ?? 'order-service',
  traceExporter: new OTLPTraceExporter(),
  instrumentations: [
    getNodeAutoInstrumentations(),
    new PinoInstrumentation(),  // adds trace_id/span_id to every log
  ],
});

sdk.start();
// src/main.ts
import './tracing';  // first — before everything else
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

If tracing.ts is imported later — the modules are already loaded without the patch, and the integration won't work. Don't add trace_id to the logs by hand via logger.assign({ traceId: ... }): when the active span changes, the field goes stale.

userId in a guard after JWT validation

requestId is set at the start of the request — before authentication. userId is available only after JWT validation. So userId must be added to the ALS context in a guard, not in middleware.

// src/common/guards/jwt-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { PinoLogger, InjectPinoLogger } from 'nestjs-pino';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly jwtService: JwtService,
    @InjectPinoLogger(JwtAuthGuard.name)
    private readonly logger: PinoLogger,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.replace('Bearer ', '');

    const payload = this.jwtService.verify(token);
    request.user = payload;

    this.logger.assign({ userId: payload.sub });  // ALS enrichment — here and only here
    return true;
  }
}

nestjs-pino's logger.assign enriches the ALS context of the current request — all subsequent log records within this request will contain userId. The guard runs after the nestjs-pino middleware, which means ALS is already initialized.

Don't add userId inside a service method: there the cleanup logic is not obvious, and the context will be polluted for other calls.

BullMQ: where the context goes

Here's where there's a real trap. AsyncLocalStorage works only within a single async tree. When you put a job into a BullMQ queue — the job goes off to a separate worker_thread or a separate process. ALS doesn't cross over: the serialized payload crosses the thread boundary, and with it — no context at all.

The solution is simple: pass requestId and traceparent explicitly in the job payload when enqueuing, and restore the context in the handler.

Enqueuing a job:

// src/order/order.service.ts
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { context, propagation } from '@opentelemetry/api';
import { ClsService } from 'nestjs-cls';

@Injectable()
export class OrderService {
  constructor(
    @InjectQueue('notifications') private readonly notificationsQueue: Queue,
    @InjectPinoLogger(OrderService.name) private readonly logger: PinoLogger,
    private readonly cls: ClsService,
  ) {}

  async placeOrder(customerId: string, items: OrderItem[]): Promise<string> {
    const order = await this.ordersRepository.create(customerId, items);

    const carrier: Record<string, string> = {};
    propagation.inject(context.active(), carrier);  // serialize traceparent + tracestate

    await this.notificationsQueue.add('order-placed', {
      orderId: order.id,
      customerId,
      requestId: this.cls.get<string>('requestId'),  // take it explicitly from the context
      traceparent: carrier['traceparent'],
      tracestate: carrier['tracestate'],
    });

    this.logger.info({ orderId: order.id }, 'order placed, notification queued');
    return order.id;
  }
}

The job handler — we restore the context:

// src/notifications/processors/order-placed.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { propagation, context, trace } from '@opentelemetry/api';
import { PinoLogger, InjectPinoLogger } from 'nestjs-pino';

@Processor('notifications')
export class OrderPlacedProcessor extends WorkerHost {
  constructor(
    @InjectPinoLogger(OrderPlacedProcessor.name)
    private readonly logger: PinoLogger,
    private readonly notificationService: NotificationService,
  ) {
    super();
  }

  async process(job: Job): Promise<void> {
    const { orderId, customerId, requestId, traceparent, tracestate } = job.data;

    const carrier = { traceparent, tracestate };
    const parentContext = propagation.extract(context.active(), carrier);

    await context.with(parentContext, async () => {
      this.logger.assign({ requestId, orderId, customerId });
      this.logger.info({ orderId }, 'processing order-placed notification');

      await this.notificationService.sendOrderConfirmation(customerId, orderId);
    });
  }
}

context.with(parentContext, fn) sets the OTel context for the entire async tree inside fn. This links the handler's span with the original trace — in Tempo you see a single call tree, not two disconnected ones.

nestjs-cls as an alternative

If the project uses nestjs-cls (ClsModule) instead of raw ALS — the pattern is analogous, but through ClsService:

// src/common/interceptors/request-context.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Observable } from 'rxjs';
import { randomUUID } from 'node:crypto';

@Injectable()
export class RequestContextInterceptor implements NestInterceptor {
  constructor(private readonly cls: ClsService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest();
    const requestId = request.headers['x-request-id'] ?? randomUUID();
    this.cls.set('requestId', requestId);
    return next.handle();
  }
}

In the guard after JWT:

this.cls.set('userId', payload.sub);

In services — read only, don't write:

const requestId = this.cls.get<string>('requestId');
this.logger.info({ orderId, requestId }, 'product reserved');

The order of layers in NestJS

It's important to understand in which order the layers run — this affects when ALS is already ready and when it's not yet:

HTTP Request
  └─ nestjs-pino middleware  ← creates ALS, genReqId → X-Request-Id
       └─ OTel (tracing.ts)  ← reads traceparent, creates a span
            └─ Guards         ← logger.assign({ userId })
                 └─ Interceptors
                      └─ Controller → Service

LoggerModule is registered via app.use(logger) in main.ts before app.listen(). OTel patches the http module even earlier, at the tracing.ts level — before the application starts.

Common mistakes

A shared mutable store instead of per-request ALS. If you keep requestId in a regular object or a module-level variable — one request's value overwrites another's. A neighboring request's userId leaking into the logs is a serious problem. Use nestjs-pino's per-request ALS or ClsModule.

logger.assign({ userId }) inside a service method. The cleanup logic is not obvious, and subsequent calls may get a stale userId. Enrich the context only in middleware, a guard or an interceptor.

A BullMQ job without requestId and traceparent in the payload. Without passing them explicitly there's no way to link the handler's log records with the original request.

logger.assign({ traceId: span.traceId }) by hand. When the active span changes, the field goes stale. Use @opentelemetry/instrumentation-pino.

tracing.ts is imported after NestFactory. The modules are already loaded without the OTel patch, the integration doesn't work. import './tracing' — the first line of main.ts.

In short

  • AsyncLocalStorage is the Node.js analogue of Java's MDC: it stores data separately for each request's async tree, without passing it explicitly through arguments.
  • nestjs-pino creates a per-request ALS context via genReqIdrequestId is automatically in every record.
  • trace_id and span_id are added by @opentelemetry/instrumentation-pino — no need to set them by hand.
  • Import tracing.ts as the first line of main.ts — before NestFactory.
  • Add userId to ALS only in a guard after JWT validation, not in middleware and not in services.
  • BullMQ breaks ALS — pass requestId and traceparent explicitly in the job payload and restore them via propagation.extract in the handler.
  • Context enrichment (logger.assign, cls.set) — only in middleware, a guard, an interceptor. In services — read only.
  • Logging in NestJS — the structure of a log record, redact for personal data, error handling.
  • Tracing in NestJS — NodeSDK, active spans, sampling, data restrictions.
  • Metrics in NestJS — RED histograms, collectDefaultMetrics, low-cardinality labels.
  • Health checks in NestJS — liveness/readiness via @nestjs/terminus.