Tracing

Start the OpenTelemetry SDK before Nest loads, and let W3C trace context carry one trace across services.

Syntaxnew NodeSDK({ serviceName, traceExporter, instrumentations: [getNodeAutoInstrumentations()] })

A trace is one request's path through your system, assembled from spans: named, timed operations, each with attributes and a parent. Metrics tell you that 1% of requests are slow. A trace tells you which hop spent the time.

Starting it

OpenTelemetry works by patching libraries — http, pg, ioredis, @nestjs/core — as they load. So the SDK must run before anything else is imported. Put NodeSDK in a file of its own and preload it: node --import ./dist/telemetry.js dist/main.js. Importing it at the top of main.ts is already too late for whatever main.ts imported above that line.

NestJS 12 is published as ESM only: @nestjs/core and @nestjs/common declare "type": "module" and ship no CommonJS entry point, so a Nest 12 app is an ESM build and needs the loader hook on top of --import: --experimental-loader=@opentelemetry/instrumentation/hook.mjs. The CommonJS route, where a build that requires @nestjs/core is patched with no extra flags, only applies to Nest 11 and earlier.

Propagation

Context crosses a process boundary in headers. The default propagator is W3C trace context plus baggage, so an outgoing call from an instrumented HTTP client already carries traceparent, and the next service continues the same trace. For anything the SDK does not patch — a queue message, your own protocol — you inject and extract the carrier yourself with the propagation API.

What trips people up

@opentelemetry/sdk-node is still a 0.x package and documents breaking changes between releases. Pin the exact version and upgrade it deliberately.

Example

Example · typescript
// src/telemetry.ts
// Preloaded, never imported from main.ts:
//   node --import ./dist/telemetry.js dist/main.js
// An ESM build also needs:
//   --experimental-loader=@opentelemetry/instrumentation/hook.mjs
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { NodeSDK } from '@opentelemetry/sdk-node';

const sdk = new NodeSDK({
  // Becomes the service.name resource attribute; backends group by it.
  serviceName: process.env.OTEL_SERVICE_NAME ?? 'orders-api',
  traceExporter: new OTLPTraceExporter({
    url:
      process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ??
      'http://localhost:4318/v1/traces',
  }),
  // The metapackage turns on http, express, pg, ioredis, nestjs-core and more.
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-http': {
        // Probes and scrapes would otherwise be most of your traces.
        ignoreIncomingRequestHook: (req) =>
          req.url === '/health/live' ||
          req.url === '/health/ready' ||
          req.url === '/metrics',
      },
    }),
  ],
});

// Synchronous, returns void.
sdk.start();

process.on('SIGTERM', () => {
  // Spans are batched: without a flush the last few are lost on exit.
  void sdk.shutdown().finally(() => process.exit(0));
});

When to use it

  • Find which downstream call makes one endpoint slow, without adding timing logs.
  • See the SQL a single request issued, in order, with durations.
  • Follow one user action through gateway, API and worker as a single trace.
  • Open the trace for a failed request straight from its log line.

More examples

A span around work worth naming

The automatic instrumentation already gives you one span per request and one per query. Add manual spans for the steps that have business meaning or that call something unpatched, and put ids on them as attributes so you can find a specific trace later.

Example · typescript
// src/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { SpanStatusCode, trace } from '@opentelemetry/api';
import { OrdersRepository } from './orders.repository.js';

// One tracer per module or library; the name and version appear on the span
// as its instrumentation scope.
const tracer = trace.getTracer('orders-api', '1.4.0');

@Injectable()
export class OrdersService {
  constructor(private readonly orders: OrdersRepository) {}

  async settle(orderId: string): Promise<number> {
    // startActiveSpan makes this span the parent of everything awaited
    // inside the callback, including the SQL spans the pg instrumentation
    // creates, and returns whatever the callback returns.
    return tracer.startActiveSpan('settle order', async (span) => {
      span.setAttribute('order.id', orderId);

      try {
        const totalCents = await this.orders.settle(orderId);
        span.setAttribute('order.total_cents', totalCents);
        return totalCents;
      } catch (error) {
        span.recordException(error as Error);
        span.setStatus({ code: SpanStatusCode.ERROR, message: 'settle failed' });
        throw error;
      } finally {
        // A span that is never ended is never exported. finally covers both
        // the success and the failure path.
        span.end();
      }
    });
  }
}

Trace ids in log lines

Do this once, early. Logs and traces answer different questions, and the only cheap way to move between them is a shared id: traceId in the log line, log lines filtered by traceId. Put the helper in a custom logger if you want it on every line without the spread.

Example · typescript
// src/observability/trace-fields.ts
import { trace } from '@opentelemetry/api';

// Returns an empty object when no span is active, for example when the SDK
// was not preloaded or the call came from a startup hook.
export function traceFields(): { traceId?: string; spanId?: string } {
  const span = trace.getActiveSpan();

  if (!span) {
    return {};
  }

  const { traceId, spanId } = span.spanContext();
  return { traceId, spanId };
}

// src/orders/shipping.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { traceFields } from '../observability/trace-fields.js';

@Injectable()
export class ShippingService {
  private readonly logger = new Logger(ShippingService.name);

  dispatch(orderId: string, carrier: string): void {
    // The ids go into the structured params, so one log query opens the
    // matching trace and one trace links back to its log lines.
    this.logger.log('dispatch requested', {
      orderId,
      carrier,
      ...traceFields(),
    });
  }
}

Carrying context onto a queue

Needed for every asynchronous hop: queues, cron jobs, webhooks you sign yourself. Without inject and extract the worker's spans form a separate trace, and the slow background step never appears under the request that caused it.

Example · typescript
// src/jobs/email.producer.ts
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { context, propagation } from '@opentelemetry/api';
import type { Queue } from 'bullmq';

export interface EmailJob {
  to: string;
  carrier: Record<string, string>;
}

@Injectable()
export class EmailProducer {
  constructor(@InjectQueue('email') private readonly queue: Queue<EmailJob>) {}

  async enqueue(to: string): Promise<void> {
    // inject() writes the active context into a plain object as W3C headers
    // ({ traceparent, tracestate, baggage }), which is JSON-safe.
    const carrier: Record<string, string> = {};
    propagation.inject(context.active(), carrier);

    await this.queue.add('welcome', { to, carrier });
  }
}

// src/jobs/email.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { SpanKind, context, propagation, trace } from '@opentelemetry/api';
import type { Job } from 'bullmq';
import { MailService } from '../mail/mail.service.js';
import type { EmailJob } from './email.producer.js';

const tracer = trace.getTracer('email-worker');

@Processor('email')
export class EmailProcessor extends WorkerHost {
  constructor(private readonly mail: MailService) {
    super();
  }

  async process(job: Job<EmailJob>): Promise<void> {
    // extract() rebuilds the producer's context; context.with() makes it
    // active, so this span is a child of the request that queued the job
    // instead of a trace of its own.
    const parent = propagation.extract(context.active(), job.data.carrier);

    await context.with(parent, () =>
      tracer.startActiveSpan(
        'send welcome email',
        { kind: SpanKind.CONSUMER },
        async (span) => {
          try {
            await this.mail.sendWelcome(job.data.to);
          } finally {
            span.end();
          }
        },
      ),
    );
  }
}

Discussion

  • Be the first to comment on this lesson.