lenec ru

← все посты

OpenTelemetry tracing в Node.js: инструментируем микросервисы от и до

14K

Микросервисная архитектура даёт гибкость, но усложняет отладку: один 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 в продакшене.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.