OpenTelemetry tracing в Node.js: инструментируем микросервисы от и до
Микросервисная архитектура даёт гибкость, но усложняет отладку: один HTTP-запрос проходит через 5–10 сервисов, и без distributed tracing найти узкое место невозможно. OpenTelemetry — открытый стандарт (CNCF), который решает эту задачу. В этой статье настроим tracing для Node.js-микросервисов от инструментации до визуализации в Jaeger.
Что такое OpenTelemetry: traces, spans, context propagation
OpenTelemetry (OTel) — это набор API, SDK и инструментов для сбора телеметрии: traces, metrics и logs. Для tracing ключевые понятия:
- Trace — полный путь запроса через систему. Идентифицируется уникальным
trace_id(128 бит). - Span — единица работы внутри trace: HTTP-запрос, вызов БД, обработка сообщения из очереди. У каждого span есть
span_id,parent_span_id, имя операции, timestamps и атрибуты. - Context Propagation — механизм передачи trace/span ID между сервисами. По умолчанию OTel использует W3C TraceContext — заголовок
traceparent.
Формат заголовка traceparent:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
| | | |
version trace-id parent-id flags
Когда сервис A вызывает сервис B, он вставляет traceparent в HTTP-заголовки. Сервис B извлекает контекст и создаёт дочерний span — так выстраивается дерево вызовов всего запроса.
Настройка SDK для Node.js
Устанавливаем пакеты:
npm install @opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-proto \
@opentelemetry/sdk-trace-node
Создаём файл instrumentation.ts — он должен загружаться до любого кода приложения:
// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'my-service',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingPaths: ['/health', '/ready'],
},
'@opentelemetry/instrumentation-express': {},
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().then(() => process.exit(0));
});
Запуск приложения с инструментацией:
# Node.js 20+
node --import ./instrumentation.ts app.ts
# Или через env (для Docker/K8s)
NODE_OPTIONS="--import ./instrumentation.ts" node app.ts
Auto-instrumentation автоматически создаёт spans для HTTP-запросов (входящих и исходящих), Express-роутов, вызовов к PostgreSQL, Redis, gRPC и десятков других библиотек.
Ручная инструментация: кастомные spans
Auto-instrumentation покрывает I/O, но бизнес-логику нужно размечать вручную:
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('order-service', '1.0.0');
async function processOrder(orderId: string) {
return tracer.startActiveSpan('processOrder', async (span) => {
try {
span.setAttribute('order.id', orderId);
// Вложенный span для валидации
await tracer.startActiveSpan('validateOrder', async (child) => {
const isValid = await validateItems(orderId);
child.setAttribute('order.valid', isValid);
if (!isValid) {
child.setStatus({ code: SpanStatusCode.ERROR, message: 'Invalid order' });
}
child.end();
});
// Вложенный span для оплаты
await tracer.startActiveSpan('chargePayment', async (child) => {
await paymentGateway.charge(orderId);
child.addEvent('payment_charged', { 'payment.amount': 99.90 });
child.end();
});
span.setStatus({ code: SpanStatusCode.OK });
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
}
Ключевые правила: всегда вызывайте span.end(), ставьте статус при ошибке, добавляйте атрибуты для фильтрации в UI.
Context propagation между микросервисами
При использовании auto-instrumentation контекст пробрасывается автоматически через HTTP-заголовки. Но если вы используете кастомный транспорт (например, очереди), нужна ручная propagation:
import { context, propagation } from '@opentelemetry/api';
// Отправитель: инжектим контекст в headers сообщения
function publishMessage(queue: string, payload: object) {
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers);
broker.publish(queue, {
body: payload,
headers, // содержит traceparent + tracestate
});
}
// Получатель: извлекаем контекст и создаём span
function onMessage(msg: QueueMessage) {
const parentCtx = propagation.extract(context.active(), msg.headers);
context.with(parentCtx, () => {
tracer.startActiveSpan('process_message', (span) => {
span.setAttribute('messaging.queue', msg.queue);
handleMessage(msg.body);
span.end();
});
});
}
Так trace не разрывается на границе очереди — в Jaeger вы увидите полную цепочку от HTTP-запроса через Kafka/RabbitMQ до финального обработчика.
Экспорт в Jaeger через OTel Collector
В продакшене трейсы отправляются не напрямую в бэкенд, а через OTel Collector — промежуточный агент для батчинга, ретраев и роутинга:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
processors:
batch:
timeout: 5s
send_batch_size: 512
memory_limiter:
check_interval: 1s
limit_mib: 512
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/jaeger]
Docker Compose для локальной разработки:
# docker-compose.yaml (фрагмент)
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml
ports:
- "4317:4317"
- "4318:4318"
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # UI
- "4317" # OTLP gRPC
Подводные камни
- Порядок загрузки — instrumentation.ts должен загружаться первым. Если Express импортируется раньше — spans не создаются. Используйте
--importилиNODE_OPTIONS. - ESM-ловушка — для ESM-приложений нужен loader hook:
--experimental-loader=@opentelemetry/instrumentation/hook.mjs. Без него monkey-patching не работает. - Высокая кардинальность — не кладите user_id или request_body в атрибуты span. Это взрывает хранилище Jaeger/Tempo. Используйте индексируемые low-cardinality атрибуты.
- Sampling — в проде включайте tail-based sampling на Collector, иначе при 10k RPS вы генерируете терабайты трейсов. Начните с
probabilistic_samplerна 10%. - Graceful shutdown — без
sdk.shutdown()последний батч spans теряется при деплое. Обязательно обрабатывайте SIGTERM.
Вывод
OpenTelemetry в Node.js — это три шага: установить SDK + auto-instrumentations, подключить OTLP-экспортёр, развернуть Collector + Jaeger. Auto-instrumentation покрывает HTTP, Express, базы данных и очереди из коробки. Для бизнес-логики добавляйте кастомные spans через tracer.startActiveSpan(). Context propagation через W3C TraceContext работает автоматически для HTTP и требует ручного inject/extract для очередей. Главное — загружать инструментацию до кода приложения и не забывать про sampling в продакшене.