Files

8 lines
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 155,
"slug": "editorial-2023-09-mechanism-telemetry-signals",
"title": "Почему trace ID не должен становиться label метрики",
"excerpt": "Trace, metric и log отвечают на разные вопросы. Разбираем, как сохранить корреляцию одного запроса, не превратить метрику в журнал событий и проверить отрицательный путь.",
"contentHtml": "<p>Симптом появляется во время расследования отказа: график показывает рост ошибок, но по точке на графике нельзя найти конкретный запрос. В ответ хочется добавить <code>trace_id</code> или <code>request_id</code> в labels метрики. Фильтр действительно станет точнее, но каждая новая строка начнёт описывать отдельный запрос. Команда получит много временных рядов, а метрика перестанет отвечать на вопрос о тенденции.</p>\n<p>Рабочее правило проще сформулировать через задачу сигнала: metric агрегирует класс поведения, trace показывает путь операции, а log или event сохраняет контекст отдельного события. Общий идентификатор нужен для корреляции trace и записи события. Он не обязан становиться dimension метрики. Ниже — модель, пример и проверка, которую можно воспроизвести без SDK и доступа к backend.</p>\n<h2>Сначала разделите три вопроса</h2>\n<p>Трасса отвечает на вопрос «какой путь прошла операция?». Trace состоит из связанных spans: корневой span описывает вход в операцию, дочерние — вызовы сервиса, базы или внешнего API. У trace есть общий <code>TraceId</code>, а каждый span получает собственный <code>SpanId</code>. Поэтому один запрос можно проследить от gateway до шага оплаты, не смешивая соседние операции.</p>\n<p>Метрика отвечает на вопрос «как ведёт себя класс операций во времени?». Её точка имеет имя, значение и набор атрибутов. Например, счётчик может считать отказы для <code>service=checkout-api</code>, <code>route=checkout</code> и <code>outcome=authorization_rejected</code>. Такой набор пригоден для группировки: можно сравнить маршруты или исходы, не перечисляя каждый запрос.</p>\n<p>Log или event отвечает на вопрос «что произошло в конкретный момент?». В запись можно положить имя события, класс ошибки, <code>trace_id</code>, <code>span_id</code> и разрешённый контекст. Это помогает перейти от агрегата к расследованию. При этом запись события не заменяет счётчик: поиск по свободному тексту и уникальным идентификаторам плохо подходит для долгого тренда.</p>\n<table><caption>Роль одного и того же идентификатора в сигналах</caption><thead><tr><th scope=\"col\">Сигнал</th><th scope=\"col\">Главный вопрос</th><th scope=\"col\">Что хранить</th><th scope=\"col\">Чего не требовать</th></tr></thead><tbody><tr><td>Trace</td><td>Как прошла операция?</td><td><code>TraceId</code>, <code>SpanId</code>, parent и имя операции</td><td>Заменять им агрегированную статистику</td></tr><tr><td>Metric</td><td>Как меняется класс поведения?</td><td>Стабильные service, route, outcome и environment</td><td>Идентичность каждого request</td></tr><tr><td>Log/event</td><td>Что случилось на одном шаге?</td><td>Имя события, trace/span ID и проверенные attributes</td><td>Использовать как единственный источник тренда</td></tr></tbody></table>\n<figure><img src=\"/assets/editorial/2023/telemetry-signals-2023-correlation-map.svg\" alt=\"Схема учебной операции: общий trace ID связывает gateway и payment spans с записью отказа, а metric содержит только service, route и outcome\" loading=\"lazy\" /><figcaption>Корреляция ведёт от агрегата к конкретному событию, но идентификатор операции не входит в набор labels метрики. Иллюстрация показывает учебный контракт, а не данные реального сервиса.</figcaption></figure>\n<h2>Почему label меняет стоимость метрики</h2>\n<p>В Prometheus временной ряд однозначно задаётся именем метрики и набором пар «label — value». Изменение значения label создаёт новый ряд. В OpenTelemetry metric stream также идентифицируется набором attributes, а модель поддерживает последующую агрегацию с меньшим числом attributes. Это полезные механизмы, но они не делают идентификатор запроса хорошим dimension: стоимость и объём уже возникших комбинаций никуда не исчезают автоматически.</p>\n<p>Рассмотрим два запроса одного маршрута. В первом варианте labels описывают класс результата:</p>\n<pre><code>checkout_authorization_total{\n service=\"checkout-api\",\n route=\"checkout\",\n outcome=\"authorization_rejected\"\n} 1</code></pre>\n<p>Во втором к тем же labels добавляют <code>trace_id</code>. Если за интервал пришло 100 000 запросов и каждый получил новый идентификатор, появится до 100 000 комбинаций только для этого маршрута и исхода. Это иллюстрация верхней границы при условии, что все IDs различны и система принимает их без дополнительной агрегации. Реальное число рядов зависит от backend, других labels, срока хранения, sampling и того, как инструмент экспортирует данные.</p>\n<p>Имена маршрутов тоже требуют осторожности. В label должен попадать шаблон маршрута вроде <code>/orders/{orderId}</code> или заранее согласованное имя операции, а не полный URL с идентификатором заказа. Иначе в метрику попадёт та же проблема высокой cardinality — число уникальных комбинаций dimensions.</p>\n<h2>Корреляция проходит через context, а не через новый ряд</h2>\n<p>В OpenTelemetry дочерний span с родителем сохраняет тот же <code>TraceId</code>, но получает собственный <code>SpanId</code>. Это даёт точный путь: gateway и payment принадлежат одной трассе, а их spans различаются. Если контекст передаётся между процессами, instrumentation или propagator должен извлечь его на входе и использовать при создании следующего span.</p>\n<p>Запись отказа должна ссылаться на тот span, где отказ наблюдался. Ссылка только на trace без span оставляет расследование слишком широким; новый случай trace/span mismatch должен быть виден в проверке. Нельзя «чинить» потерю context копированием ID во все metrics labels: сначала проверяют propagation и границу записи, затем исправляют контракт.</p>\n<pre><code>// Учебные данные: код не обращается к сети и не создаёт 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};</code></pre>\n<p>Длины IDs в примере выбраны по формату, описанному в спецификации OpenTelemetry: hex-представление <code>TraceId</code> содержит 32 символа, <code>SpanId</code> — 16. Это проверяет форму учебного объекта, но не доказывает, что ваш SDK корректно передаст context через HTTP, очередь или фоновой worker.</p>\n<h2>Exemplar — отдельная связь для точки измерения</h2>\n<p>Иногда расследователю полезно перейти с точки метрики к одной трассе. Для такого сценария OpenTelemetry описывает exemplar — записанное значение, связанное с context метрики; в нём могут присутствовать <code>trace_id</code> и <code>span_id</code>. Exemplar не становится label и не создаёт по одному временному ряду на каждую операцию. Это принципиально другой канал: агрегат сохраняет свою размерность, а отдельная точка получает ссылку на trace.</p>\n<p>Поддержка exemplars и переход по ним зависит от SDK, exporter и системы хранения. Поэтому нельзя обещать рабочую ссылку только по факту добавления поля в объект. Проверьте документацию конкретного стека, формат экспорта и то, отображает ли выбранный интерфейс exemplar. Если такой цепочки нет, сохраняйте ID в структурированном log/event с учётом доступа, redaction и retention.</p>\n<h2>Диагностика: симптом, причина, проверка, действие</h2>\n<table><caption>Матрица выбора следующего шага</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>График есть, trace не находится</td><td>Метрику использовали как журнал</td><td>Проверить exemplar или связь с event по одному сценарию</td><td>Оставить labels агрегируемыми, восстановить отдельную корреляцию</td></tr><tr><td>Число рядов растёт вместе с трафиком</td><td>В dimension попал per-request ID</td><td>Выгрузить словарь значений label за короткий интервал</td><td>Убрать поле из labels и перенести его в event/context</td></tr><tr><td>Log найден, но span другой</td><td>Context потерян на границе процесса</td><td>Сравнить trace ID, span ID и parent на одном запросе</td><td>Проверить propagator, middleware и формат записи</td></tr><tr><td>Маршрут дробится по заказам</td><td>В label попал полный URL</td><td>Сопоставить значения route с шаблонами маршрутов</td><td>Записывать нормализованный route template</td></tr><tr><td>Нужен поиск по ID</td><td>Metric выполняет роль индекса событий</td><td>Сформулировать запрос, который должен отвечать на агрегат</td><td>Если нужен один request, искать trace или event</td></tr></tbody></table>\n<h2>Проверка контракта с отрицательными случаями</h2>\n<p>Зелёный happy path показывает только согласованный объект. Для полезной проверки нужны намеренно неверные входы: другой trace ID, другой span ID и запрещённый label. Следующая функция не использует OpenTelemetry SDK; она фиксирует минимальное правило учебного договора.</p>\n<pre><code>const 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) =&gt; !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</code></pre>\n<p>Запустите этот фрагмент в Node.js, сохранив его как обычный JavaScript-файл, или перенесите правило в тест своего instrumentation. В production-тесте добавьте проверку реального экспортированного payload: учебная функция не знает о collector, sampling, exporter, индексации и правах доступа.</p>\n<h2>Порядок внедрения и границы вывода</h2>\n<ol><li><strong>Назовите вопрос.</strong> Решите, нужен тренд по классу ошибок, путь одного запроса или контекст события. Один сигнал может ссылаться на другой, но не обязан хранить его поля в одинаковой роли.</li><li><strong>Нормализуйте dimensions.</strong> Для каждого label зафиксируйте словарь значений и запретите полный URL, user ID, order ID, request ID и текст исключения, если это per-request данные.</li><li><strong>Проверьте путь.</strong> На контролируемом запросе сравните общий trace ID, собственные span IDs и parent-child связь. Для границы процесса отдельно проверьте извлечение и инъекцию context.</li><li><strong>Выберите корреляцию.</strong> Если стек поддерживает exemplars, проверьте полный переход metric → trace. Если нет, запишите разрешённые IDs в структурированное событие и документируйте поиск.</li><li><strong>Прогоните отрицательные случаи.</strong> Сломайте trace ID, span ID и набор labels по очереди. Проверка должна отличать три причины, а не принимать любой объект с непустым ID.</li><li><strong>Измерьте реальную cardinality.</strong> Снимите число временных рядов до и после изменения в выбранном backend и на согласованном интервале. Без такого замера нельзя заявлять экономию или безопасную стоимость.</li><li><strong>Зафиксируйте данные.</strong> Для trace ID и event attributes проверьте redaction, retention и права чтения. Корреляция не отменяет требований к чувствительным данным.</li></ol>\n<h2>Что именно доказывает пример</h2>\n<p>Пример доказывает только структуру договора: labels описывают небольшой набор классов, а trace и event могут иметь общий trace ID и точный span ID. Он не доказывает, что выбранный SDK создаёт такие spans, что HTTP-заголовок не теряется, что sampling сохранит нужную трассу или что backend поддерживает переход по exemplar.</p>\n<p>Также нельзя выводить стоимость по числу трёх labels. Cardinality зависит от числа значений и их сочетаний, а не только от количества ключей. Даже нормализованные route и outcome требуют словаря, владельца и наблюдения за ростом рядов. Если данные о backend недоступны, честный результат проверки — «контракт запрещает per-request label», а не «система доказанно экономна».</p>\n<p>Критерий готовности для реального изменения состоит из четырёх наблюдений: metric строится по согласованным dimensions; trace показывает ожидаемый путь; event ссылается на правильный trace и span; неверные IDs и per-request label отвергаются. После этого отдельно проверяют экспорт, хранение, sampling, безопасность и число временных рядов.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://opentelemetry.io/docs/concepts/signals/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Signals</a> — определения traces, metrics и logs.</li><li><a href=\"https://opentelemetry.io/docs/specs/otel/trace/api/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Tracing API</a> — формат IDs, parent-child spans и наследование TraceId.</li><li><a href=\"https://opentelemetry.io/docs/specs/otel/metrics/data-model/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Metrics Data Model</a> — metric streams, attributes, reaggregation и exemplars.</li><li><a href=\"https://prometheus.io/docs/concepts/data_model/\" target=\"_blank\" rel=\"noopener noreferrer\">Prometheus: Data model</a> — идентичность временного ряда и влияние изменения labels.</li><li><a href=\"https://opentelemetry.io/docs/specs/otel/context/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Context</a> — перенос context между операциями и границами инструментирования.</li></ul>"
}