← назад к разделу

Когда запрос падает или тормозит в распределённом приложении, метрики говорят «что-то сломалось», логи показывают отдельные события — но ни то ни другое не объясняет, по какому пути прошёл конкретный запрос и где именно случился сбой. Distributed tracing решает эту проблему: он записывает путь запроса через все сервисы в виде связанной цепочки событий — trace.

Разберём, как это работает в NestJS через OpenTelemetry JS.

Что такое trace и span

Представьте: пользователь нажимает «Оплатить». Запрос идёт в order-service, тот обращается в базу, потом вызывает payment-service, который делает HTTP к Сберу.

Span — это один шаг в этом пути. Каждый шаг фиксирует имя, время начала и конца, статус и атрибуты. Trace — это вся цепочка связанных spans от первого запроса до последнего ответа.

Связь spans строится через HTTP-заголовок traceparent (стандарт W3C Trace Context):

traceparent: 00-5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4-1f2e3d4c5b6a7980-01
                  │                                  │                 │
                  trace-id (общий для цепочки)        span-id          флаги

Сервис получает traceparent из входящего запроса, создаёт дочерний span и передаёт заголовок дальше. Так весь путь остаётся связанным, даже если сервисов десять.

Подключение OpenTelemetry

OpenTelemetry — отраслевой стандарт для трассировки, метрик и логов, который вытеснил Zipkin и Jaeger как самостоятельные библиотеки.

Устанавливаем пакеты:

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 ?? 'otel-collector:4317',
  }),
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBasedSampler(
      parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG ?? '0.1'),
    ),
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

И подключаем его первой строкой main.ts — до NestFactory.create. Это критично: если импорт позже, модули уже загружены и автоинструментация не сработает.

import './tracing';                     // обязательно первым
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

После этого без единой строки дополнительного кода появляются spans для:

  • входящих HTTP-запросов к NestJS (Express/Fastify)
  • исходящих HTTP-вызовов (axios, fetch)
  • запросов к PostgreSQL через pg — с текстом SQL в атрибутах
  • Kafka produce/consume через kafkajs
  • команд к Redis через ioredis

Полная картина «запрос → база → Kafka → HTTP-выход» бесплатно.

Ручные spans для важных операций

Автоинструментация покрывает инфраструктуру. Но иногда нужно добавить span на бизнес-операцию — чтобы видеть в трассировке, например, «подтверждение заказа», а не только «SQL-запрос к таблице orders».

import { Injectable } from '@nestjs/common';
import { trace, SpanStatusCode } from '@opentelemetry/api';

@Injectable()
export class ConfirmOrderHandler {
  private readonly tracer = trace.getTracer('order-service');

  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();        // span.end() — всегда в finally
      }
    });
  }
}

startActiveSpan с callback-функцией делает span активным в текущем контексте — все дочерние операции (SQL-запросы, HTTP-вызовы) автоматически становятся его child spans. Именно поэтому важно использовать callback-форму, а не создавать span напрямую.

Для повторяющегося паттерна удобен декоратор:

import { trace, SpanStatusCode } 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 — это контекст, который помогает разобраться при анализе. Но данные трассировки хранятся в отдельных системах (Tempo, Jaeger) с другим режимом доступа, и если туда попадут персональные данные пользователей — это нарушение требований безопасности.

Правило простое: внутренние идентификаторы и enum-статусы — можно, данные пользователей — нельзя.

Хорошие атрибуты: order.id, customer.id (внутренние UUID), order.status, payment.method (статусы и типы), external.system (имя внешней системы вроде "sber").

Плохие атрибуты: customer.email, customer.phone, card.number, iban, содержимое тела запроса целиком.

Sampling — сколько traces сохранять

Записывать 100% запросов в продакшене — дорого и обычно не нужно. Нормальные запросы похожи друг на друга, ценность каждого невысока. Ценность есть в ошибках.

Стандартный подход: 1–10% запросов в проде + 100% для запросов с ошибками.

Конфигурация через переменные среды:

OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

ParentBasedSampler работает так: если входящий запрос несёт traceparent с флагом sampled (решение уже принято вышестоящим сервисом) — участвуем в trace. Иначе принимаем решение сами по заданному проценту.

«100% для ошибок» настраивается на уровне OTel Collector через tail-based sampling: коллектор смотрит на завершённый trace целиком и решает, сохранить ли его. Это не конфигурация приложения — это конфигурация инфраструктуры.

Для сервисов с малым трафиком (менее 10 запросов в секунду) можно держать 1.0 — хранилище не перегрузится.

trace_id в логах

Самый полезный сценарий: нашли строку в логах → перешли в Tempo/Jaeger по trace_id → увидели весь распределённый путь запроса.

Для этого trace_id и span_id должны появляться в каждой log-записи. Автоматически это делает @opentelemetry/instrumentation-pino:

npm install @opentelemetry/instrumentation-pino
// src/tracing.ts
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';

const sdk = new NodeSDK({
  instrumentations: [
    getNodeAutoInstrumentations(),
    new PinoInstrumentation(),
  ],
  // ...
});

Результат в JSON-логе:

{
  "level": 50,
  "time": 1748991600000,
  "msg": "payment failed",
  "orderId": "ord-9281",
  "err": { "type": "PaymentGatewayError", "message": "timeout" },
  "trace_id": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
  "span_id": "1f2e3d4c5b6a7980"
}

Если по каким-то причинам PinoInstrumentation не подходит, можно добавить поля через mixin:

LoggerModule.forRoot({
  pinoHttp: {
    mixin: () => {
      const span = trace.getActiveSpan();
      if (!span) return {};
      const ctx = span.spanContext();
      return { trace_id: ctx.traceId, span_id: ctx.spanId };
    },
  },
}),

Частые ошибки

Импорт tracing.ts не первым. Если import './tracing' стоит после других импортов — модули уже загружены без патчей, автоматические spans не появятся. Всегда первой строкой.

span.end() не в finally. Если выброшено исключение, а span.end() стоит в теле без finally — span не закроется, trace будет неполным. Всегда в finally.

Sampling 100% в нагруженном сервисе. При тысячах запросов в секунду это быстро переполняет хранилище трассировок. Ставьте 1–10% через TraceIdRatioBasedSampler.

Передача контекста в фоновые задачи. При offload в worker_threads или BullMQ контекст трассировки не передаётся автоматически. Нужно явно сохранять traceparent в payload задачи и восстанавливать его в обработчике.

Коротко

  • Trace — полный путь запроса через сервисы, span — один шаг в этом пути.
  • Связь spans между сервисами — через заголовок traceparent (W3C Trace Context), OTel передаёт его автоматически.
  • import './tracing' — первой строкой main.ts, до NestFactory.create.
  • getNodeAutoInstrumentations() бесплатно покрывает HTTP, pg, kafkajs, ioredis.
  • Ручные spans через startActiveSpan с callback — закрывают контекст правильно; span.end() — в finally.
  • В атрибуты span: внутренние ID и enum-статусы. Персональные данные (email, телефон, номер карты) — нельзя.
  • Sampling 1–10% в проде; 100% для ошибок — через tail-based sampling на коллекторе.
  • PinoInstrumentation автоматически добавляет trace_id/span_id в каждую лог-запись.

Что почитать дальше

  • Context propagation в NestJS — передача контекста в BullMQ и worker_threads.
  • Logging в NestJS — структурированные логи через pino, PII-гигиена.
  • Metrics в NestJS — prom-client, гистограммы, low-cardinality labels.
  • SLO и алерты — error budget, multi-window alerts.