lenec ru

← все посты

OpenTelemetry в Node-приложении: настройка трейсинга и метрик

10K

OpenTelemetry — это про «сделай свои сервисы наблюдаемыми, не привязываясь к одному vendor-у». Я внедряла OTel на трёх Node-сервисах в этом году: один в Selectel, один в Yandex Cloud, один в свой self-hosted Grafana Tempo. На вид много шагов, на деле базовая интеграция занимает час. Расскажу, что подключаю и в каком порядке.

Контекст: Node 20, TypeScript, OpenTelemetry SDK ~1.25, в качестве backend — Tempo + Grafana либо Yandex Monitoring. На самом приложении не суть: Express, Fastify, Hono — все ходят через одни и те же инструментаторы.

Чем OTel отличается от Sentry/Datadog SDK

Sentry, Datadog, NewRelic — это удобные SDK конкретного vendor-а: что увидел, то отправил в их облако. Менять backend больно. OpenTelemetry — спецификация и набор библиотек, которые принимают данные в стандартном формате (OTLP) и отправляют в любой совместимый бэкенд. Меняешь endpoint — летит в другое облако.

На бумаге всё рестораны радуют. На практике у OTel чуть больше boilerplate, и инструментация некоторых библиотек неполная. Но за два года жизни с OTel я ни разу не пожалела о выборе: в каждом новом проекте просто меняем backend, код не трогаем.

Что нужно поставить

Минимальный комплект:

pnpm add @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http

auto-instrumentations подцепляет популярные библиотеки автоматически: http, express, fastify, pg, ioredis, fs и десятки других. Для большинства проектов этого хватает «из коробки».

Стартовый файл tracing.ts

OTel должен инициализироваться до импорта инструментируемых модулей. Поэтому делаем отдельный файл, который запускается первым.

// src/tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'api',
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? 'dev',
    'deployment.environment': process.env.NODE_ENV ?? 'development',
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces',
    headers: { Authorization: `Bearer ${process.env.OTEL_AUTH_TOKEN ?? ''}` },
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? 'http://localhost:4318/v1/metrics',
      headers: { Authorization: `Bearer ${process.env.OTEL_AUTH_TOKEN ?? ''}` },
    }),
    exportIntervalMillis: 60_000,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { enabled: false }, // слишком шумно
    }),
  ],
});

sdk.start();

process.on('SIGTERM', async () => {
  try { await sdk.shutdown(); } catch (e) { console.error('otel shutdown error', e); }
});

Что отметить:

  • Отключаю fs-инструментацию. Она добавляет span на каждый readFileSync, и трейсы превращаются в кашу.
  • Метрики через PeriodicExportingMetricReader. Раз в минуту экспортируем накопленные метрики.
  • Resource. Это атрибуты, которые ставятся на каждый span/метрику. Имя сервиса — обязательное поле, по нему фильтруют в UI.

Запуск: добавляем --require ./dist/tracing.js или импортируем первой строкой entry-файла.

node --require ./dist/tracing.js dist/server.js

Auto-instrumentation: что подключается само

Из коробки получаем span-ы:

  • Входящие HTTP-запросы (метод, URL, статус, длительность).
  • Исходящие fetch/axios — между сервисами трейс не теряется.
  • Postgres-запросы (через pg, mysql2, mongoose, ioredis и др.).
  • BullMQ jobs, kafka, redis-pubsub — у каждого свой пакет инструментатора, но они в auto-instrumentations включены.

Этого достаточно, чтобы первые сутки потратить на разглядывание трейсов и понимать «куда уходит время на запросе /checkout/...».

Свои span-ы

Когда нужно отследить кусок бизнес-логики, добавляешь span вручную:

import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('checkout');

async function calculatePricing(orderId: string) {
  return tracer.startActiveSpan('checkout.calculate-pricing', async (span) => {
    try {
      span.setAttribute('order.id', orderId);
      const result = await heavyComputation(orderId);
      span.setAttribute('order.total', result.total);
      return result;
    } catch (e) {
      span.recordException(e as Error);
      span.setStatus({ code: 2, message: (e as Error).message });
      throw e;
    } finally {
      span.end();
    }
  });
}

Имя span-а — короткое, в стиле «service.action». Атрибуты — пары ключ-значение, по которым в backend будем фильтровать.

Метрики

Бизнес-метрики у меня обычно сделаны через OTel Metrics API:

import { metrics } from '@opentelemetry/api';

const meter = metrics.getMeter('checkout');
const orderCounter = meter.createCounter('checkout.orders.total', { description: 'Total orders' });
const orderValue   = meter.createHistogram('checkout.order.value_rub', { unit: 'RUB' });

