Распределённая трассировка с OpenTelemetry
Инструментируйте контроллеры, провайдеры и HTTP-клиенты, чтобы создавать связанные промежутки трассировки между сервисами.
«Распределённая трассировка с OpenTelemetry» — бесплатный урок NestJS Enterprise Backend APIs на CoddyKit. Это урок 3 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения 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 requestspan_id— unique per operationparent_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-httpto 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-conventionsBootstrapping 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({ ... }) optionsCorrelating 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.startActiveSpanadds business spans that nest correctly. - Enrich the active span —
trace.getActiveSpan()in controllers to attach tenant/user/order attributes. - Propagation — the W3C
traceparentheader carriestrace_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_idinto 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) и разблокировать остальной курс NestJS Enterprise Backend APIs, подпишись на CoddyKit PRO. Курс NestJS Enterprise Backend APIs содержит 4 уроков всего.
Чему я научусь в уроке «Распределённая трассировка с OpenTelemetry»?
Инструментируйте контроллеры, провайдеры и HTTP-клиенты, чтобы создавать связанные промежутки трассировки между сервисами. Ты практикуешь NestJS Enterprise Backend APIs с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать NestJS Enterprise Backend APIs?
Предыдущий опыт не требуется. NestJS Enterprise Backend APIs на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 3 из 4.
Сколько времени занимает урок «Распределённая трассировка с OpenTelemetry»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке NestJS Enterprise Backend APIs?
Да. Каждый урок NestJS Enterprise Backend APIs включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Тайм-ауты, повторы и изоляция с помощью перехватчиков
- Размыкатели цепи при сбоях зависимостей
- Распределённая трассировка с OpenTelemetry
- Определение SLO и бюджетов ошибок