Наблюдаемость для API: трейсинг, корреляция запросов и измеримые SLA
Настроим подход к логам/метрикам/трейсам так, чтобы инциденты находились за минуты, а не “по ощущениям”. Рассмотрим корреляцию по request-id и практики формирования событий.
Содержание
Наблюдаемость для API: трейсинг, корреляция запросов и измеримые SLA
API — это не просто набор эндпоинтов. Это распределённая система с сетью, очередями, балансировщиками, кешами, базами данных, внешними сервисами и множеством отказов на пути от запроса до ответа. В таких условиях «посмотреть логи» редко работает как стратегия. Нужна наблюдаемость (observability): возможность быстро понять что случилось, где произошло, почему, и насколько это влияет на SLA.
В этой статье разберём практический подход к наблюдаемости для API, который помогает находить инциденты не «по ощущениям», а за минуты: трейсинг запросов, корреляция по request-id, формирование событий и метрик, а также связка с измеримыми SLA/SLO. Будем говорить не абстрактно, а через конкретные практики и примеры конфигурации.
Из чего состоит наблюдаемость API
Наблюдаемость обычно описывают тремя «китами»:
- Логи — текстовые события, удобные для диагностики, но сложно коррелировать и искать причинно-следственные связи в распределённой системе.
- Метрики — агрегированные численные показатели (latency, error rate, throughput), полезны для контроля и оповещений, но без контекста могут быть «серой коробкой».
- Трейсы (трейсинг) — распределённые трассы, которые описывают путь запроса через сервисы и позволяют видеть причинные цепочки.
Для API важна не просто настройка трёх типов данных, а их согласованность: общий идентификатор запроса, единая семантика ошибок, одинаковые правила атрибуции (какой сервис что измеряет), и понятные связки «тревога → трасса → причина → влияние на пользователей».
Почему инциденты «по ощущениям» не масштабируются
Когда в проде что-то сломалось, обычно происходит следующее:
- Приходит алерт по метрике (например, вырос 5xx).
- Команда ищет логи по времени и IP.
- В попытках угадать причину просматривает много «несвязанных» событий.
- Если несколько сервисов участвуют в запросе, то поиск начинает превращаться в ручной комбинаторный ад.
Проблема не в том, что логи плохие. Проблема в том, что взаимосвязь событий не выражена в данных так, чтобы её можно было машинно восстановить.
Корреляция запросов: request-id как базовый каркас
request-id vs trace-id
Есть два похожих, но разных концепта:
request-id— идентификатор на уровне «внешнего запроса» (обычно на входе в API). Он помогает связать действия в пределах одного логического запроса.trace-id— идентификатор трассы (в терминах OpenTelemetry/Distributed Tracing). Он объединяет span-ы (отрезки выполнения) в дерево/граф.
На практике их можно связать:
request-idиспользуется как «человеческий» и удобный для логов корреляционный ключ.trace-id— как ключ трассировки для инструментов трейсинга.
Хорошая практика: прописывать один и тот же идентификатор (или однозначную связку) между заголовками, логами и трейсам, чтобы при наведении на запрос вы не перескакивали между разными контекстами.
Где генерировать request-id
Правило простое: идентификатор должен появляться на самом раннем этапе обработки и передаваться дальше.
- Если у вас есть API Gateway / Ingress — обычно request-id генерируется там или прокидывается от клиента.
- Если у вас напрямую экспонирован сервис — генерируйте request-id в middleware на входе.
Дополнительно важно: request-id должен быть устойчивым до конца жизни запроса, включая ретраи и асинхронные ветки (там — по отдельным правилам).
Пример: middleware для request-id и логов
Ниже пример на Node.js/Express, демонстрирующий идею: извлекаем X-Request-Id (или генерируем), кладём в контекст и используем в логах.
import express from "express";
import { randomUUID } from "crypto";
const app = express();
function requestIdMiddleware(req, res, next) {
const incoming = req.headers["x-request-id"];
const requestId = typeof incoming === "string" && incoming.length > 0
? incoming
: randomUUID();
req.requestId = requestId;
res.setHeader("X-Request-Id", requestId);
// Если используете pino/http-log-context или async hooks — лучше кладите в контекст.
// Здесь для простоты — в объект запроса.
next();
}
app.use(requestIdMiddleware);
app.get("/v1/users/:id", (req, res) => {
const requestId = req.requestId;
try {
// ... бизнес-логика
res.json({ ok: true, requestId });
} catch (e) {
console.error("error", { requestId, err: e });
res.status(500).json({ error: "internal", requestId });
}
});
app.listen(3000);
Ключевые моменты:
- request-id возвращаем клиенту (удобно для поддержки и корреляции).
- используем request-id в логах и ответах.
- дальше — передаём заголовок во все исходящие запросы к другим сервисам.
Передача request-id в межсервисных вызовах
В микросервисной среде запросы часто идут через HTTP/gRPC. В любом клиенте (fetch/axios/http client) добавляйте заголовок.
Пример на fetch:
async function callUserService(requestId, userId) {
const resp = await fetch(`http://users/v1/users/${userId}`, {
headers: {
"X-Request-Id": requestId
}
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`users service failed: ${resp.status} ${text}`);
}
return resp.json();
}
На стороне получателя request-id должен попасть в его контекст и логирование.
Трейсинг: как сделать видимым путь запроса
Что считать «корректным» трейсингом
Хорошая трасса для API должна содержать:
- Один корневой span на входящий запрос (server span).
- span-ы на ключевые операции: вызовы БД, кеша, внешних сервисов, вычисления (если они длительные), публикация событий.
- Семантические атрибуты: метод, путь, статус, downstream service, тип ошибки, размер ответа (если нужно), tenant/user (с оглядкой на приватность).
- Ошибки как часть модели: исключения и ошибки уровня span должны быть промаркированы, чтобы они отображались в UI и учитывались при построении метрик.
Плохая трасса — это когда span-ы не совпадают с реальными операциями или отсутствует связность.
OpenTelemetry как практичный стандарт
OpenTelemetry стал де-факто основой трейсинга в облачных средах: он стандартизирует форматы данных, что снижает «зоопарк интеграций». Критично лишь внедрить его правильно: не только включить SDK, но и добавить согласованные атрибуты и корректную передачу контекста.
Пример: instrument стек OpenTelemetry (концептуально)
Ниже — псевдореалистичный фрагмент на Python с opentelemetry-sdk и концепцией автодокументирования серверных spans. Конкретные версии пакетов могут отличаться, но логика одинакова.
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer(__name__)
def handle_request(request):
with tracer.start_as_current_span("http.server", kind=trace.SpanKind.SERVER) as span:
span.set_attribute("http.method", request.method)
span.set_attribute("http.route", request.path)
try:
# downstream call
with tracer.start_as_current_span("db.query", kind=trace.SpanKind.CLIENT) as db_span:
# db query...
pass
span.set_attribute("http.status_code", 200)
return {"status": 200}
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
span.set_attribute("http.status_code", 500)
return {"status": 500}
В реальном проекте вы обычно используете авто-инструментацию HTTP/DB клиентов, но принцип тот же: span должен отражать операцию и быть корректно помечен при ошибке.
Как увязать request-id и trace-id
В идеале:
- в логах:
request-id - в трассах:
trace-id - в UI/аналитике: запрос можно найти по request-id, а затем открыть trace по trace-id (или наоборот).
На практике часто удобно дублировать request-id в атрибут трассы (например, request.id = ...), и тогда вы можете искать трассы по request-id и не зависеть от того, чем пользователь «подсветил» контекст.
Измеримые SLA/SLO: от сигналов к обязательствам
Почему «SLA» без измерения — это юридический документ без пользы
SLA — это обещание доступности/скорости. Но без SLO (Service Level Objectives) и без измерения конкретных пользовательских сценариев SLA превращается в декларацию.
Чтобы SLA стали рабочими, вам нужен минимум:
- единая метрика качества (например, success rate и latency на конкретном маршруте);
- окно измерения (например, последние 5 минут или 30 дней);
- чёткая формула (что считается успешным, какие коды исключаются, как трактовать таймауты).
Для API типичная модель: SLO по проценту успешных ответов и/или latency p95/p99.
Примеры SLO для API
- Availability / Success rate:
- SLO: ≥ 99.9% запросов к
/v1/*успешно (2xx–3xx) в интервале 30 дней. - Обязательно определить, что делать с 4xx/5xx: например, 4xx считаем клиентскими ошибками, исключаем из «инцидентности», 5xx и таймауты — считаем отказами.
- Latency:
- SLO: p95 latency ≤ 200 ms для эндпоинта
/v1/users/:idза 5-минутное окно. - Важно: latency измеряйте как «end-to-end» от принятия запроса до отправки ответа, а не только время DB.
- Error budget:
- Отдельно считать budget и тратить его на подтверждённые инциденты, а не на любые шумные колебания.
Откуда берутся метрики: согласование с трейсингом
Если метрики «видят» только приложение, а трассы — только инфраструктуру, связь теряется. Хорошая практика:
- Метрики считаются из той же точки времени, что и корневой span.
- Теги/лейблы метрик включают route, method, статус-код и критические измерения (tenant — если оправдано).
- В событиях/трейсах есть одинаковая классификация ошибок.
Тогда вы сможете сделать расследование так:
- Метрика упала: например, p95 вырос.
- Смотрите распределение ошибок по классификации.
- Открываете top traces за период, выбираете лидирующий downstream span.
- Коррелируете запросы по request-id и подтверждаете влияние.
Формирование событий: логика «инцидент — не шум»
Почему одних алертов мало
Метрики будут всегда «шуметь». Вопрос — как превращать это в управляемые расследования.
Событийная модель (event semantics) нужна для:
- группировки инцидентов (один корень — много симптомов);
- автоматического обогащения контекста;
- сокращения времени от «алерт пришёл» до «мы знаем первопричину».
Практика: классификация ошибок и атрибуты событий
Определите минимальный набор полей для событий:
service.nameendpoint/routehttp.methodhttp.status_codeerror.type(например:timeout,db_error,upstream_5xx,validation_error)upstream.service(если ошибка вниз по цепочке)request.idtrace.id(если есть)duration_msuser_context(по возможности, с учётом приватности)
Тогда инциденты можно агрегировать: например, «таймауты к upstream X» или «рост db deadlocks».
Типичные ошибки при формировании событий
- Смешивание транспортных и бизнес-ошибок. Таймаут сети — это одно, валидация входа — другое.
- Неполные лейблы. Если вы не тэгируете route и upstream, расследование будет ручным.
- Отсутствие единого словаря
error.type. «Timeout», «timed out», «ETIMEDOUT» превращаются в десятки категорий. - События без request-id / trace-id. Тогда корреляция невозможна.
Практический сценарий: расследование за минуты
Рассмотрим типичный кейс: растут 5xx на публичном API.
Шаг 1. Метрика и алерт
Вы видите, что 5xx_rate по route /v1/orders вырос с 0.2% до 2.5% за 7 минут. Дополнительно алерт по p95 latency с 180 ms до 900 ms.
Сразу проверяете, не является ли это комбинацией нескольких причин. Например, распределите по error.type и upstream.service.
Шаг 2. Корреляция по trace/request
Открываете выборку трасс по интервалу инцидента. Фильтруете по:
http.route = /v1/ordershttp.status_code = 500(или ошибка сервера)- сортируете по длительности root span
- смотрите доминирующий downstream span
Часто вы увидите, что корневой span «ждёт» upstream.payment.charge или «залипает» в db.query.
Если вы также используете request-id, можно взять один из наиболее характерных запросов и посмотреть связанные логи в нескольких сервисах по одному X-Request-Id.
Шаг 3. Локализация первопричины
В корректной системе расследование сводится к одному из паттернов:
- upstream отдаёт 5xx → классифицируем как
upstream_5xx; - upstream таймауты →
timeout; - рост latency из-за lock contention →
db_contention; - неверные входные данные, приводящие к ошибкам в downstream →
validation_error(в зависимости от модели).
В трассе это видно как сегменты с ошибкой/статусом на конкретном span. В логах — как набор событий с одним request-id.
Шаг 4. Подтверждение влияния на SLA
Дальше вы переводите симптомы в «влияние на клиентов»:
- сколько запросов попали в период инцидента;
- сколько из них были успешны;
- какие эндпоинты пострадали;
- какие SLO нарушены и на сколько.
Именно здесь трейсинг и логирование становятся «инженерными», а не «театром лога»: вы видите не просто, что «что-то тормозит», а как это отражается на формализованных SLA/SLO.
Как настроить логирование, чтобы оно действительно помогало
Логи должны быть структурными
Текстовые логи удобны для чтения глазами, но корреляция и аналитика требуют структуры. Практический стандарт:
- JSON-формат логов
- поля:
timestamp,level,service,request.id,trace.id,message,error,duration_ms(где уместно)
Если вы внедряете структурные логи, то фильтрация по request-id становится мгновенной, а не «grep-ом по файлам».
Не логируйте всё подряд
Слишком много логов:
- создаёт шум и повышает стоимость хранения/индексации;
- ухудшает сигнал/шум;
- может утекать PII.
Подход: логируйте события, которые участвуют в расследовании: ошибки, предупреждения о деградации, start/stop ключевых операций (с duration), и контекст.
Правило: ошибки — всегда с классификацией
Вместо «Something went wrong» используйте:
error.typeerror.code(если есть)exception.class(техническое)- downstream детали (что именно не удалось)
Управление «инцидентностью»: ретраи, дедлайны и корректная картина
Отдельная зона риска для API — ретраи и таймауты. Без них метрики могут казаться странными, а трассы — запутанными.
Ретраи меняют семантику “одного запроса”
Если клиент ретраит запрос, вы получите несколько корневых трасс. Корреляция только по request-id не всегда поможет, если request-id генерируется заново на каждом ретрае.
Что делать:
- Если клиент умеет — пусть передаёт один
request-idна весь процесс. - Если ретраите вы сами (между сервисами) — нужно явно помечать попытку (
attempt=1..N) и сохранять общий корреляционный идентификатор.
В трассе это отражается span-ами попыток, а в событиях — классификацией timeout и количеством попыток.
Комментарии
Пока нет комментариев