8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"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) => !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>"
|
||
}
|