OpenTelemetry в Node-приложении: настройка трейсинга и метрик
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-httpauto-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.jsAuto-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, которая просто есть, и в инцидентах ты не догадываешься, а смотришь.