0Pricing
NestJS Enterprise Backend APIs · Lección

Trazabilidad distribuida con OpenTelemetry

Instrumente controllers, providers y clientes HTTP para emitir spans correlacionados entre servicios.

Trazabilidad distribuida con OpenTelemetry es una lección gratuita de NestJS Enterprise Backend APIs en CoddyKit. Esta es la lección 3 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de NestJS Enterprise Backend APIs, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de NestJS Enterprise Backend APIs incluye 4 lecciones en total.

Partes de esta lección aún no han sido traducidas y se muestran en inglés.

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 span — trace.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.

Preguntas frecuentes

¿La lección «Trazabilidad distribuida con OpenTelemetry» es gratis?

Sí — el texto completo de «Trazabilidad distribuida con OpenTelemetry» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de NestJS Enterprise Backend APIs, actualiza a CoddyKit PRO. El curso de NestJS Enterprise Backend APIs incluye 4 lecciones en total.

¿Qué aprenderé en «Trazabilidad distribuida con OpenTelemetry»?

Instrumente controllers, providers y clientes HTTP para emitir spans correlacionados entre servicios. Practicas NestJS Enterprise Backend APIs con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.

¿Necesito experiencia previa para empezar NestJS Enterprise Backend APIs?

No se requiere experiencia previa. NestJS Enterprise Backend APIs en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 3 de 4.

¿Cuánto tiempo toma la lección «Trazabilidad distribuida con OpenTelemetry»?

La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.

¿Puedo escribir y ejecutar código en esta lección de NestJS Enterprise Backend APIs?

Sí. Cada lección de NestJS Enterprise Backend APIs incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.

Todas las lecciones de este curso

  1. Tiempos de espera, reintentos y bulkheads con interceptors
  2. Circuit breakers para fallos en servicios descendentes
  3. Trazabilidad distribuida con OpenTelemetry
  4. Definición de SLO y presupuestos de error
← Volver a NestJS Enterprise Backend APIs