export function trackOrder(value: number, status: 'paid' | 'failed') {
  orderCounter.add(1, { status });
  if (status === 'paid') orderValue.record(value);
}

В Grafana или Yandex Monitoring эти метрики появляются с тегами service.name, status и можно строить дашборды.

Корреляция с логами

Самый частый запрос: «нашёл медленный трейс — покажи логи этого запроса». Решается простой подстановкой trace_id в log-record:

import { trace } from '@opentelemetry/api';
import pino from 'pino';

const log = pino({
  mixin() {
    const span = trace.getActiveSpan();
    if (!span) return {};
    const ctx = span.spanContext();
    return { trace_id: ctx.traceId, span_id: ctx.spanId };
  },
});

Теперь любой log.info(...) внутри активного span-а автоматически получает trace_id. В Grafana при просмотре трейса можно прыгнуть в Loki по этому id.

OTel Collector vs прямой экспорт

Прямой экспорт из приложения — простейший вариант. Минусы:

  • Если backend тормозит, твоё приложение делает retry и тратит CPU.
  • На каждом инстансе нужно знать креды и адрес backend-а.
  • Шумить отдельным трафиком.

Я предпочитаю поставить рядом с приложением OTel Collector в режиме agent. Локальный сервис на 4318 порту, который принимает OTLP и пересылает дальше: в Tempo, Yandex Monitoring, Datadog — что нужно. Приложение всегда стучит в localhost, никаких сетевых таймаутов.

# /etc/otelcol/config.yaml
receivers:
  otlp:
    protocols:
      http: { endpoint: 0.0.0.0:4318 }
      grpc: { endpoint: 0.0.0.0:4317 }

processors:
  batch:
    timeout: 10s
  memory_limiter:
    limit_mib: 256
    check_interval: 5s

exporters:
  otlphttp/tempo:
    endpoint: https://tempo.example.ru
    headers: { Authorization: 'Basic ...' }
  otlphttp/metrics:
    endpoint: https://metrics.example.ru

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/metrics]

Запускаешь как systemd-сервис, приложение пишет в localhost:4318, остальное — забота collector-а. Помимо удобства, это позволяет менять backend без передеплоя приложений.

Sampling

На активных API трейсить 100% запросов разорит и backend, и сеть. Базовое sampling-правило: трейсим все ошибочные запросы и около 5–10% успешных.

import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';

sdk.configureTraceProvider({
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBasedSampler(0.1),
  }),
});

10% — стартовая точка. Если хочется «всё, что больше 1 секунды или 5xx» — это tail-based sampling, делается в OTel Collector через tail_sampling-процессор. Прямо в SDK тонкого хвостового семплинга нет.

Грабли

Импорт до tracing.ts

Если хоть один модуль импортирован раньше OTel SDK, его инструментация может не сработать. Проверяй: либо --require, либо первая строка entry-файла. Я ловила «почему нет HTTP span-ов?» именно из-за этого.

Слишком много span-ов от fs

Уже сказала, отключаю. Иначе на каждом старте приложение генерит сотни span-ов «прочитал файл», и backend начинает захлёбываться.

Контекст в async-вызовах

OTel опирается на AsyncLocalStorage. На современных Node это работает «само», но если ты используешь нестандартные библиотеки, которые рвут контекст (старые callbacks, eventemitter с накопленными слушателями) — span может не подцепиться. Решение — обернуть нестандартный участок в tracer.startActiveSpan и не полагаться на auto.

Headers в exporter

Если у тебя backend требует токен (Yandex Monitoring, Datadog), не забудь Authorization. Тихая проблема: backend отвечает 401, exporter пишет это в свои внутренние логи, а ты думаешь, что данные отправляются. Загляни в OTEL_LOG_LEVEL=debug, чтобы увидеть отказы.

Что я считаю готовым к проду

  • SDK инициализируется до всех импортов.
  • Auto-instrumentations включены, fs отключён.
  • Resource с правильным service.name.
  • Метрики накапливаются, экспортируются раз в минуту.
  • OTel Collector рядом, приложение стучит в localhost.
  • Sampling 10% по умолчанию, ошибки — всегда.
  • Trace_id в log-record для прыжков из трейсов в логи.

OpenTelemetry — это «один раз настроил и забыл». Дальше любой новый сервис запускается с тем же tracing.ts, метрики и трейсы появляются в Grafana автоматически. Самый главный профит — visibility, которая просто есть, и в инцидентах ты не догадываешься, а смотришь.

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

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

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