0Pricing
NestJS Enterprise Backend APIs · Lesson

Distributed Tracing with OpenTelemetry

Instrument controllers, providers, and HTTP clients to emit correlated spans across services.

Distributed Tracing with OpenTelemetry is a free NestJS Enterprise Backend APIs lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the NestJS Enterprise Backend APIs learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Distributed Tracing

In a microservice fleet a single user request might touch a gateway, an orders service, a payments service, and a third-party HTTP API. When latency spikes, logs alone cannot tell you which hop was slow.

Distributed tracing stitches these hops together. Each unit of work becomes a span; spans linked by a shared trace_id form one end-to-end trace.

  • trace_id — the same value across every service in one request
  • span_id — unique per operation
  • parent_span_id — how spans nest into a tree

OpenTelemetry (OTel) is the vendor-neutral standard for producing and propagating these spans, which we'll wire into NestJS.

The Anatomy of a Span

A span is just a typed object describing one operation in time. Before touching NestJS, it helps to model what OTel actually emits. Below is a plain TypeScript sketch of the fields the SDK populates.

Note kind: SERVER spans represent inbound requests, CLIENT spans represent outbound calls. Correlating a CLIENT span in service A with a SERVER span in service B is exactly what context propagation buys you.

type SpanKind = 'SERVER' | 'CLIENT' | 'INTERNAL';

interface Span {
  traceId: string;
  spanId: string;
  parentSpanId?: string;
  name: string;
  kind: SpanKind;
  startTimeMs: number;
  endTimeMs: number;
  attributes: Record<string, string | number | boolean>;
}

function durationMs(span: Span): number {
  return span.endTimeMs - span.startTimeMs;
}

const span: Span = {
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
  spanId: '00f067aa0ba902b7',
  name: 'GET /orders/:id',
  kind: 'SERVER',
  startTimeMs: 1000,
  endTimeMs: 1042,
  attributes: { 'http.method': 'GET', 'http.route': '/orders/:id', 'http.status_code': 200 },
};

console.log(`${span.name} took ${durationMs(span)}ms`);

Installing the OTel SDK

For a NestJS service you need three layers of OTel packages:

  • @opentelemetry/sdk-node — the Node SDK and lifecycle
  • @opentelemetry/auto-instrumentations-node — zero-code patches for HTTP, Express, Nest, pg, ioredis, etc.
  • An exporter such as @opentelemetry/exporter-trace-otlp-http to ship spans to a collector or backend (Jaeger, Tempo, Honeycomb)

Auto-instrumentation alone already produces correlated SERVER and CLIENT spans for incoming HTTP and outgoing fetch/axios calls. Manual spans (later scenes) layer business meaning on top.

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Bootstrapping Tracing Before Nest

The single most important rule: start the OTel SDK before any application module is imported. Auto-instrumentation works by monkey-patching modules like http at require time. If Nest loads first, the patches miss it.

Put the SDK in its own tracing.ts and import it at the very top of main.ts, or preload it with node --require ./dist/tracing.js.

// tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

export const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'orders-service',
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? '0.0.0',
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0));
});

Wiring It Into main.ts

Because side-effect import order matters, the tracing import must be the first statement — above the Nest factory and even above AppModule. ES module hoisting will still respect physical order for the SDK's sdk.start() side effect as long as this file is first.

A safer alternative that avoids any hoisting ambiguity is the node --require ./dist/tracing.js dist/main.js preload flag in your start script.

// main.ts
import './tracing'; // MUST be first
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

How Context Propagates Across Services

Spans become a single trace only if the trace_id travels between services. OTel does this with the W3C Trace Context standard, injecting a traceparent HTTP header on outbound CLIENT spans and extracting it on inbound SERVER spans.

The header looks like:

  • traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
  • Format: version-traceId-parentSpanId-flags

With auto-instrumentation this is automatic for HTTP. The key requirement is that the active context flows through your async code so the outbound call knows which trace it belongs to.

function parseTraceparent(header: string) {
  const [version, traceId, parentId, flags] = header.split('-');
  return { version, traceId, parentId, sampled: (parseInt(flags, 16) & 1) === 1 };
}

const h = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const ctx = parseTraceparent(h);
console.log(ctx.traceId);
console.log('sampled:', ctx.sampled);

Manual Spans in a Provider

Auto-instrumentation gives you HTTP-level spans, but business logic ("reserve inventory", "charge card") deserves its own spans. Grab a Tracer and wrap the operation with startActiveSpan so child spans nest correctly under the current SERVER span.

startActiveSpan sets the span as active for the duration of its callback, meaning any nested span or outbound HTTP call automatically becomes its child.

// orders.service.ts
import { Injectable } from '@nestjs/common';
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('orders-service');

@Injectable()
export class OrdersService {
  async reserveInventory(orderId: string, sku: string, qty: number) {
    return tracer.startActiveSpan('reserveInventory', async (span) => {
      span.setAttribute('order.id', orderId);
      span.setAttribute('inventory.sku', sku);
      span.setAttribute('inventory.qty', qty);
      try {
        const result = await this.doReserve(sku, qty);
        span.setStatus({ code: SpanStatusCode.OK });
        return result;
      } catch (err) {
        span.recordException(err as Error);
        span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
        throw err;
      } finally {
        span.end();
      }
    });
  }

  private async doReserve(sku: string, qty: number) {
    return { sku, qty, reserved: true };
  }
}

Enriching the Controller's Active Span

A NestJS controller handler already runs inside the auto-generated SERVER span for the request. Rather than create a new one, fetch the active span and decorate it with high-cardinality business attributes (tenant id, user id, order id) so traces become searchable.

