Трейсинг и алертинг: OpenTelemetry, SLO и on-call
Логи отвечают «что случилось», метрики — «насколько плохо». Но есть вопрос, на который не отвечает ни один из них: «где именно по цепочке сервисов потерялись 800 миллисекунд?». Запрос прошёл через Nginx, Node.js, очередь, воркер, PostgreSQL — и каждый компонент честно отрапортовал «всё ок, я работал». Только трейсинг показывает цепочку как единое целое: 50 мс в Nginx, 80 в API, 650 в SELECT без индекса.
В этой главе разберём третий столп наблюдаемости — распределённый трейсинг на OpenTelemetry: концепции trace и span, распространение контекста через HTTP-заголовки, развёртывание Jaeger и инструментацию Node.js-приложения. Научимся связывать трейсы с логами через trace_id — когда по одному идентификатору из алерта ты за секунду видишь и метрику, и лог, и «водопад» спанов. А затем перейдём к алертингу как инженерной дисциплине: Alertmanager с маршрутизацией и подавлением шума, полные примеры правил Prometheus, SLI/SLO с error budget и основы on-call ротации. Отдельно стоит знать про Sentry — трекинг исключений приложения: ошибки со стеком, релизом и контекстом запроса, дополняющие трейсинг и алертинг.
Трейсинг: концепции OpenTelemetry
Заголовок раздела «Трейсинг: концепции OpenTelemetry»Trace, span, контекст
Заголовок раздела «Trace, span, контекст»- Trace — полный путь одного запроса через систему. Состоит из деревьев спанов.
- Span — одна операция с началом, длительностью, статусом и атрибутами. HTTP-запрос к API — span; SQL-запрос внутри него — дочерний span; DNS-резолвинг — ещё один.
- Context propagation — механизм передачи идентификатора трейса между сервисами (как это устроено — в документации OpenTelemetry). HTTP-клиент добавляет заголовки
traceparent: 00-<trace-id>-<span-id>-01, следующий сервис читает их и продолжает трейс вместо начала нового.
Trace a1b2c3d4e5f6... (запрос GET /api/orders)├── Span: nginx → 12 ms├── Span: express middleware → 3 ms├── Span: GET /api/orders handler → 800 ms│ ├── Span: authenticate (JWT verify) → 15 ms│ ├── Span: SELECT orders (PostgreSQL) → 650 ms ← вот оно│ └── Span: serialize response → 20 ms└── Span: send response → 5 msКлючевое свойство: трейс собирается автоматически из инструментированных компонентов. Разработчик не пишет «начать трейс здесь, закончить там» для каждого вызова — SDK делает это за него через автоматическую инструментацию HTTP-клиентов, фреймворков и драйверов БД.
Развёртывание Jaeger
Заголовок раздела «Развёртывание Jaeger»Jaeger — система хранения и визуализации трейсов от Uber. Архитектура: приложения шлют спаны OTLP-коллектору, Jaeger хранит их в БД (для pet-проекта — in-memory или Badger, для продакшена — Elasticsearch/ClickHouse) и показывает в UI.
# docker-compose.yml — Jaeger all-in-one для pet-проектаservices: jaeger: image: jaegertracing/all-in-one:1.60 ports: - "16686:16686" # UI - "4318:4318" # OTLP HTTP (приём спанов) environment: COLLECTOR_OTLP_ENABLED: true SPAN_STORAGE_TYPE: badger # badger = локальное хранилище на диске BADGER_EPHEMERAL: false BADGER_DIRECTORY_VALUE: /badger/data BADGER_DIRECTORY_KEY: /badger/key volumes: [jaeger-data:/badger]
volumes: jaeger-data:Открой http://localhost:16686 — UI Jaeger: выбор сервиса, поиск по trace_id, «водопад» спанов. All-in-one годится для pet-проекта; при росте нагрузки переезжай на коллектор + отдельное хранилище.
Инструментация Node.js
Заголовок раздела «Инструментация Node.js»OpenTelemetry SDK для Node.js: автоматическая инструментация Express, HTTP-клиента, PostgreSQL через pg, плюс ручные спаны для бизнес-операций.
npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \ @opentelemetry/exporter-trace-otlp-http @opentelemetry/resources \ @opentelemetry/semantic-conventions// tracing.ts — инициализация при старте приложения (первый импорт!)import { NodeSDK } from "@opentelemetry/sdk-node";import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";import { resourceFromAttributes } from "@opentelemetry/resources";import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
const sdk = new NodeSDK({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "pet-api", // имя сервиса в Jaeger }), traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT // http://jaeger:4318/v1/traces // или http://localhost:4318/v1/traces }), instrumentations: [getNodeAutoInstrumentations()],});
sdk.start();// Graceful shutdown: при SIGTERM — sdk.shutdown(), чтобы успели уйти последние спаны// index.ts — ПЕРВАЯ строка: трассировка до любого другого импортаimport "./tracing";import express from "express";// ... остальной код
// Ручной спан для бизнес-операции (внутри автоматических спанов HTTP)import { trace } from "@opentelemetry/api";
app.post("/api/payments", async (req, res) => { const tracer = trace.getTracer("pet-api"); await tracer.startActiveSpan("process-payment", async (span) => { span.setAttribute("payment.amount", req.body.amount); // атрибут — в Jaeger try { const result = await chargeCard(req.body); span.setStatus({ code: SpanStatusCode.OK }); res.json(result); } catch (err) { span.recordException(err); // стек в спане span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); res.status(502).json({ error: "payment failed" }); } finally { span.end(); // спан завершён } });});Важная деталь: инструментация pg оборачивает каждый SQL-запрос в span с текстом запроса (без параметров — конфиденциальность). В Jaeger видишь: SELECT ... FROM orders WHERE user_id = $1 — 650 мс. Проблема найдена без единого grep по логам.
Сэмплирование трейсов
Заголовок раздела «Сэмплирование трейсов»Каждый запрос → трейс из десятков спанов. При 1000 RPS полное собирание убьёт сеть и хранилище. Решение — head-based sampling: решение о записи принимается на старте трейса (голове) и распространяется контекстом:
import { NodeSDK } from "@opentelemetry/sdk-node";import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-node";
const sdk = new NodeSDK({ sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(0.1), // 10% трейсов на старте }), // ...});ParentBasedSampler — если входящий запрос уже в трейсе (заголовок traceparent присутствует), решение родителя уважается: сервис B продолжает трейс, который сервис A решил записать. Это гарантирует целостность: не будет трейсов, обрывающихся посередине цепочки. Для ошибок есть tail-based sampling в OTel Collector: решение после завершения трейса («записать, если есть error-спан») — мощнее, но требует централизованного коллектора.
Связывание логов и трейсов
Заголовок раздела «Связывание логов и трейсов»По отдельности лог и трейс — полкартины. Вместе — полная: лог даёт контекст («payment failed for user 4821»), трейс — место и время каждой операции. Связка через trace_id, который OTel проставляет в логи:
// logger.ts — pino с trace_id из активного спанаimport { pino } from "pino";import { trace } from "@opentelemetry/api";
export const log = pino({ level: process.env.LOG_LEVEL ?? "info", base: { service: "pet-api" }, mixin() { const span = trace.getActiveSpan(); if (!span) return {}; const ctx = span.spanContext(); return { trace_id: ctx.traceId, span_id: ctx.spanId }; // в каждую строку лога },});Теперь расследование — три клика: алерт «error rate вырос» → в логе ошибки trace_id: a1b2c3... → в Jaeger открываешь трейс по этому ID → видишь водопад: где именно упало. И наоборот: из трейса с медленным спаном берёшь trace_id, грепишь логи — контекст бизнес-операции.
Алертинг: от метрики до человека
Заголовок раздела «Алертинг: от метрики до человека»Метрики без алертов — картинка. Цепочка: Prometheus правило → Alertmanager → маршрутизация → получатель (Telegram, webhook, PagerDuty). Каждое звено решает свою задачу: Prometheus определяет «что случилось», Alertmanager — «кому и как сказать».
Alertmanager: routes, inhibit_rules, receivers
Заголовок раздела «Alertmanager: routes, inhibit_rules, receivers»global: smtp_smarthost: "smtp.example.com:587" smtp_from: "alerts@darkpix.dev"
route: receiver: default # приёмник по умолчанию group_by: [alertname, severity] # группировать похожие алерты в одно сообщение group_wait: 30s # ждать 30 секунд, собирая группу group_interval: 5m # между отправкой обновлений группы repeat_interval: 4h # не спамить: повторяем алерт раз в 4 часа
routes: - match: { severity: critical } # critical — сразу в Telegram receiver: telegram-ops continue: false - match: { severity: warning } # warning — дайджестом в вебхук receiver: webhook-digest group_wait: 5m repeat_interval: 24h
# Подавление: если ApiDown (critical) — глушим все latency/error-rate алерты warning,# иначе получишь 20 сообщений об одном инцидентеinhibit_rules: - source_match: severity: critical target_match: severity: warning equal: [instance]
receivers: - name: default webhook_configs: - url: "http://localhost:5001/alerts"
- name: telegram-ops telegram_configs: - bot_token: ${TG_BOT_TOKEN} chat_id: ${TG_CHAT_ID} message: | 🚨 {{ .CommonAnnotations.summary }} {{ range .Alerts }} Instance: {{ .Labels.instance }} {{ .Annotations.description }} {{ end }} parse_mode: ""
- name: webhook-digest webhook_configs: - url: "https://hooks.slack.com/services/XXX/YYY/ZZZ" send_resolved: false # resolved-уведомления не шлём для warningКлючевые механики: group_by склеивает «CPU high на 3 инстансах» в одно сообщение вместо трёх; inhibit_rules решает проблему «шторма алертов» — когда упал сервис, latency/error rate алерты этого же инстанса глушатся, потому что уже ясно: сервис лежит; repeat_interval ограничивает спам.
Правила алертов: полные примеры
Заголовок раздела «Правила алертов: полные примеры»# alert.rules.yml — монтирование: prometheus --config.file=prometheus.yml,# где prometheus.yml: rule_files: [alert.rules.yml]groups: - name: pet-infrastructure interval: 30s
rules: # --- Доступность --- - alert: InstanceDown expr: up{job=~"app|node"} == 0 for: 2m # не мигать: держится 2 минуты labels: { severity: critical, team: ops } annotations: summary: "Инстанс {{ $labels.instance }} недоступен" description: "Prometheus не может достучаться до {{ $labels.job }} на {{ $labels.instance }} уже 2 минуты."
# --- Ресурсы --- - alert: DiskAlmostFull expr: node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes < 0.15 for: 10m labels: { severity: warning, team: ops } annotations: summary: "Диск {{ $labels.instance }} заполнен на {{ $value | humanizePercentage }}" description: "На {{ $labels.instance }} свободно меньше 15% на {{ $labels.mountpoint }}. Очисти логи/бэкапы."
- alert: CpuHigh expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90 for: 15m labels: { severity: warning, team: ops } annotations: summary: "CPU {{ $labels.instance }} загружен на {{ $value | humanize }}%" description: "Средняя загрузка CPU за 15 минут выше 90%. Проверь топ-процессы (cAdvisor/Node Exporter)."
- alert: MemoryHigh expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1 for: 10m labels: { severity: warning, team: ops } annotations: summary: "Мало свободной памяти на {{ $labels.instance }}" description: "Available memory < 10%. Возможен OOM."
# --- Приложение (RED) --- - alert: HighErrorRate expr: | sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.01 for: 5m labels: { severity: critical, team: app } annotations: summary: "Доля 5xx выше 1% ({{ $value | humanizePercentage }})" description: "Проверь логи: {service=\"pet-api\"} |= \"error\". Возможен баг в последнем деплое."
- alert: HighLatencyP95 expr: | histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])) ) > 0.5 for: 10m labels: { severity: warning, team: app } annotations: summary: "P95 латентности {{ $value }} сек (порог 0.5)" description: "P95 выше 500 мс 10 минут. Базовая линия: 120 мс. Проверь медленные запросы в Jaeger."
- alert: PodRestartLoop # для Docker: контейнер перезапускается expr: increase(container_start_time_seconds[15m]) > 3 for: 0m labels: { severity: critical, team: app } annotations: summary: "Контейнер {{ $labels.name }} перезапускается в цикле" description: "Более 3 рестартов за 15 минут. Смотри docker logs {{ $labels.name }}."Каждое правило — по формуле: expr (PromQL) + for (длительность) + labels (severity для маршрутизации) + annotations (summary/description — это текст, который получит человек). Пороги выводи из базовой линии, а не из головы: замерил нормальный P95 = 120 мс → порог 500 мс; нормальная доля 5xx = 0.05% → порог 1%.
SLI, SLO и error budget
Заголовок раздела «SLI, SLO и error budget»Алерты «CPU > 90%» — технические, но бизнесу плевать на CPU. SLI (Indicator) — измеримый показатель качества сервиса: доля успешных запросов (availability), P95 латентности. SLO (Objective) — цель по SLI за период: «99.9% запросов успешны за 30 дней». Error budget — допустимый объём нарушений: 0.1% от всех запросов за месяц.
SLO: 99.9% availability за 30 дней→ Error budget: 0.1% запросов могут быть 5xx→ При 1M запросов/мес: 1000 запросов «в копилке»→ Сгорели за неделю: 950 → стоп деплоя новых фич, стабилизация→ Сгорели: 100 → откат последнего деплоя, инцидент-ревьюError budget — рычаг баланса скорости и стабильности: пока бюджет есть — команда деплоит смело; бюджет кончается — фиксируем стабильность, замораживаем фичи. Это объективный аргумент в споре «деплоить быстрее» vs «стабильнее»: цифры вместо мнений.
| SLI | SLO (пример) | Чем измеряем |
|---|---|---|
| Availability | 99.9% запросов без 5xx за 30 дн | 1 - sum(rate(5xx[30d]))/sum(rate(all[30d])) |
| Latency | P95 < 300 мс за 30 дн | histogram_quantile(0.95, ...) |
| Freshness (для очередей) | лаг < 5 мин | queue depth/rate |
Для pet-проекта формализуй хотя бы availability SLO — он же источник порогов алертов: SLO 99.9% → алерт на быстрое сгорание бюджета (например, 5xx rate > 1% за 5 минут = тратим бюджет в 6 раз быстрее нормы).
On-call ротация
Заголовок раздела «On-call ротация»Когда алерты приходят в 3 часа ночи, должен быть человек, который отвечает. On-call — расписание дежурств: первичный (ответить за 5 минут) и вторичный (если первичный недоступен).
Практика для маленькой команды (2-3 человека):
- Период: неделя на человека, ротация в понедельник утром (не в пятницу вечером).
- Уведомление: critical алерты — звонок/PagerDuty, не молчаливый webhook. Telegram-пинг — минимум.
- Эскалация: если за 15 минут нет ack — вторичному, ещё через 15 — всем.
- Компенсация: дежурство — работа. Оплачивается/отгуливается; это не «просто держи телефон рядом».
Инструменты: PagerDuty/Opsgenie (платные, для команд), Grafana OnCall (open source, интеграция с Alertmanager), простой вариант для pet-проекта — Telegram-бот с кнопкой «принял» и эскалацией через cron-скрипт.
Сквозной чек-лист наблюдаемости
Заголовок раздела «Сквозной чек-лист наблюдаемости»Перед тем как считать наблюдаемость «готовой», спровоцируй инцидент и замерь время реакции:
- Убей процесс приложения → алерт
InstanceDownпришёл за 2-5 минут? В сообщении — инстанс, ссылка на дашборд, понятное действие? - Заполни диск до 90% →
DiskAlmostFullс указанием, что чистить (логи Docker? бэкапы?)? - Разлей 5xx на 2% трафика (фича-флаг в canary) →
HighErrorRateраньше, чем жалобы пользователей? - По
trace_idиз алерта → трейс в Jaeger открывается за секунды, показывает спан-виновник?
Если хоть один ответ «нет» — не хватает сигнала, а не дисциплины. Верись к главам про логирование/метрики/трейсинг и закрывай пробел.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Трейсинг без сэмплирования. 100% трейсов при 500 RPS: Jaeger захлёбывается, диск полон, сеть забита спанами. Хорошо: head-based sampling 1-10%, tail-based для ошибок через OTel Collector.
- OTel-инициализация после импорта Express.
import "./tracing"должен быть первой строкой — иначе HTTP-инструментация не перехватит require фреймворка и спанов не будет. Хорошо: проверьtracer.startSpan("test")в стартап-логе. - Шторм алертов без inhibit_rules. Упал сервис → 15 алертов (down, latency, error rate, CPU) в Telegram за минуту → телефон выключен, настоящий инцидент пропущен. Хорошо: critical глушит warning той же инстанс-группы, group_by склеивает похожие.
- Алерты без
for(мгновенные). Сетевой глитч на 3 секунды → алерт «InstanceDown» → паника → алерт resolved через 5 секунд. Хорошо:for: 2mминимум для availability, 5-10 минут для ресурсов — исключает шум. trace_idне в логах. В Jaeger видишь медленный спан, но не можешь найти лог этого запроса — контекст потерян. Хорошо: pino-mixin сtrace_id/span_idиз активного спана (код выше), одинаковый формат во всех сервисах.- SLO «100% availability». Недостижимо: любой деплой, любой сетевой глитч — нарушение. Команда либо игнорирует SLO, либо не деплоит вообще. Хорошо: 99.9% для старта (43 минуты даунтайма в месяц — реально), error budget управляет скоростью.
- On-call «кто-нибудь да посмотрит». Алерт пришёл в общий чат, никто не ответил 2 часа. Хорошо: один ответственный по расписанию, ack-кнопка, эскалация вторичному, ревью каждого пропущенного алерта.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем трейсинг отличается от логирования? Лог — точечное событие в одном сервисе; трейс — сквозной путь запроса через все сервисы с длительностями каждой операции. Лог отвечает «что случилось», трейс — «где потерялось время/где упало». Трейс строится автоматически из спанов, лог — явно пишется разработчиком.
- Как OpenTelemetry распространяет контекст между сервисами? Через заголовок
traceparent(W3C Trace Context):00-<trace-id>-<parent-span-id>-<flags>. HTTP-клиент инструментированного сервиса добавляет его, входящий middleware следующего сервиса извлекает и делает спан дочерним. Решение о сэмплировании тоже передаётся в флагах. - Что такое head-based и tail-based sampling? Head — решение на старте трейса (быстро, просто, но не знаем, будет ли трейс ошибочным). Tail — решение после завершения трейса (можем записать все ошибки, но требует буферизации в коллекторе и отложенной отправки). Для большинства — head с ratio 1-10%, tail — в OTel Collector для точного контроля.
- Как связать логи и трейсы? Добавить
trace_idиspan_idиз активного OTel-спана в каждую JSON-строку лога (pino mixin). Тогда по ID из трейса в Jaeger находишь логи, и наоборот. Требует: единый формат JSON, автоматическая инструментация спанов. - Зачем нужен Alertmanager, если Prometheus умеет в webhook? Prometheus шлёт сырые алерты; Alertmanager добавляет маршрутизацию по severity/labels, группировку (одно сообщение вместо 20), подавление (inhibit_rules), повторные отправки и интервалы, интеграцию с разными получателями (Telegram, PagerDuty, Slack). Без него — шторм и хаос.
- Что такое error budget и как его использовать? Допустимый объём нарушений SLO за период (например, 0.1% 5xx при SLO 99.9%). Пока бюджет не исчерпан — деплоим смело; при исчерпании — стабилизация, заморозка фич, фокус на надёжности. Объективный баланс скорости и стабильности, аргумент в планировании.
- Почему алерты должны иметь
forи как подобрать длительность?forтребует, чтобы условие держалось N минут — отсекает кратковременные всплески (сетевые глитчи, GC-паузы). Длительность из SLO: если SLO 99.9% за 30 дней, алерт сfor: 2mна error rate 1% сигнализирует о трате бюджета в 6 раз быстрее допустимого — успеем среагировать. - SLI vs SLA: в чём разница? SLI — измеряемый показатель (доля 200/300 ответов). SLO — внутренняя цель команды по SLI (99.9%). SLA — внешний контракт с пользователем/клиентом, обычно мягче SLO (99.5%) с компенсациями (кредиты). SLO должен быть строже SLA, чтобы успеть исправить до нарушения контракта.
Практика
Заголовок раздела «Практика»- Подними Jaeger all-in-one через compose, инструментируй pet-приложение OTel SDK (service name
pet-api, экспорт в OTLP). Найди в UI самый медленный трейс за час: какой спан занимает 80% времени? Оптимизируй и сравни. - Настрой pino-mixin с
trace_id/span_id. Сымитируй ошибку в API, найди её лог поtrace_idв Loki, открой трейс в Jaeger по этому же ID — три клика от алерта до корня. - Напиши 5 alert-правил из примера (InstanceDown, DiskAlmostFull, CpuHigh, HighErrorRate, HighLatencyP95) с порогами из твоей базовой линии. Сымитируй каждый (убей процесс, заполни диск tmp-файлом, нагрузи CPU
stress-ng, верни 500 из эндпоинта, добавьsleep(600)в handler) и убедись: алерт пришёл, содержит summary/instance, resolved пришёл после восстановления. - Настрой Alertmanager с двумя маршрутами: critical → Telegram сразу, warning → вебхук-дайджест раз в сутки. Добавь inhibit_rule: InstanceDown глушит warning-алерты того же инстанса. Проверь шторм: убей сервис и убедись, что пришло ОДНО сообщение, а не пять.
- Формализируй SLO для pet-проекта: availability 99.9% за 30 дней. Посчитай error budget в запросах (твой RPS × 2592000 × 0.001). Настрой алерт «быстрое сгорание бюджета»: error rate > 0.1% за час. Отслеживай остаток бюджета на дашборде.
- Спроектируй on-call для команды из двух человек: расписание недельной ротации, эскалация (5 мин → вторичный → все), отчёт по инциденту (timeline, root cause, action items). Проведи учебный инцидент: один «спит», алерт эскалируется ко второму.
Что почитать
Заголовок раздела «Что почитать»- OpenTelemetry — Node.js instrumentation и Trace Context (W3C)
- Jaeger — getting started и architecture
- Prometheus — alerting rules и Alertmanager configuration
- Google SRE Book — Monitoring Distributed Systems — SLI/SLO, error budget
- Google SRE Book — Alerting on SLOs — burn rate alerting
- Grafana OnCall — open source on-call ротация