Наблюдаемость: логи, метрики, трейсинг

В монолите отладка — это стектрейс. В микросервисах запрос проходит через восемь процессов на разных машинах, и без специальных усилий вы просто ослепли.

Наблюдаемость (observability) — свойство системы, позволяющее по её внешним сигналам (логи, метрики, трейсы) понять, что происходит внутри, не выкатывая новый код.

Разница с привычным мониторингом принципиальна. Мониторинг отвечает на вопросы, которые вы придумали заранее: «жив ли сервис», «не кончилось ли место». Наблюдаемость нужна для вопросов, которых вы не предвидели: «почему у пользователей из мобильного приложения с промокодом оплата тормозит, и только по вторникам?» Заранее такой дашборд никто не нарисует — данные должны позволять задать вопрос уже после того, как всё сломалось.

Три столпа

СтолпНа какой вопрос отвечаетЧто это физическиЧем ограничен
МетрикиСломалось? Насколько плохо? Когда началось?Числовые ряды во времени: rps, доля ошибок, время ответаДёшевы, но в них нельзя класть уникальные значения (см. кардинальность ниже)
ЛогиЧто именно случилось с этим запросом?События с контекстом, обычно JSON-строкиДороги при больших объёмах; нужны уровни и сэмплирование
ТрейсыГде именно потерялись 800 мс из 900?Дерево спанов одного запроса через все сервисыПочти всегда сэмплируются: хранить 100% трейсов дорого

В настоящем инциденте вы пользуетесь ими строго по очереди. Метрика кричит: «доля 5xx выросла до 4%». Трейс показывает: «время уходит в сервисе инвентаря, а внутри него — в запросе к Postgres». Лог договаривает: «lock wait timeout на таблице reservations». Метрика — что, трейс — где, лог — почему. Пропустите любой столп, и один из этих вопросов останется без ответа.

Trace-id: нить, которая связывает всё

Это самая важная вещь во всём уроке. Один запрос пользователя порождает десятки записей в логах разных сервисов на разных машинах. Без общего идентификатора связать их невозможно — вы будете искать «что-то в логах платежей около 10:31:02», где в эту секунду 4000 записей от других пользователей.

Trace-id — идентификатор, который рождается на входе в систему (обычно в API Gateway), кладётся в каждую запись лога и передаётся дальше по всем вызовам. Отраслевой стандарт — W3C Trace Context, заголовок traceparent:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^^ ^                              ^                ^
             |  |                              |                флаги: 01 = трейс сэмплируется
             |  |                              span-id (8 байт): ЭТОТ конкретный шаг
             |  trace-id (16 байт): один на весь путь запроса
             версия формата

Здесь два разных id, и путать их нельзя. Trace-id один на весь запрос, от кнопки до базы. Span-id — идентификатор одного шага (один HTTP-вызов, один запрос к базе). У каждого спана есть родитель, и из этих связей собирается дерево. Визуализируют его «водопадом»:

api-gateway   [============================ 820 мс ============================]
  orders        [====================== 760 мс ======================]
    payments      [== 90 мс ==]
    inventory                  [============ 410 мс ============]
      postgres                   [========== 380 мс =========]  <- вот он, виновник
    notify                                                     [= 12 мс =]

Одна картинка мгновенно отвечает на вопрос, ради которого раньше поднимали пять человек в чат: 380 мс из 820 съел один запрос к Postgres в сервисе инвентаря. Без трейсинга этот вывод добывается часами.

Где trace-id теряется

Обычно в трёх местах, и все три надо проверять руками:

  • Асинхронная граница. Сервис положил сообщение в RabbitMQ и не проложил traceparent в headers сообщения. Трейс обрывается ровно там, где интереснее всего. Правило: любой отправитель в очередь обязан класть контекст в заголовки сообщения, любой потребитель — доставать его оттуда и продолжать трейс.
  • Фоновые задачи и пулы потоков. Контекст живёт в контексте выполнения (contextvars, AsyncLocal, ThreadLocal) и не переезжает в новый поток сам.
  • Самописный HTTP-клиент. Автоинструментация патчит известные ей библиотеки; ваш велосипед она не знает и заголовок не вставит.

Логи: что писать и чего не писать никогда

