Когда запрос падает или тормозит в распределённом приложении, метрики говорят «что-то сломалось», логи показывают отдельные события — но ни то ни другое не объясняет, по какому пути прошёл конкретный запрос и где именно случился сбой. 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.