Опирается на правила:
R-OBS-TRC-1…R-OBS-TRC-6иR-OBS-TRC-X1…R-OBS-TRC-X4из Observability Style Guide → раздел 3. Tracing.
Важно знать
- OpenTelemetry автоинструментация через
NodeSDK+getNodeAutoInstrumentations()— auto-spans для HTTP, Express/NestJS core,pg, Kafka,ioredis.tracing.tsимпортируется доNestFactory.create— иначе модули не будут пропатчены и spans не появятся.traceparentW3C Trace Context propagation между сервисами — автоматически (W3CTraceContextPropagator по умолчанию).- Manual span через
startActiveSpan— callback-форма закрывает контекст сама;span.end()— вfinally.- Span attributes — business context (
order.id,payment.method), не PII.- Sampling 1–10% в проде + 100% для error-traces через tail-based sampling на collector-е.
trace_id/span_idв pino-логах — через@opentelemetry/instrumentation-pino, не руками.- Trace рвётся при offload в
worker_threadsи BullMQ без явной передачи контекста.
Tracing — третья нога observability. Когда метрики фиксируют рост p95, а логи показывают ошибки в payment-service, trace указывает на конкретный запрос от POST /orders через несколько сервисов: где потратили 300 мс в pg, где 700 мс в HTTP-вызове к Sber, где упало. UCP опирается на OpenTelemetry JS — отраслевой стандарт, который пришёл на смену Zipkin/Jaeger client-библиотекам.
Автоинструментация через NodeSDK
R-OBS-TRC-1: настройка в отдельном файле tracing.ts, который загружается до bootstrap приложения.
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-grpc
// src/tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://otel-collector:4317',
}),
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(
parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG ?? '0.1'),
),
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
Запуск — через --require или первой строкой main.ts:
// src/main.ts
import './tracing'; // до NestFactory — обязательно
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
Auto-spans создаются для:
- HTTP server (Express / Fastify под NestJS) — incoming span с атрибутами
http.method,http.route,http.status_code. - HTTP client (
node:http,axios,fetch) — outgoing span с пробросомtraceparent. - pg (PostgreSQL) — span на каждый запрос с
db.statement. - Kafka (
kafkajs) — span на produce/consume сmessaging.destination. - ioredis — span на команды.
Без custom-кода уже есть полная картина «request → pg → Kafka → HTTP-выход».
Traceparent propagation
R-OBS-TRC-2: W3C Trace Context (R-HDR-4) — W3CTraceContextPropagator включён по умолчанию.
traceparent: 00-5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4-1f2e3d4c5b6a7980-01
│ │ │ │
│ trace-id (16 bytes) span-id (8 bytes) flags
version
OTel автоматически:
- Извлекает
traceparentиз входящих HTTP-заголовков, создаёт child span. - Пропагирует в исходящие HTTP и Kafka заголовки.
- Связывает spans в единый distributed trace на collector-е.
Между сервисами на OTel никакого явного кода не нужно — каждый сервис автоматически становится участником trace-цепочки.
Manual spans для use case
R-OBS-TRC-3: для значимых business-операций добавляем явный span с атрибутами.
import { Injectable } from '@nestjs/common';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { trace, SpanStatusCode } from '@opentelemetry/api';
@Injectable()
export class ConfirmOrderHandler {
private readonly tracer = trace.getTracer('order-service');
constructor(
@InjectPinoLogger(ConfirmOrderHandler.name)
private readonly logger: PinoLogger,
private readonly orderRepository: OrderRepository,
) {}
async handle(cmd: ConfirmOrderCommand): Promise<Order> {
return this.tracer.startActiveSpan('confirmOrder', async (span) => {
try {
span.setAttribute('order.id', cmd.orderId);
span.setAttribute('customer.id', cmd.customerId);
const order = await this.orderRepository.findByIdOrFail(cmd.orderId);
order.confirm();
await this.orderRepository.save(order);
span.setAttribute('order.status', order.status);
return order;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
span.end();
}
});
}
}
startActiveSpan с callback-функцией автоматически делает span активным в контексте — дочерние операции (pg-запросы, HTTP-вызовы) становятся его child spans. span.end() — в finally обязательно.
Альтернатива для простых случаев — декоратор из @opentelemetry/instrumentation:
import { trace } from '@opentelemetry/api';
function WithSpan(name: string): MethodDecorator {
return (target, key, descriptor: PropertyDescriptor) => {
const original = descriptor.value;
descriptor.value = async function (...args: unknown[]) {
const tracer = trace.getTracer('order-service');
return tracer.startActiveSpan(name, async (span) => {
try {
return await original.apply(this, args);
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
};
return descriptor;
};
}
@Injectable()
export class GetProductHandler {
@WithSpan('getProduct')
async handle(query: GetProductQuery): Promise<Product> {
return this.productRepository.findByIdOrFail(query.productId);
}
}
Span attributes — business, не PII
R-OBS-TRC-4: что класть в атрибуты.
| ОК | Не ОК |
|---|---|
order.id, customer.id (внутренние ID) | customer.email, customer.phone |
order.status, payment.method (enum) | card.number, iban |
external.system ("sber", "cdek") | external.api_key |
circuit_breaker.state ("open") | request.body целиком |
Tracing-данные хранятся в отдельном storage (Tempo, Jaeger) с другим режимом доступа и retention. PII там — нарушение compliance. Внутренние ID — норма.
Sampling
R-OBS-TRC-5: 1–10% в проде по умолчанию.
// tracing.ts — ParentBasedSampler уже подключён выше
// Через env:
// OTEL_TRACES_SAMPLER=parentbased_traceidratio
// OTEL_TRACES_SAMPLER_ARG=0.1
ParentBasedSampler — если входящий запрос несёт traceparent с флагом sampled, сервис тоже участвует в trace (head-based). Иначе — 10% случайных.
Tail-based sampling на collector-е добавляет «100% если в trace есть error». В результате storage не переполняется нормальными traces, но ни одна ошибка не теряется.
Для low-traffic сервисов (менее 10 req/s) можно держать 1.0 — хранилище не перегружается.
Trace в pino-логах
R-OBS-TRC-6: @opentelemetry/instrumentation-pino автоматически добавляет trace_id, span_id в каждую pino-запись.
npm install @opentelemetry/instrumentation-pino
// tracing.ts
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations(),
new PinoInstrumentation(), // добавляет trace_id/span_id в каждый лог
],
// ...
});
Результат в JSON-логе:
{
"level": 50,
"time": 1748991600000,
"msg": "payment failed",
"orderId": "ord-9281",
"err": { "type": "PaymentGatewayError", "message": "timeout", "stack": "..." },
"trace_id": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
"span_id": "1f2e3d4c5b6a7980"
}
В Loki / ELK кликаем на trace_id → переходим в Tempo / Jaeger → видим весь distributed trace с этим span-ом. Связка «лог-запись ↔ trace» — основной профит интеграции OTel с pino.
Альтернатива без дополнительного instrumentation — mixin:
// nestjs-pino LoggerModule.forRoot
LoggerModule.forRoot({
pinoHttp: {
mixin: () => {
const span = trace.getActiveSpan();
if (!span) return {};
const ctx = span.spanContext();
return { trace_id: ctx.traceId, span_id: ctx.spanId };
},
},
}),
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Sampling 100% в high-traffic проде | R-OBS-TRC-X1 | 1–10% + tail-based для errors |
PII в span attributes (customer.email, card.number) | R-OBS-TRC-X2 | только внутренние ID и enum-статусы |
Manual span без finally/callback-формы | R-OBS-TRC-X3 | startActiveSpan callback или try/finally span.end() |
Offload в worker_threads / BullMQ без передачи traceparent | R-OBS-TRC-X4 | передавать traceparent в payload джобы, восстанавливать в processor |
import './tracing' после NestFactory.create | R-OBS-TRC-1 | импорт первой строкой main.ts |
trace_id в логах руками | R-OBS-TRC-6 | PinoInstrumentation или mixin из trace.getActiveSpan() |
Куда дальше
- Context propagation —
AsyncLocalStorage, передача контекста в BullMQ worker-ы. - Logging —
trace_idв pino, PII-гигиена, structured merge-объект. - Metrics — low-cardinality labels,
prom-clienthistogram. - Конфигурация — management-порт,
NODE_ENV-профили,pino-pretty. - Health checks —
@nestjs/terminus, liveness / readiness, кастомные индикаторы. - SLO и алерты — error budget, multi-window alerts, runbook.