Первое: логи должны быть структурными. Не строка "order 8831 failed: timeout", которую можно только грепать, а объект с полями — по нему можно фильтровать, группировать и строить графики:

{
  "ts": "2026-07-14T10:31:02.481Z",
  "level": "error",
  "service": "orders",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "event": "payment_failed",
  "order_id": "ord-8831",
  "user_id": 10427,
  "reason": "upstream_timeout",
  "upstream": "payments",
  "duration_ms": 3002
}

Такой лог даёт мгновенный ответ на вопрос «сколько заказов упало по таймауту платежей за час» — обычной агрегацией, без регулярных выражений. И, главное, в нём есть trace_id: из лога вы одним кликом проваливаетесь в трейс.

Пишем всегдаtrace_id, span_id, имя сервиса, уровень, событие, длительность, коды ошибок внешних сервисов, ключевые бизнес-идентификаторы (order_id, user_id)
Пишем осторожноТела запросов (обрезать до N килобайт), стектрейсы (только на error), SQL-запросы (текст — да, значения параметров — нет)
Не пишем НИКОГДАПароли, токены и заголовок Authorization, номера карт и CVV, персональные данные (паспорт, адрес, телефон), содержимое личных сообщений, ключи API

Последнюю строку стоит воспринимать всерьёз. Логи уезжают в поисковый индекс, доступ к которому есть у половины инженеров, у подрядчиков и у мониторинга. Токен, попавший в лог, считается скомпрометированным — его придётся отзывать. А номер карты в логе — это уже не техническая проблема, а разговор с юристами.

Метрики: RED и почему среднее врёт

Для любого сервиса минимальный набор метрик называется RED:

R — RateСколько запросов в секунду приходит
E — ErrorsКакая доля из них завершается ошибкой
D — DurationРаспределение времени ответа — именно распределение, а не одно число

На слове «распределение» ломаются почти все. Смотреть на среднее время ответа бессмысленно, и вот почему:

import statistics

# длительности 100 запросов в миллисекундах: 95 быстрых и 5 «залипших»
latencies = [40 + (i % 20) for i in range(95)] + [2100, 2400, 2700, 3000, 5200]
latencies.sort()


def percentile(data, p):
    index = min(int(len(data) * p / 100), len(data) - 1)
    return data[index]


print("запросов:       %d" % len(latencies))
print("среднее:        %.0f мс   -- выглядит вполне прилично" % statistics.mean(latencies))
print("медиана (p50):  %d мс" % percentile(latencies, 50))
print("p95:            %d мс" % percentile(latencies, 95))
print("p99:            %d мс   -- вот что чувствует самый несчастный клиент" % percentile(latencies, 99))

Результат:

запросов:       100
среднее:        201 мс   -- выглядит вполне прилично
медиана (p50):  50 мс
p95:            2100 мс
p99:            5200 мс   -- вот что чувствует самый несчастный клиент

Среднее — 201 мс, дашборд зелёный, все довольны. При этом каждый двадцатый пользователь ждёт больше двух секунд, а каждый сотый — больше пяти. Именно эти люди пишут в поддержку и уходят к конкурентам. Поэтому в целях (SLO) фигурируют перцентили: «p99 времени ответа меньше 500 мс», а не «среднее время ответа».

Кардинальность — мина под мониторингом

У метрики есть лейблы (метки): http_requests_total{service="orders", status="500"}. Каждая уникальная комбинация лейблов — это отдельный временной ряд в хранилище. Пара десятков сервисов × пять статусов = сотня рядов, отлично.

А теперь кто-то добавляет лейбл user_id. Миллион пользователей = миллион рядов. Prometheus съедает всю память и умирает — и вместе с ним умирает ваш мониторинг, ровно в тот момент, когда он нужнее всего. Правило железное: уникальные идентификаторы (user_id, order_id, trace_id) идут в логи и трейсы, но НИКОГДА в лейблы метрик. В метриках место только тому, у чего мало значений: сервис, эндпоинт, код ответа, регион.

Как это работает

Писать это руками не нужно — есть OpenTelemetry (OTel), вендоронезависимый стандарт и набор SDK. Его автоинструментация подменяет популярные HTTP-клиенты, серверные фреймворки и драйверы БД, так что traceparent сам вставляется в исходящие запросы и сам вычитывается из входящих. Руками код нужно писать только на границах, о которых OTel не знает: свой протокол, экзотическая очередь, ручной пул потоков.