Use trace.getActiveSpan() — it returns the SERVER span the request handler is executing within. Adding attributes here keeps everything on one node of the trace instead of fragmenting it.

// orders.controller.ts
import { Controller, Post, Body, Headers } from '@nestjs/common';
import { trace } from '@opentelemetry/api';
import { OrdersService } from './orders.service';

@Controller('orders')
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  async create(@Body() dto: { sku: string; qty: number }, @Headers('x-tenant-id') tenantId: string) {
    const span = trace.getActiveSpan();
    span?.setAttribute('tenant.id', tenantId);
    span?.setAttribute('order.sku', dto.sku);
    span?.addEvent('order.create.received');

    return this.orders.reserveInventory(crypto.randomUUID(), dto.sku, dto.qty);
  }
}

Tracing Outbound HTTP Clients

When the orders service calls the payments service over HTTP, you want a CLIENT span that propagates the traceparent header so payments continues the same trace.

If you use the auto-instrumented http module (Node fetch, axios, Nest's HttpService), propagation is automatic — provided the call happens inside the active context. Calling out within a startActiveSpan callback guarantees the outbound CLIENT span nests under your business span.

// payments.client.ts
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('orders-service');

@Injectable()
export class PaymentsClient {
  constructor(private readonly http: HttpService) {}

  async charge(orderId: string, amount: number) {
    return tracer.startActiveSpan('payments.charge', async (span) => {
      span.setAttribute('order.id', orderId);
      span.setAttribute('payment.amount', amount);
      try {
        // traceparent header is injected automatically by http instrumentation
        const res = await firstValueFrom(
          this.http.post('http://payments-svc/charges', { orderId, amount }),
        );
        span.setStatus({ code: SpanStatusCode.OK });
        return res.data;
      } catch (err) {
        span.recordException(err as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        throw err;
      } finally {
        span.end();
      }
    });
  }
}

Sampling and Cost Control

At enterprise scale, recording 100% of traces is expensive. OTel uses samplers to decide which traces to keep, and the decision propagates via the sampled flag in traceparent so a trace is recorded (or dropped) consistently across all services.

  • ParentBasedSampler — respect the upstream service's decision (the standard default)
  • TraceIdRatioBasedSampler — keep a fixed fraction, e.g. 10%
  • Tail sampling — done in the Collector: keep all error/slow traces, sample the rest

Configure the root sampler with OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG env vars or the SDK sampler option.

// tracing.ts (excerpt)
import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';

const sampler = new ParentBasedSampler({
  // when this service starts a trace, keep 10%
  root: new TraceIdRatioBasedSampler(0.1),
});

// pass `sampler` into the NodeSDK({ ... }) options

Correlating Logs with Traces

The final win is linking your structured logs to traces. Pull trace_id and span_id from the active span context and inject them into every log line. In Jaeger/Tempo you can then jump from a slow span straight to its logs and back.

Use trace.getActiveSpan()?.spanContext() to read the current ids and feed them into your Pino/Winston logger as fields.

import { trace } from '@opentelemetry/api';

function traceFields(): Record<string, string> {
  const ctx = trace.getActiveSpan()?.spanContext();
  if (!ctx) return {};
  return { trace_id: ctx.traceId, span_id: ctx.spanId };
}

// simulate a log line enriched with correlation ids
const entry = {
  level: 'info',
  msg: 'order created',
  ...{ trace_id: '4bf92f3577b34da6a3ce929d0e0e4736', span_id: '00f067aa0ba902b7' },
};
console.log(JSON.stringify(entry));

Quick Check

Your NestJS orders service emits SERVER spans, but the outbound calls to the payments service show up as separate, disconnected traces with a brand-new trace_id. Auto-instrumentation for both HTTP and Nest is installed. What is the most likely root cause?

Recap

You instrumented a NestJS service end-to-end with OpenTelemetry:

  • Bootstrap first — start the NodeSDK before AppModule (or use node --require) so auto-instrumentation patches HTTP/Nest in time.
  • Auto + manual spans — auto-instrumentation gives SERVER/CLIENT HTTP spans; tracer.startActiveSpan adds business spans that nest correctly.
  • Enrich the active spantrace.getActiveSpan() in controllers to attach tenant/user/order attributes.
  • Propagation — the W3C traceparent header carries trace_id + sampling decision; outbound calls must run inside the active context to stay correlated.
  • Sampling & logs — ParentBased + ratio (or tail sampling in the Collector) controls cost; inject trace_id/span_id into logs to bridge traces and logging.

The cardinal rule: a trace stays whole only when the active context flows through every async hop.

Frequently asked questions

Is the “Distributed Tracing with OpenTelemetry” lesson free?

Yes — the full text of “Distributed Tracing with OpenTelemetry” is free to read here on the web, and the NestJS Enterprise Backend APIs course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the NestJS Enterprise Backend APIs course, upgrade to CoddyKit PRO.

What will I learn in “Distributed Tracing with OpenTelemetry”?

Instrument controllers, providers, and HTTP clients to emit correlated spans across services. You practise NestJS Enterprise Backend APIs with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start NestJS Enterprise Backend APIs?

No prior experience is required. NestJS Enterprise Backend APIs on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Distributed Tracing with OpenTelemetry” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this NestJS Enterprise Backend APIs lesson?

Yes. Every NestJS Enterprise Backend APIs lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Timeouts, Retries, and Bulkheads with Interceptors
  2. Circuit Breakers for Downstream Failures
  3. Distributed Tracing with OpenTelemetry
  4. Defining SLOs and Error Budgets
← Back to NestJS Enterprise Backend APIs