0Pricing
NestJS Enterprise Backend APIs · 강의

OpenTelemetry를 활용한 분산 추적

컨트롤러, 공급자 및 HTTP 클라이언트를 계측해 서비스 전반에서 서로 연관된 스팬을 생성합니다.

OpenTelemetry를 활용한 분산 추적은(는) CoddyKit의 무료 NestJS Enterprise Backend APIs 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 NestJS Enterprise Backend APIs 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

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.

자주 묻는 질문

“OpenTelemetry를 활용한 분산 추적” 강의는 무료인가요?

네 — “OpenTelemetry를 활용한 분산 추적” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 NestJS Enterprise Backend APIs 강의 전체를 잠금 해제할 수 있습니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

“OpenTelemetry를 활용한 분산 추적”에서 뭘 배우나요?

컨트롤러, 공급자 및 HTTP 클라이언트를 계측해 서비스 전반에서 서로 연관된 스팬을 생성합니다. 브라우저에서 직접 실행하는 실습 코드로 NestJS Enterprise Backend APIs을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

NestJS Enterprise Backend APIs을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 NestJS Enterprise Backend APIs은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.

“OpenTelemetry를 활용한 분산 추적” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 NestJS Enterprise Backend APIs 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 NestJS Enterprise Backend APIs 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 인터셉터를 활용한 시간 초과, 재시도 및 격벽
  2. 하위 서비스 장애를 위한 회로 차단기
  3. OpenTelemetry를 활용한 분산 추적
  4. SLO와 오류 예산 정의
← NestJS Enterprise Backend APIs(으)로 돌아가기