Дальше данные едут через OTel Collector — отдельный процесс, который принимает сигналы от приложений, фильтрует, сэмплирует и раскладывает по хранилищам. Типичный стек: Prometheus или VictoriaMetrics (метрики), Loki или Elasticsearch (логи), Jaeger или Tempo (трейсы), Grafana как единое окно поверх всего.

Важная мелочь: приложение пишет логи в stdout и не знает, куда они поедут. Собирает их агент (Promtail, Fluent Bit, Vector), запущенный рядом. Приложение, которое само ходит в Elasticsearch, — это приложение, которое ляжет вместе с Elasticsearch.

Наконец, сэмплирование. Хранить трейс каждого запроса нереально дорого. Есть два подхода. Head-based: решение принимается на входе (оставляем 1% трейсов) — дёшево, но именно тот трейс, который сломался, вы с вероятностью 99% выбросите. Tail-based: коллектор дожидается всего трейса и оставляет те, где были ошибки или превышена длительность — дороже, зато в хранилище попадает ровно то, что интересно.

Частые ошибки

  • Нет сквозного trace-id. Отладка превращается в археологию по таймстампам. Это ошибка номер один — всё остальное лечится, эта нет.
  • Trace-id есть, но обрывается на очереди. Контекст не проложен в headers сообщения, и трейс кончается ровно там, где начинается интересное.
  • Смотрим на среднее время ответа. Оно прячет хвост распределения. Смотрите p95 и p99.
  • Взрыв кардинальности. order_id в лейбле Prometheus кладёт мониторинг во время инцидента.
  • Логи-строки вместо структурных. Грепать можно, агрегировать нельзя. Одна строка — один event и поля, а не форматированный текст.
  • Логирование в цикле. Строка на каждый из 10 000 элементов: сервис тратит на логи больше, чем на работу, а счёт за хранение обгоняет счёт за вычисления.
  • Алерты на всё подряд. Дежурный за неделю привыкает их игнорировать (это называется alert fatigue). Алертить надо на симптомы, которые чувствует пользователь — доля 5xx, p99, — а не на «CPU 80%».
  • Health check всегда возвращает 200. Kubernetes уверен, что под здоров, а тот давно потерял базу и молча отвечает ошибками.
  • Секреты в логах. Токен в логе = отозванный токен. Данные карты в логе = инцидент с регулятором.

Итоги

  • Наблюдаемость — про вопросы, которых вы не предвидели. Мониторинг — про те, что предвидели. В микросервисах нужны оба.
  • Метрики отвечают «что сломалось», трейсы — «где», логи — «почему». В инциденте вы идёте именно в таком порядке.
  • Сквозной trace-id — обязательное условие. Рождается на входе, летит во всех HTTP-заголовках и в headers сообщений, попадает в каждую строку лога.
  • Стандарт — W3C traceparent: версия, trace-id (один на запрос), span-id (один на шаг), флаги.
  • Логи — структурные, с trace_id. Никогда — пароли, токены, карты, персональные данные.
  • Метрики — RED (Rate, Errors, Duration) и перцентили вместо среднего. Уникальные id в лейблы не кладём никогда.
  • Инструментирует всё это OpenTelemetry; логи пишем в stdout, а собирает их агент рядом.
Проверьте себя
1. Средняя длительность запроса — 201 мс, а p99 — 5200 мс. Что это означает на практике?
AДанные противоречивы, где-то ошибка в расчёте метрик
BКаждый сотый запрос обслуживается дольше 5 секунд — среднее просто прячет этот хвост
CРовно 99% запросов выполняются за 5200 мс
DСервис работает нормально: раз среднее низкое, пользователи довольны
2. Почему нельзя добавлять order_id в качестве лейбла метрики Prometheus?
APrometheus не поддерживает строковые лейблы, только числовые
BЭто нарушает методологию RED, которая запрещает бизнес-данные
CКаждая уникальная комбинация лейблов создаёт отдельный временной ряд — миллионы заказов взорвут кардинальность и положат хранилище метрик
Dorder_id может содержать персональные данные пользователя