{ "index": 155, "slug": "editorial-2023-09-mechanism-telemetry-signals", "title": "Почему trace ID не должен становиться label метрики", "excerpt": "Trace, metric и log отвечают на разные вопросы. Разбираем, как сохранить корреляцию одного запроса, не превратить метрику в журнал событий и проверить отрицательный путь.", "contentHtml": "
Симптом появляется во время расследования отказа: график показывает рост ошибок, но по точке на графике нельзя найти конкретный запрос. В ответ хочется добавить trace_id или request_id в labels метрики. Фильтр действительно станет точнее, но каждая новая строка начнёт описывать отдельный запрос. Команда получит много временных рядов, а метрика перестанет отвечать на вопрос о тенденции.
Рабочее правило проще сформулировать через задачу сигнала: metric агрегирует класс поведения, trace показывает путь операции, а log или event сохраняет контекст отдельного события. Общий идентификатор нужен для корреляции trace и записи события. Он не обязан становиться dimension метрики. Ниже — модель, пример и проверка, которую можно воспроизвести без SDK и доступа к backend.
\nТрасса отвечает на вопрос «какой путь прошла операция?». Trace состоит из связанных spans: корневой span описывает вход в операцию, дочерние — вызовы сервиса, базы или внешнего API. У trace есть общий TraceId, а каждый span получает собственный SpanId. Поэтому один запрос можно проследить от gateway до шага оплаты, не смешивая соседние операции.
Метрика отвечает на вопрос «как ведёт себя класс операций во времени?». Её точка имеет имя, значение и набор атрибутов. Например, счётчик может считать отказы для service=checkout-api, route=checkout и outcome=authorization_rejected. Такой набор пригоден для группировки: можно сравнить маршруты или исходы, не перечисляя каждый запрос.
Log или event отвечает на вопрос «что произошло в конкретный момент?». В запись можно положить имя события, класс ошибки, trace_id, span_id и разрешённый контекст. Это помогает перейти от агрегата к расследованию. При этом запись события не заменяет счётчик: поиск по свободному тексту и уникальным идентификаторам плохо подходит для долгого тренда.
| Сигнал | Главный вопрос | Что хранить | Чего не требовать |
|---|---|---|---|
| Trace | Как прошла операция? | TraceId, SpanId, parent и имя операции | Заменять им агрегированную статистику |
| Metric | Как меняется класс поведения? | Стабильные service, route, outcome и environment | Идентичность каждого request |
| Log/event | Что случилось на одном шаге? | Имя события, trace/span ID и проверенные attributes | Использовать как единственный источник тренда |
В Prometheus временной ряд однозначно задаётся именем метрики и набором пар «label — value». Изменение значения label создаёт новый ряд. В OpenTelemetry metric stream также идентифицируется набором attributes, а модель поддерживает последующую агрегацию с меньшим числом attributes. Это полезные механизмы, но они не делают идентификатор запроса хорошим dimension: стоимость и объём уже возникших комбинаций никуда не исчезают автоматически.
\nРассмотрим два запроса одного маршрута. В первом варианте labels описывают класс результата:
\ncheckout_authorization_total{\n service=\"checkout-api\",\n route=\"checkout\",\n outcome=\"authorization_rejected\"\n} 1\nВо втором к тем же labels добавляют trace_id. Если за интервал пришло 100 000 запросов и каждый получил новый идентификатор, появится до 100 000 комбинаций только для этого маршрута и исхода. Это иллюстрация верхней границы при условии, что все IDs различны и система принимает их без дополнительной агрегации. Реальное число рядов зависит от backend, других labels, срока хранения, sampling и того, как инструмент экспортирует данные.
Имена маршрутов тоже требуют осторожности. В label должен попадать шаблон маршрута вроде /orders/{orderId} или заранее согласованное имя операции, а не полный URL с идентификатором заказа. Иначе в метрику попадёт та же проблема высокой cardinality — число уникальных комбинаций dimensions.
В OpenTelemetry дочерний span с родителем сохраняет тот же TraceId, но получает собственный SpanId. Это даёт точный путь: gateway и payment принадлежат одной трассе, а их spans различаются. Если контекст передаётся между процессами, instrumentation или propagator должен извлечь его на входе и использовать при создании следующего span.
Запись отказа должна ссылаться на тот span, где отказ наблюдался. Ссылка только на trace без span оставляет расследование слишком широким; новый случай trace/span mismatch должен быть виден в проверке. Нельзя «чинить» потерю context копированием ID во все metrics labels: сначала проверяют propagation и границу записи, затем исправляют контракт.
\n// Учебные данные: код не обращается к сети и не создаёт telemetry.\nconst trace = {\n traceId: '4bf92f3577b34da6a3ce929d0e0e4736',\n spans: {\n gateway: { spanId: '00f067aa0ba902b7', parent: null },\n payment: { spanId: 'b7ad6b7169203331', parent: '00f067aa0ba902b7' },\n },\n};\n\nconst metric = {\n name: 'checkout_authorization_total',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'rejected' },\n};\n\nconst event = {\n name: 'payment.authorization.rejected',\n traceId: trace.traceId,\n spanId: trace.spans.payment.spanId,\n attributes: { failureClass: 'declined' },\n};\nДлины IDs в примере выбраны по формату, описанному в спецификации OpenTelemetry: hex-представление TraceId содержит 32 символа, SpanId — 16. Это проверяет форму учебного объекта, но не доказывает, что ваш SDK корректно передаст context через HTTP, очередь или фоновой worker.
Иногда расследователю полезно перейти с точки метрики к одной трассе. Для такого сценария OpenTelemetry описывает exemplar — записанное значение, связанное с context метрики; в нём могут присутствовать trace_id и span_id. Exemplar не становится label и не создаёт по одному временному ряду на каждую операцию. Это принципиально другой канал: агрегат сохраняет свою размерность, а отдельная точка получает ссылку на trace.
Поддержка exemplars и переход по ним зависит от SDK, exporter и системы хранения. Поэтому нельзя обещать рабочую ссылку только по факту добавления поля в объект. Проверьте документацию конкретного стека, формат экспорта и то, отображает ли выбранный интерфейс exemplar. Если такой цепочки нет, сохраняйте ID в структурированном log/event с учётом доступа, redaction и retention.
\n| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| График есть, trace не находится | Метрику использовали как журнал | Проверить exemplar или связь с event по одному сценарию | Оставить labels агрегируемыми, восстановить отдельную корреляцию |
| Число рядов растёт вместе с трафиком | В dimension попал per-request ID | Выгрузить словарь значений label за короткий интервал | Убрать поле из labels и перенести его в event/context |
| Log найден, но span другой | Context потерян на границе процесса | Сравнить trace ID, span ID и parent на одном запросе | Проверить propagator, middleware и формат записи |
| Маршрут дробится по заказам | В label попал полный URL | Сопоставить значения route с шаблонами маршрутов | Записывать нормализованный route template |
| Нужен поиск по ID | Metric выполняет роль индекса событий | Сформулировать запрос, который должен отвечать на агрегат | Если нужен один request, искать trace или event |
Зелёный happy path показывает только согласованный объект. Для полезной проверки нужны намеренно неверные входы: другой trace ID, другой span ID и запрещённый label. Следующая функция не использует OpenTelemetry SDK; она фиксирует минимальное правило учебного договора.
\nconst allowedLabels = new Set(['service', 'route', 'outcome']);\n\nfunction validate({ traceId, spanId, labels }) {\n if (traceId !== '4bf92f3577b34da6a3ce929d0e0e4736') {\n return 'reject: trace mismatch';\n }\n if (spanId !== 'b7ad6b7169203331') {\n return 'reject: span mismatch';\n }\n if (Object.keys(labels).some((key) => !allowedLabels.has(key))) {\n return 'reject: metric label contract';\n }\n return 'accept: educational contract';\n}\n\nvalidate({\n traceId: '4bf92f3577b34da6a3ce929d0e0e4736',\n spanId: 'b7ad6b7169203331',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'rejected' },\n});\n// accept: educational contract\n\nvalidate({\n traceId: '4bf92f3577b34da6a3ce929d0e0e4736',\n spanId: 'b7ad6b7169203331',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'rejected', trace_id: '...' },\n});\n// reject: metric label contract\nЗапустите этот фрагмент в Node.js, сохранив его как обычный JavaScript-файл, или перенесите правило в тест своего instrumentation. В production-тесте добавьте проверку реального экспортированного payload: учебная функция не знает о collector, sampling, exporter, индексации и правах доступа.
\nПример доказывает только структуру договора: labels описывают небольшой набор классов, а trace и event могут иметь общий trace ID и точный span ID. Он не доказывает, что выбранный SDK создаёт такие spans, что HTTP-заголовок не теряется, что sampling сохранит нужную трассу или что backend поддерживает переход по exemplar.
\nТакже нельзя выводить стоимость по числу трёх labels. Cardinality зависит от числа значений и их сочетаний, а не только от количества ключей. Даже нормализованные route и outcome требуют словаря, владельца и наблюдения за ростом рядов. Если данные о backend недоступны, честный результат проверки — «контракт запрещает per-request label», а не «система доказанно экономна».
\nКритерий готовности для реального изменения состоит из четырёх наблюдений: metric строится по согласованным dimensions; trace показывает ожидаемый путь; event ссылается на правильный trace и span; неверные IDs и per-request label отвергаются. После этого отдельно проверяют экспорт, хранение, sampling, безопасность и число временных рядов.
\n