OpenTelemetry за один день: traces, metrics, logs в одном pipeline
Когда нам прилетел баг «у пользователя не оформляется заказ», я открыл три вкладки: Datadog для трейсов, CloudWatch для логов и отдельный Grafana для метрик. Провёл двадцать минут, пытаясь склеить в голове события из разных систем по timestamp. Потом ещё десять — убеждая себя, что это нормально.
Это не нормально.
Проблема не в инструментах — каждый из них хорош. Проблема в том, что три источника данных не знают друг о друге. Трейс не знает, какой лог к нему относится. Лог не знает, в каком трейсе он возник. Метрики вообще живут своей жизнью.
OpenTelemetry решает именно это: единый стандарт для всех трёх сигналов — traces, metrics, logs — с автоматической корреляцией между ними. И самое важное: вы не привязаны ни к одному вендору. Сегодня Grafana, завтра Jaeger, послезавтра что угодно — код приложения не меняется.
В этой статье я покажу, как поднять полноценный observability-стек с нуля за один рабочий день. С реальным кодом, docker-compose, и результатом, которым можно пользоваться в production.
Что такое OpenTelemetry и почему это важно
OpenTelemetry (сокращённо OTel) — это CNCF-проект, который стандартизирует три вещи:
API — как инструментировать код (создавать spans, метрики, логи)
SDK — реализация API для конкретного языка
Protocol (OTLP) — формат передачи данных между компонентами
До OTel каждый вендор имел свой агент, свой формат, свои SDK. Если вы хотели мигрировать с Datadog на Jaeger — переписывали весь instrumentation код. С OTel вы пишете код один раз, а куда слать данные — настраиваете в конфиге коллектора.
Архитектура выглядит так:
Приложение (с OTel SDK)
↓ OTLP (gRPC или HTTP)
OTel Collector
↓ ↓ ↓
Tempo Prometheus Loki
(traces) (metrics) (logs)
↓
Grafana (единый UI)
Ключевой компонент — OTel Collector. Это прокси между вашим приложением и бэкендами хранения. Он принимает данные, обрабатывает (фильтрует, семплирует, обогащает) и экспортирует в нужные места. Приложение не знает ничего про Prometheus или Loki — оно просто шлёт OTLP на коллектор.
Что мы будем строить
Наш стек:
Приложение — Node.js сервис с Express, имитирующий реальный API с базой данных
OTel SDK — автоматическая и ручная инструментация
OTel Collector — приём, обработка, экспорт
Grafana Tempo — хранение и поиск трейсов
Prometheus — хранение метрик
Loki — хранение логов
Grafana — единый UI для всего
Всё это поднимается одним docker-compose up. Код приложения — меньше 200 строк.
Шаг 1: docker-compose — поднимаем инфраструктуру
Начнём с инфраструктуры. Создаём docker-compose.yml:
version: '3.8'
services:
# OTel Collector — центральный узел
otel-collector:
image: otel/opentelemetry-collector-contrib:0.96.0
command: ["--config=/etc/otel-collector-config.yml"]
volumes:
- ./otel-collector-config.yml:/etc/otel-collector-config.yml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "8888:8888" # Metrics коллектора (для self-monitoring)
depends_on:
- tempo
- prometheus
- loki
# Grafana Tempo — трейсы
tempo:
image: grafana/tempo:2.4.0
command: ["-config.file=/etc/tempo.yml"]
volumes:
- ./tempo.yml:/etc/tempo.yml
- tempo-data:/var/tempo
ports:
- "3200:3200" # HTTP API
- "9095:9095" # gRPC
# Prometheus — метрики
prometheus:
image: prom/prometheus:v2.50.0
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=7d'
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
ports:
- "9090:9090"
# Loki — логи
loki:
image: grafana/loki:2.9.0
command: -config.file=/etc/loki/loki.yml
volumes:
- ./loki.yml:/etc/loki/loki.yml
- loki-data:/loki
ports:
- "3100:3100"
# Grafana — UI для всего
grafana:
image: grafana/grafana:10.3.0
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- grafana-data:/var/lib/grafana
ports:
- "3000:3000"
depends_on:
- tempo
- prometheus
- loki
# Наше приложение
app:
build: ./app
ports:
- "8080:8080"
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
- OTEL_SERVICE_NAME=order-service
- OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.version=1.2.0
depends_on:
- otel-collector
volumes:
tempo-data:
prometheus-data:
loki-data:
grafana-data:
Шаг 2: конфигурируем OTel Collector
Это самый важный файл во всём стеке. otel-collector-config.yml:
receivers:
# Принимаем данные от приложения по OTLP
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# Батчинг: копим данные и отправляем пачками, а не по одному
batch:
timeout: 1s
send_batch_size: 1024
# Обогащаем данные: добавляем атрибуты к каждому span/log
resource:
attributes:
- key: host.name
from_attribute: host.name
action: upsert
# Memory limiter: защита от OOM
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
# Фильтруем health-check эндпоинты — не засоряем трейсы
filter/health:
error_mode: ignore
traces:
span:
- 'attributes["http.route"] == "/health"'
exporters:
# Трейсы → Tempo
otlp/tempo:
endpoint: tempo:9095
tls:
insecure: true
# Метрики → Prometheus (в формате remote_write)
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
# Логи → Loki
loki:
endpoint: http://loki:3100/loki/api/v1/push
default_labels_enabled:
exporter: false
job: true
# Дебаг в консоль коллектора (выключите в prod)
debug:
verbosity: basic
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/health, resource, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [loki]
Обратите внимание на секцию processors. Это одна из главных ценностей коллектора: приложение просто шлёт все данные подряд, а коллектор фильтрует ненужное (health-check), ограничивает потребление памяти и батчит запросы к бэкендам. Логика observability-пайплайна отделена от логики приложения.
Шаг 3: конфигурируем Tempo
tempo.yml — минимальная рабочая конфигурация:
server:
http_listen_port: 3200
grpc_listen_port: 9095
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:9095
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 48h # Храним трейсы 2 дня
storage:
trace:
backend: local
local:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
# Включаем генерацию метрик из трейсов (Service Graph)
metrics_generator:
registry:
collection_interval: 15s
storage:
path: /var/tempo/generator/wal
traces_storage:
path: /var/tempo/generator/traces
Последний блок — metrics_generator — это мощная фича Tempo. Она автоматически генерирует метрики из трейсов: latency по сервисам, error rate, request rate. Это называется RED-метрики (Rate, Errors, Duration), и они появятся в Prometheus без единой строчки дополнительного кода.
Шаг 4: инструментируем приложение
Вот где начинается магия. Создаём файл app/tracing.js — он должен быть импортирован первым, до всего остального:
// app/tracing.js
const { NodeSDK } = require('@opentelemetry/sdk-node')
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node')
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http')
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http')
const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http')
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics')
const { BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs')
const { Resource } = require('@opentelemetry/resources')
const { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions')
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318'
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'unknown-service',
[SEMRESATTRS_SERVICE_VERSION]: '1.2.0',
}),
// Трейсы
traceExporter: new OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
}),
// Метрики — экспортируем каждые 15 секунд
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${endpoint}/v1/metrics`,
}),
exportIntervalMillis: 15_000,
}),
// Логи
logRecordProcessor: new BatchLogRecordProcessor(
new OTLPLogExporter({
url: `${endpoint}/v1/logs`,
})
),
// Автоматическая инструментация: HTTP, Express, pg, redis и ещё 30+ библиотек
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }, // файловая система — слишком шумно
'@opentelemetry/instrumentation-http': { enabled: true },
'@opentelemetry/instrumentation-express': { enabled: true },
'@opentelemetry/instrumentation-pg': { enabled: true },
}),
],
})
sdk.start()
// Graceful shutdown — важно для корректной отправки последних данных
process.on('SIGTERM', () => sdk.shutdown())
process.on('SIGINT', () => sdk.shutdown())
Теперь главный файл приложения app/index.js:
// ВАЖНО: tracing должен быть первым импортом!
require('./tracing')
const express = require('express')
const { trace, metrics, context, SpanStatusCode } = require('@opentelemetry/api')
const { logs } = require('@opentelemetry/api-logs')
const app = express()
app.use(express.json())
// Получаем tracer, meter и logger для нашего сервиса
const tracer = trace.getTracer('order-service', '1.2.0')
const meter = metrics.getMeter('order-service', '1.2.0')
const logger = logs.getLogger('order-service', '1.2.0')
// Создаём метрики вручную
const orderCounter = meter.createCounter('orders.created', {
description: 'Количество созданных заказов',
})
const orderValueHistogram = meter.createHistogram('orders.value', {
description: 'Стоимость заказов в рублях',
unit: 'RUB',
boundaries: [100, 500, 1000, 5000, 10000, 50000],
})
const activeOrdersGauge = meter.createObservableGauge('orders.active', {
description: 'Количество активных заказов в данный момент',
})
// Симулируем количество активных заказов (в реальности — запрос к БД)
let activeOrders = 0
activeOrdersGauge.addCallback(result => {
result.observe(activeOrders)
})
// Хелпер: логируем с привязкой к текущему трейсу
function log(severity, message, attributes = {}) {
const span = trace.getActiveSpan()
const spanContext = span?.spanContext()
logger.emit({
severityText: severity,
body: message,
attributes: {
...attributes,
// Привязываем лог к трейсу — это и есть корреляция
'trace.id': spanContext?.traceId,
'span.id': spanContext?.spanId,
},
})
}
// --- Роуты ---
app.get('/health', (req, res) => {
res.json({ status: 'ok' })
})
app.post('/orders', async (req, res) => {
// Ручной span для бизнес-операции — автоматика покрывает HTTP, но не бизнес-логику
return tracer.startActiveSpan('create-order', async (span) => {
try {
const { userId, items } = req.body
// Атрибуты на span — они будут видны в Grafana Tempo
span.setAttributes({
'order.user_id': userId,
'order.items_count': items?.length ?? 0,
})
log('INFO', 'Начало создания заказа', { userId, itemsCount: items?.length })
// Симуляция валидации
if (!userId || !items?.length) {
span.setStatus({ code: SpanStatusCode.ERROR, message: 'Invalid input' })
span.recordException(new Error('userId and items are required'))
log('ERROR', 'Невалидный запрос на создание заказа', { userId })
return res.status(400).json({ error: 'userId and items are required' })
}
// Симуляция работы с базой данных
const order = await tracer.startActiveSpan('db.save-order', async (dbSpan) => {
dbSpan.setAttributes({
'db.system': 'postgresql',
'db.operation': 'INSERT',
'db.table': 'orders',
})
// Имитируем задержку БД (5–50ms)
await sleep(5 + Math.random() * 45)
const orderId = Math.floor(Math.random() * 100000)
const totalValue = items.reduce((sum, item) => sum + (item.price * item.qty), 0)
dbSpan.setAttributes({ 'order.id': orderId, 'order.total': totalValue })
dbSpan.end()
return { id: orderId, total: totalValue }
})
// Записываем метрики
orderCounter.add(1, { 'order.status': 'success', 'user.tier': 'standard' })
orderValueHistogram.record(order.total, { 'order.status': 'success' })
activeOrders++
// Добавляем order.id на родительский span
span.setAttributes({ 'order.id': order.id, 'order.total': order.total })
log('INFO', 'Заказ успешно создан', { orderId: order.id, total: order.total, userId })
res.status(201).json({ orderId: order.id, total: order.total })
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
span.recordException(err)
orderCounter.add(1, { 'order.status': 'error' })
log('ERROR', 'Ошибка создания заказа', { error: err.message })
res.status(500).json({ error: 'Internal server error' })
} finally {
span.end()
}
})
})
app.get('/orders/:id', async (req, res) => {
return tracer.startActiveSpan('get-order', async (span) => {
try {
const { id } = req.params
span.setAttribute('order.id', id)
// Симуляция запроса
await sleep(2 + Math.random() * 20)
// Иногда симулируем 404
if (parseInt(id) % 7 === 0) {
span.setStatus({ code: SpanStatusCode.ERROR, message: 'Order not found' })
log('WARN', 'Заказ не найден', { orderId: id })
return res.status(404).json({ error: 'Order not found' })
}
log('INFO', 'Заказ получен', { orderId: id })
res.json({ id, status: 'processing', createdAt: new Date() })
} catch (err) {
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR })
res.status(500).json({ error: 'Internal server error' })
} finally {
span.end()
}
})
})
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
app.listen(8080, () => {
console.log('Order service listening on :8080')
log('INFO', 'Сервис запущен', { port: 8080 })
})
package.json для приложения:
{
"name": "order-service",
"version": "1.2.0",
"dependencies": {
"express": "^4.18.3",
"@opentelemetry/sdk-node": "^0.49.1",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/api-logs": "^0.49.1",
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.49.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.49.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.49.1",
"@opentelemetry/sdk-metrics": "^1.22.0",
"@opentelemetry/sdk-logs": "^0.49.1",
"@opentelemetry/resources": "^1.22.0",
"@opentelemetry/semantic-conventions": "^1.22.0"
}
}
Шаг 5: настраиваем Grafana
Чтобы Grafana автоматически знала про все наши источники данных, используем provisioning. Создаём grafana/provisioning/datasources/datasources.yml:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus:9090
isDefault: true
jsonData:
httpMethod: POST
exemplarTraceIdDestinations:
# Это связывает метрики с трейсами — ключевая настройка!
- name: traceID
datasourceUid: tempo
- name: Tempo
type: tempo
uid: tempo
url: http://tempo:3200
jsonData:
httpMethod: GET
tracesToLogsV2:
# Это связывает трейсы с логами
datasourceUid: loki
spanStartTimeShift: '-1m'
spanEndTimeShift: '1m'
filterByTraceID: true
filterBySpanID: false
serviceMap:
datasourceUid: prometheus
search:
hide: false
nodeGraph:
enabled: true
- name: Loki
type: loki
url: http://loki:3100
jsonData:
derivedFields:
# Из лога вытаскиваем trace.id и делаем ссылку в Tempo
- matcherRegex: '"trace\.id":"(\w+)"'
name: TraceID
url: '$${__value.raw}'
datasourceUid: tempo
urlDisplayLabel: 'Открыть трейс'
Обратите внимание на секции exemplarTraceIdDestinations, tracesToLogsV2 и derivedFields. Это и есть настройка корреляции:
Метрики → Трейсы: на графике Prometheus можно кликнуть на точку с высокой latency и сразу открыть трейс, который её вызвал
Трейсы → Логи: в Tempo на любом span есть кнопка “Посмотреть логи”, которая открывает Loki с фильтром по
trace.idЛоги → Трейсы: в Loki из каждой строки с
trace.idможно прыгнуть прямо в Tempo
Шаг 6: запускаем и проверяем
Структура файлов:
.
├── docker-compose.yml
├── otel-collector-config.yml
├── tempo.yml
├── prometheus.yml
├── loki.yml
├── grafana/
│ └── provisioning/
│ └── datasources/
│ └── datasources.yml
└── app/
├── Dockerfile
├── package.json
├── tracing.js
└── index.js
Запускаем:
docker-compose up -d
# Проверяем что всё поднялось
docker-compose ps
# Генерируем тестовый трафик
for i in $(seq 1 50); do
curl -s -X POST http://localhost:8080/orders \
-H 'Content-Type: application/json' \
-d '{"userId": '$i', "items": [{"price": 1500, "qty": 2}]}' > /dev/null
curl -s http://localhost:8080/orders/$i > /dev/null
sleep 0.2
done
echo "Трафик сгенерирован. Открывай http://localhost:3000"
Через 30–60 секунд открываем Grafana на http://localhost:3000 и видим данные.
Что мы получили: живая корреляция в действии
Вот реальный сценарий работы с нашим стеком.
Сценарий: пользователь жалуется, что заказ не оформился. Время инцидента примерно известно.
Старый путь: открываем CloudWatch, ищем логи по времени, копируем request ID, идём в Datadog, ищем трейс, копируем span ID, идём в другую систему… 20 минут.
Новый путь:
Открываем Grafana → Loki → фильтр
{service_name="order-service"} |= "error"→ видим строку лога с ошибкойНажимаем на
trace.idв логе → мгновенно открывается Tempo с полным трейсомВ трейсе видим: HTTP span → create-order span → db.save-order span → ошибка на DB span с деталями
Нажимаем на “Metrics” в Tempo → видим graf метрик
orders.createdс разбивкой поorder.status=errorНа графике метрик замечаем, что ошибки начались ровно в 14:32 → смотрим деплои в этот момент
Это заняло 3 минуты вместо 20.
Sampling: не копите всё подряд
В production нельзя сохранять 100% трейсов при тысячах RPS — это дорого. OTel Collector поддерживает несколько стратегий семплирования.
Head sampling (решение при старте трейса) — быстрый, но тупой: просто берём каждый N-й трейс.
Tail sampling (решение после завершения трейса) — умный: смотрим на весь трейс и берём 100% ошибок, 100% медленных запросов, и только 1% успешных быстрых:
# Добавляем в processors коллектора
processors:
tail_sampling:
decision_wait: 10s # Ждём 10 секунд перед принятием решения
num_traces: 50000 # Держим в памяти не более 50k трейсов
expected_new_traces_per_sec: 100
policies:
# Всегда берём трейсы с ошибками
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
# Всегда берём медленные запросы (>500ms)
- name: slow-traces-policy
type: latency
latency:
threshold_ms: 500
# 1% от всего остального
- name: probabilistic-policy
type: probabilistic
probabilistic:
sampling_percentage: 1
С этой конфигурацией вы ничего не потеряете из важного, но объём данных упадёт в 10–50 раз.
Подводные камни, о которых не пишут в документации
За время настройки мы наступили на несколько граблей.
Проблема 1: порядок импортов критичен. OTel SDK перехватывает модули Node.js при их загрузке через monkey-patching. Если Express загрузится до того, как SDK инициализирован — автоматической инструментации не будет. require('./tracing') должен быть абсолютно первым.
Проблема 2: context propagation через async/await. OTel использует AsyncLocalStorage для передачи контекста через асинхронные вызовы. Это работает для async/await и промисов, но ломается при использовании EventEmitter или сторонних библиотек с callback-based API. Если span вдруг становится “orphan” (без родителя) — проверяйте, не теряется ли контекст в callback.
Проблема 3: cardinality в метриках. Каждая уникальная комбинация лейблов метрики — это отдельный time series в Prometheus. Если вы добавите user.id как атрибут к метрике с миллионом пользователей — получите миллион series и Prometheus упадёт. Правило: в метриках — только атрибуты с низкой кардинальностью (status, method, tier). Высокая кардинальность (user.id, order.id) — только в трейсах и логах.
Проблема 4: коллектор как Single Point of Failure. В production запускайте коллектор как минимум в двух инстансах. Приложение должно уметь работать, даже если коллектор недоступен — OTel SDK буферизует данные, но буфер не бесконечный.
Производительность: сколько это стоит?
Главный вопрос у всех: как сильно OTel замедляет приложение?
Мы замерили overhead на нашем сервисе (Node.js, 200 RPS):
Метрика | Без OTel | С OTel (100% sampling) | С OTel (1% sampling) |
|---|---|---|---|
p50 latency | 12ms | 14ms | 12ms |
p99 latency | 45ms | 52ms | 46ms |
CPU overhead | — | +8% | +2% |
Memory overhead | — | +35MB | +35MB |
Сеть (к коллектору) | — | ~2MB/min | ~0.1MB/min |
При 1% sampling overhead практически незаметен. При 100% sampling добавляется ~16% к p99 — это приемлемо для dev/staging, но в production используйте tail sampling.
Что дальше
Мы построили минимально работающий observability-стек. Вот что можно добавить следующим шагом:
Service Map — Tempo и Grafana умеют автоматически строить граф зависимостей между сервисами на основе трейсов. Включается одной строчкой в datasource-конфиге Grafana и сразу показывает, какой сервис перегружен и где bottleneck.
Alerts — Grafana поддерживает алертинг по любому источнику данных. Классический набор: alert на error rate > 1%, alert на p99 > 500ms, alert на отсутствие трейсов (значит сервис упал).
Continuous profiling — Grafana Pyroscope позволяет корелировать трейсы с CPU/memory профилями. Если у вас медленный span — можно посмотреть, какая именно функция съедала CPU в этот момент.
OpenTelemetry в Kubernetes — OTel Operator позволяет автоматически инструментировать все поды без изменения кода через admission webhooks. Настроил один раз на уровне кластера — и все новые сервисы автоматически шлют данные.
Итог
OpenTelemetry — это не ещё один инструмент в стек. Это способ перестать жить в трёх разных системах и начать видеть своё приложение как единое целое.
За один день мы получили:
Автоматические трейсы для всех HTTP-запросов, SQL-запросов и вызовов Redis без единой строчки ручного кода в роутах
Кастомные метрики с правильной кардинальностью и histogram buckets под нашу предметную область
Структурированные логи, автоматически привязанные к трейсам через
trace.idКорреляцию между всеми тремя сигналами в едином Grafana UI
Vendor independence — завтра можно заменить Tempo на Jaeger, не меняя ни строчки в приложении
Главный инсайт: OTel не требует переписывать приложение. getNodeAutoInstrumentations() даёт вам 80% ценности бесплатно. Остальные 20% — это ручные spans для бизнес-логики, которые вы добавляете по мере необходимости.