Опирается на правила: R-OBS-TRC-1R-OBS-TRC-6 и R-OBS-TRC-X1R-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 не появятся.
  • traceparent W3C 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-X11–10% + tail-based для errors
PII в span attributes (customer.email, card.number)R-OBS-TRC-X2только внутренние ID и enum-статусы
Manual span без finally/callback-формыR-OBS-TRC-X3startActiveSpan callback или try/finally span.end()
Offload в worker_threads / BullMQ без передачи traceparentR-OBS-TRC-X4передавать traceparent в payload джобы, восстанавливать в processor
import './tracing' после NestFactory.createR-OBS-TRC-1импорт первой строкой main.ts
trace_id в логах рукамиR-OBS-TRC-6PinoInstrumentation или mixin из trace.getActiveSpan()

Куда дальше

  • Context propagation — AsyncLocalStorage, передача контекста в BullMQ worker-ы.
  • Logging — trace_id в pino, PII-гигиена, structured merge-объект.
  • Metrics — low-cardinality labels, prom-client histogram.
  • Конфигурация — management-порт, NODE_ENV-профили, pino-pretty.
  • Health checks — @nestjs/terminus, liveness / readiness, кастомные индикаторы.
  • SLO и алерты — error budget, multi-window alerts, runbook.