{ "index": 50, "slug": "editorial-2026-08-mechanism-end-to-end-observability", "title": "End-to-end наблюдаемость: как не перепутать сигналы с доказательством", "excerpt": "Когда e2e-тест падает, совпадение имён в логах не связывает UI, API и worker. Разбираем propagation, смысл span/log/metric, отрицательный путь и критерий, при котором сквозной вывод можно считать проверяемым.", "contentHtml": "

В e2e-тесте упала отправка заказа. Браузер показал таймаут, API записал ошибку, worker продолжил обрабатывать очередь. Все три записи содержат checkout. Но инженер не знает, относятся ли они к одной попытке. Он тратит время на поиск «медленного сервиса», хотя разрыв мог произойти в propagation. Цена ошибки — ложный вывод, лишний rollback и повтор инцидента после следующего релиза.

\n

End-to-end наблюдаемость начинается не с дашборда. Она начинается с проверяемого контракта связи. UI, API и worker должны передать один контекст по названным границам. Каждая граница должна описывать свой сигнал. Если контекст потерян, система должна остановить сквозной вывод. Похожее имя, соседнее время и одинаковый тип операции не заменяют корреляцию.

\n

Сквозная цепочка — это контракт

\n

У цепочки есть три разных объекта: операция, контекст и запись о результате. Операция — действие пользователя или сервиса, например checkout.submit. Контекст сообщает, к какой цепочке относится текущий участок. Запись о результате говорит, что именно произошло на этом участке. Если в лог попал только текст ошибки, он не превращается в доказательство связи с конкретным запросом.

\n

Стандарт W3C Trace Context описывает HTTP-перенос контекста через traceparent. В формате версии 00 заголовок содержит версию, trace-id, parent-id и flags. trace-id идентифицирует весь trace, а parent-id — родительский участок. Нулевые идентификаторы и неверный формат недействительны: принимающая сторона не должна считать такой заголовок доказательством связи.

\n

Это не означает, что один заголовок автоматически пройдёт через всю систему. HTTP-прокси, клиентская библиотека, API gateway и consumer должны иметь договор extraction/injection. Для очереди нужен carrier сообщения: например, отдельное поле headers или metadata. Название carrier, допустимый размер, способ сериализации и поведение при потере должны быть частью контракта, а не устной договорённостью.

\n
Схема разделяет низкокардинальные классы и индивидуальные поля, а sampling показывает как отдельное правило выбора traces
Иллюстрация разделяет две независимые задачи: cardinality ограничивает форму агрегируемых признаков, а sampling выбирает наблюдаемые traces. Меньшая выборка не делает персональное поле допустимым.
\n

Три сигнала — три разных вопроса

\n

В OpenTelemetry trace описывает путь запроса через приложение, metric — измерение во времени, а log — запись события. Их можно связать общим контекстом, но нельзя подменять один другим. Trace помогает найти участок цепочки. Log объясняет локальный исход. Metric показывает масштаб повторяющегося явления. Ни один из них в одиночку не доказывает, что пользователь увидел ошибку или что worker обработал именно этот заказ.

\n

Сначала сформулируйте вопрос, затем выберите сигнал. Для span вопрос звучит так: «какой участок пути занял время или завершился ошибкой?». Для log: «какой ограниченный исход получил API?». Для metric: «сколько операций класса payment завершилось исходом timeout?». Если ответ требует email, полного URL, свободного текста или случайного идентификатора, такое значение нельзя превращать в metric label.

\n

У исходов должен быть небольшой словарь. transport_failure означает отказ границы вызова, domain_rejected — отказ правила продукта, retryable_failure — возможность повторной обработки. Одно error=true скроет важное различие. Подробности исключения оставляйте в защищённом событии с правилами доступа и хранения, а в агрегате сохраняйте ограниченный outcome_class.

\n

Propagation через HTTP и очередь

\n

Проверяйте не наличие похожих строк, а переход между границами. На входе API нужно зафиксировать, какой carrier извлечён и какой trace-id получен. На исходящем запросе API должен создать дочерний span и передать контекст дальше. При постановке сообщения в очередь тот же контекст нужно положить в согласованный carrier. Worker извлекает его и создаёт свой участок обработки; он не должен искать ближайший trace по имени задачи или времени.

\n

Асинхронная граница меняет смысл времени. API может завершиться через 80 мс, сообщение ждать 2 секунды, а worker выполнить повтор через 300 мс. Это минимум три интервала: обработка API, задержка очереди и выполнение worker. Повтор создаёт новый участок работы, но не обязательно новый пользовательский trace. Если не записать номер попытки и причину retry, суммарная длительность будет выглядеть как один длинный вызов и приведёт к неверной оптимизации.

\n

Проверка должна различать отсутствие контекста и отказ telemetry backend. В первом случае система не знает, к чему привязать событие. Во втором контекст мог быть корректным, но запись не дошла до хранилища. Обе ситуации ухудшают расследование, однако исправляются на разных границах: в первом ищут extraction/injection, во втором — экспорт, очередь telemetry и retention.

\n

Воспроизводимая проверка формата и словаря

\n

Ниже — самостоятельный JavaScript-пример без SDK. Он проверяет только две вещи: минимальную структуру W3C-заголовка версии 00 и закрытый словарь исходов. Значения идентификаторов взяты из примера спецификации; они не являются идентификатором реального пользователя или запроса.

\n
const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';\nconst allowedOutcomes = new Set([\n  'ok', 'domain_rejected', 'transport_failure', 'retryable_failure'\n]);\n\nfunction nonZero(value) {\n  return /[1-9a-f]/.test(value);\n}\n\nfunction validTraceparent(value) {\n  const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(value);\n  return Boolean(match) && nonZero(match[1]) && nonZero(match[2]);\n}\n\nfunction inspectSignal(signal) {\n  if (!validTraceparent(signal.traceparent)) {\n    return { ok: false, reason: 'stop-broken-correlation-context' };\n  }\n  if (!allowedOutcomes.has(signal.outcome_class)) {\n    return { ok: false, reason: 'stop-unknown-outcome-class' };\n  }\n  return { ok: true, traceparent: signal.traceparent,\n    operation: signal.operation, outcome_class: signal.outcome_class };\n}\n\nconsole.log(inspectSignal({\n  traceparent,\n  operation: 'checkout.submit',\n  outcome_class: 'transport_failure'\n}));
\n

После декодирования HTML этот код можно запустить в обычном Node.js. Валидный пример возвращает ok: true. Если заменить последний фрагмент заголовка на 00, формат останется допустимым, но sampled-флаг будет снят; это не доказательство отсутствия trace. Если удалить один символ из trace-id, функция вернёт именованный stop. Если подставить свободный текст вместо outcome_class, сработает второй stop. Так проверяется не «красивый лог», а заранее названное правило.

\n

Симптом → причина → проверка → действие

\n
Диагностическая таблица для одного UI → API → worker пути
СимптомПричинаПроверкаДействие
e2e упал, backend-запрос не находитсяUI не передал context или query скрывает егоСравнить carrier на запросе с context в spanОстановить вывод и назначить владельца propagation
API и worker имеют одинаковый job nameИмя операции приняли за correlationПроверить trace-id, parent relation и attemptНе связывать события эвристикой
Metric содержит много уникальных labelsВ label попали id, URL или свободный текстПосчитать допустимые значения каждой dimensionОставить low-cardinality class или убрать поле
После retry длительность выглядит вдвое большеСложили API, очередь и повтор workerРазделить сегменты и проверить источники времениНе делать latency-вывод без модели границ
Trace иногда есть, иногда исчезаетSampling или async carrier не описаныПроверить policy, message headers и absent-context branchНазвать правило отбора и fail-closed поведение
\n

Отрицательный путь важнее счастливого

\n

Представим учебный маршрут: UI создаёт корректный traceparent, API получает его и публикует сообщение, а worker получает сообщение без carrier. В этой точке нельзя подставить «ближайший» trace и нельзя связать worker по имени задачи. Правильный результат — stop-broken-correlation-context. Он не утверждает причину сбоя в продукте; он сообщает, какого факта не хватает для сквозного вывода.

\n

Второй отрицательный путь — посредник удалил заголовок, но API создал новый trace и продолжил работу. Для локальной диагностики это может быть допустимым решением, но в отчёте нужно отметить разрыв: новый trace не доказывает продолжение исходного. Если граница критична, лучше сохранить отдельное событие о потере связи и передать в доменную команду только явно разрешённые признаки.

\n

Третий путь — неизвестный исход. Если API записал свободный текст исключения, metric не должна превращать каждую новую строку в series. Сначала ограничьте taxonomy. Если новый исход нельзя отнести к классу, запишите unknown или остановите публикацию метрики по правилу команды, а затем обновите словарь. Нельзя ретроспективно выдавать неизвестное значение за известный класс.

\n

Sampling и cardinality не заменяют корреляцию

\n

Sampling отвечает на вопрос «какие traces записывать или экспортировать». Cardinality отвечает на вопрос «сколько различных значений может иметь признак в агрегате». Это разные оси стоимости и качества. Можно выбрать только один trace из двадцати и всё равно создать опасную series для каждого email. Можно ограничить label словарём и всё равно потерять нужную попытку из-за sampling.

\n

Флаг sampled в traceparent сообщает о решении caller записывать trace, но не превращается в гарантию, что все downstream-системы сохранили данные. Компонент может изменить решение из-за своей нагрузки или политики. Поэтому в расследовании проверяйте фактическое наличие span и конфигурацию экспортёра, а не только значение флага.

\n

Не обещайте покрытие или экономию без измерения. Для решения нужны хотя бы объём входных операций, доля сохранённых traces, число series по каждой dimension, размер событий и срок хранения. Эти значения зависят от SDK, collector, backend, нагрузки и правил redaction. Перенос цифры из чужого окружения не делает её результатом вашего измерения.

\n

Порядок проверки

\n
  1. Опишите один путь: UI → API → очередь → worker. Укажите владельца каждой границы и отдельно обозначьте синхронные и асинхронные участки.
  2. Назовите carrier, точки extraction и injection, формат значения и реакцию на отсутствующий или неверный context.
  3. Для одной тестовой попытки выпишите trace-id, parent-id, attempt и outcome. Сверьте их в запросе, сообщении и записи worker.
  4. Разведите span, log и metric по вопросам. Не переносите trace-id, email, raw id или свободный payload в metric label.
  5. Составьте allow-list полей и закрытый словарь outcome-классов. Зафиксируйте, где хранится подробное исключение и кто имеет к нему доступ.
  6. Разделите UI time, API processing, queue delay и worker execution. Для retry показывайте номер попытки и не складывайте интервалы без общей модели часов.
  7. Назовите sampling policy, границу её применения и способ проверки фактической записи. Не называйте флаг sampled доказательством сохранения.
  8. Прогоните положительный и три отрицательных варианта: потерянный carrier, неверный заголовок, запрещённое поле и неизвестный исход.
  9. Сохраните результат как проверяемый контракт. Если любой stop сработал, не публикуйте end-to-end причину и не заменяйте её догадкой.
\n

Ограничения применимости

\n

Одинаковый trace-id не доказывает, что пользователь получил успешный результат. Span может быть экспортирован с задержкой, log — отброшен уровнем записи, metric — агрегировать множество операций, а sampling — не сохранить нужную попытку. Прокси может удалить заголовок, очередь — не поддержать выбранный carrier, а retry — породить несколько обработок одного сообщения.

\n

W3C задаёт формат trace context, а OpenTelemetry — модель сигналов и контекстов; эти документы не выбирают taxonomy продукта, retention, redaction, права доступа, SLA freshness или стоимость telemetry. Учебный код не подключается к браузеру, HTTP-клиенту, очереди или backend. Он проверяет локальный инвариант и не заменяет интеграционный тест с реальным SDK и collector.

\n

Для защищённых данных корреляция не должна становиться способом передать персональные сведения. Trace-id обычно безопаснее email, но он всё равно может связать записи между системами. Опишите срок хранения, доступ, маскирование и процедуру удаления отдельно. Если эти правила не определены, полезность подробного контекста не оправдывает его сбор.

\n

Критерий готовности

\n

Механизм готов к следующему инженерному шагу, если независимая проверка получает один и тот же результат: для выбранного пути назван carrier, UI, API и worker несут проверяемый контекст, каждый сигнал отвечает на свой вопрос, поля проходят allow-list, sampling описан без выдуманной эффективности, а отрицательные варианты дают именованный stop. При этом нет заявления о production-латентности, покрытии, релизе или устранённой аварии без соответствующего измерения.

\n

Проверяемые источники

" }