Skip to content
Назад к блогу
Руководства

Заголовок traceparent: полное руководство по W3C Trace Context

Заголовок traceparent по полям: значение каждого шестнадцатеричного сегмента, причины невалидности и почему трассировки рвутся. Бесплатный декодер.

13 мин чтения

Заголовок traceparent: полное руководство по W3C Trace Context

Заголовок traceparent — стандарт W3C для заголовков распределённой трассировки: одна строка ASCII, которая переносит идентичность запроса через все сервисы на его пути. В текущей версии это ровно 55 символов и четыре поля, разделённые дефисами:

00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│  │                                │                │
│  │                                │                └─ trace-flags (2 hex, 1 byte)
│  │                                └─ parent-id     (16 hex, 8 bytes)
│  └─ trace-id                                       (32 hex, 16 bytes)
└─ version                                           (2 hex, 1 byte)

Два из этих полей ведут себя по-разному по мере движения запроса. trace-id остаётся неизменным на каждом переходе (hop): это имя запроса, от пограничного прокси до последнего обращения к базе. parent-id меняется на каждом переходе, потому что он называет span, который вас вызвал, а не сам запрос. Путаница между ними даёт заметную долю обращений в духе «трассировки выглядят неправильно».

Таблица полей — простая половина. Она не расскажет, из-за чего заголовок становится невалидным, что делает с ним корректный получатель и где заголовок тихо исчезает между двумя сервисами, которые оба заявляют о поддержке трассировки. Если перед вами реальный заголовок, вставьте его в бесплатный декодер traceparent и читайте дальше: он разбивает поля, раскрывает байт флагов бит за битом и называет правило, которое нарушает сломанный заголовок.

Заголовок traceparent: краткая сводка полей

Заголовок traceparent — это один HTTP-заголовок, который переносит распределённую трассировку между сервисами. В нём четыре шестнадцатеричных поля через дефис (version, trace-id, parent-id и trace-flags), а в текущей версии ровно 55 символов. trace-id называет весь запрос целиком, а parent-id — тот span, который вас вызвал.

ПолеШестнадцатеричных цифрБайтЧто идентифицируетМеняется на каждом переходе?
version21Какому формату следует всё остальное. Сегодня всегда 00Нет
trace-id3216Весь запрос целиком, из конца в конецНет
parent-id168Вызывающий span (span ID вызывающей стороны)Да
trace-flags218-битное поле; бит 0 — sampledРедко

Прибавьте к этим 52 шестнадцатеричным цифрам три дефиса — получится 55 символов. Число стоит запомнить: заголовок версии 00 любой другой длины невалиден, а длина проверяется глазами быстрее всего.

Всё содержимое заголовка — шестнадцатеричные цифры в нижнем регистре. Не «шестнадцатеричные, регистр не важен», а именно в нижнем. Грамматика в рекомендации W3C Trace Context допускает 0-9 и a-f и ничего больше, поэтому trace ID в верхнем регистре с абсолютно правильным значением всё равно выбрасывается ниже по цепочке.

Разбор по полям

У каждого поля своя ширина, свои невалидные значения и свой способ сломаться.

version — почему это не всегда «просто 00»

Сегодня байт версии равен 00, и таким он останется ещё какое-то время. Но ff запрещён явно: спецификация резервирует его как невалидное значение, поэтому заголовок, начинающийся с ff, мёртв при получении независимо от того, что идёт дальше.

Интереснее правило про версии, которых вы никогда не видели. Парсер, который делает if (version !== '00') reject(), работает неправильно — и неправильно дорого. Спецификация просит получателя попытаться разобрать заголовок, если версия старше, а сам заголовок не короче известного формата: прочитать знакомые поля, стерпеть лишние данные в хвосте и продолжить. Отказ вместо этого превращает сервис в границу, где трассировка обрывается и начинается новая, ровно в тот момент, когда кто-нибудь выше по цепочке обновится.

// Wrong: makes your service the place traces go to die
if (version !== '00') throw new Error('bad traceparent');

// Right: parse the prefix you understand
if (version !== '00' && header.length >= 55) {
  // read version, trace-id, parent-id, trace-flags; ignore the rest
}

trace-id — 16 байт, идентичность всего запроса

Тридцать две шестнадцатеричные цифры в нижнем регистре, неизменные на протяжении всей трассировки. Какой бы сервис ни сгенерировал значение в начале, каждый переход копирует его дальше без изменений. Когда трассировку ищут в бэкенде наблюдаемости, вставляют именно эту строку.

Значение подчиняется двум правилам: ровно 32 шестнадцатеричные цифры и не все нули. 00000000000000000000000000000000 — это не «трассировка, в которой пока нет данных»: спецификация называет такое значение невалидным и требует от получателя проигнорировать заголовок целиком. На практике полностью нулевой trace ID означает SDK, который так и не инициализировался, или middleware, который подставил заглушку, потому что настоящего контекста для передачи у него не было.

trace-id занимает 128 бит — столько же, сколько UUID, — и при этом UUID не является. Здесь нет ни битов версии, ни битов варианта, ни дефисов, ни какой-либо структуры вообще: шестнадцать непрозрачных байт. Извлечь из него v4 не получится, и UUID с убранными дефисами тоже не становится автоматически валидным trace-id: nibble версии и варианта делают его случайность неравномерной. Чтобы посмотреть, что UUID на самом деле резервирует внутри этих 128 бит, загляните в разбор раскладки — что на самом деле кодирует UUID; а генератор UUID показывает биты версии и варианта на своих местах.

parent-id — 8 байт, span, который вас вызвал

Шестнадцать шестнадцатеричных цифр, переписываемых на каждом переходе. Название путает сильнее, чем поле того заслуживает: спецификация W3C зовёт его parent-id, OpenTelemetry называет те же 8 байт span ID, и это одно и то же, увиденное с двух сторон. С точки зрения вашего сервиса это родитель; с точки зрения вызывающей стороны — ID того span, который она только что создала для исходящего запроса.

Когда сервис A вызывает сервис B, A кладёт в поле parent-id свой собственный span ID. Затем B создаёт дочерний span, а при вызове сервиса C подставляет туда уже span ID сервиса B. trace-id при этом не трогают вообще. Вот и весь алгоритм распространения контекста.

Полностью нулевые parent-id тоже невалидны, и по той же причине, что и trace-id: 0000000000000000 означает, что вызывающая сторона не передала настоящий span, и заголовок следует отбросить, а не принять наполовину.

trace-flags — выглядит как булево значение, а на деле восемь бит

Почти каждый заголовок, который попадётся вам на глаза, заканчивается на 01, поэтому поле естественно читается как «да/нет». Но это байт, и биты в нём распределены так:

  • бит 0, маска 0x01sampled
  • бит 1, маска 0x02random-trace-id, добавлен в Trace Context Level 2
  • биты 2–7 — зарезервированы; при получении их игнорируют, в исходящих запросах обнуляют

Вот как декодируются комбинации:

HexДвоичноеsampledrandom-trace-idВерно ли flags === 0x01?
0000000000falsefalsefalse
0100000001truefalsetrue
0200000010falsetruefalse
0300000011truetruefalse ← вот она, ошибка

Перечитайте последнюю строку. Трассировка с флагами 03 попала в выборку. Любой код, который сравнивает весь байт с 01, молча сообщит обратное — и только для той части трафика, где случайно выставлен флаг Level 2. Хуже формы отказа не придумать: она выглядит как проблема с частотой сэмплирования, а не как ошибка разбора.

const flags = parseInt(traceFlags, 16);

// Wrong: treats a bit field as an enumeration
const sampled = traceFlags === '01';

// Right
const sampled       = (flags & 0x01) !== 0;
const randomTraceId = (flags & 0x02) !== 0;

Что именно утверждает random-trace-id? Что как минимум 7 самых правых байт trace-id сгенерированы с равномерной случайностью. Звучит академично — ровно до момента, когда речь заходит о согласованном сэмплировании: если система ниже по цепочке хочет оставить 1 % трассировок и нужно, чтобы каждый сервис независимо сошёлся на одном и том же 1 %, она может взять эти байты по модулю вместо того, чтобы сначала вычислять хеш идентификатора. Флаг — это обещание вышестоящей стороны, что так делать безопасно.

Из-за чего traceparent становится невалидным

Заголовок версии 00 отбраковывают по любой из этих причин:

СимптомПравилоРезультат
00-4BF92F35...-01Грамматика допускает только нижний регистрНевалиден — значение верное, заголовок отброшен
ff-...Версия ff запрещена спецификациейНевалиден
trace-id равен 00000000000000000000000000000000Полностью нулевой trace-id — явно названное невалидное значениеНевалиден
parent-id равен 0000000000000000Полностью нулевой parent-id — явно названное невалидное значениеНевалиден
В trace-id не 32 шестнадцатеричные цифрыФиксированная ширинаНевалиден
В parent-id не 16 шестнадцатеричных цифрФиксированная ширинаНевалиден
В trace-flags не 2 шестнадцатеричные цифрыФиксированная ширинаНевалиден
Длина заголовка версии 00 не равна ровно 55 символамДанные в хвосте допустимы только в будущей версииНевалиден
Любой символ вне 0-9a-f и дефисовНе шестнадцатеричная записьНевалиден

Дальше начинается часть, которую обычно упускают:

Корректный получатель не чинит невалидный заголовок traceparent и не передаёт его дальше. Он отбрасывает заголовок и начинает совершенно новую трассировку со свежесгенерированным trace-id.

А значит, то, что видно на экране, — не сломанная трассировка. Это две короткие несвязанные трассировки: одна обрывается на сервисе, который отправил плохой заголовок, вторая словно возникает из ниоткуда на сервисе, который его принял. Нигде ничего не помечено как ошибка. По отдельности обе трассировки выглядят здоровыми. На поиск недостающего звена между ними уходят целые вечера, а ответ в том, что middleware перевёл шестнадцатеричную строку в верхний регистр или собранный вручную заголовок вышел длиной 54 символа.

Длина и регистр — два вида отказа, которые не разглядеть, сколько ни всматривайся. Вставьте заголовок в декодер, и он назовёт нарушенное правило точно, вместо того чтобы заставлять вас считать цифры.

tracestate: соседний заголовок, в котором ошибаются все

traceparent несёт стандартную идентичность. Заголовок tracestate несёт всё, что каждый вендор хочет добавить рядом, — элементы вида key=value через запятую:

tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE

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

Но грамматика тут зубастая, и три её правила объясняют вполне реальные симптомы в проде.

Потолок в 32 элемента списка заложен в самой грамматике: list = list-member 0*31( OWS "," OWS list-member ). tracestate с 33 элементами — не «tracestate с одной лишней записью», а невалидный заголовок, и получатель вправе отбросить его целиком. Отсюда объяснение симптома, который иначе выглядит магией: данные вендора есть на границе, есть через два перехода и полностью исчезают к пятому. Каждый переход дописывал свой элемент, список перевалил за 32, и с этого момента заголовок не подрезали, а выбрасывали целиком.

Значение содержит от 1 до 256 символов и никогда не бывает пустым. Продукция значения обязана заканчиваться непробельным символом, поэтому vendor= — это не «ключ без значения», а синтаксическая ошибка. Только печатаемый ASCII, и никаких запятых или знаков равенства внутри значения.

Между Level 1 и Level 2 изменилась грамматика ключа. Level 1 описывал ключи через продукцию tenant@vendor, где @ был структурным разделителем. Level 2 заменил её плоским классом символов: ключ начинается со строчной буквы или цифры и продолжается символами a-z, 0-9, _, -, *, / и @. По Level 2 @ — обычный символ, ключ может начинаться с цифры, а a@b@c — совершенно законный ключ, который продукция Level 1 отвергла бы. Если прокси проверяет по Level 1, а сервис выдаёт ключи Level 2, одна сторона принимает то, что другая отбрасывает, и заголовок пропадает ровно на одном переходе.

Ещё два правила стоит знать. Дублирующиеся ключи невалидны без вариантов. И при изменении parent-id в traceparent собственную запись в tracestate нужно переместить в начало списка: список упорядочен от самой свежей записи к самой старой. Пропуск этого шага оставляет устаревшее состояние вендора там, где читающая сторона примет его за актуальное.

Одно правило работает в вашу пользу: пустые элементы списка допустимы. Когда промежуточный узел удаляет запись, запятая от неё нередко остаётся — получается rojo=1,,congo=2. Спецификация это прямо разрешает, поэтому парсер должен отбросить пустой элемент и продолжить, а не объявлять заголовок некорректным. Представление tracestate в декодере перечисляет все элементы с проверкой каждого и текущим счётчиком относительно лимита в 32 элемента — обычно это быстрее, чем считать запятые.

Как путешествуют заголовки распределённой трассировки: один запрос, четыре перехода

Проследим один запрос через пограничный прокси, API-сервис и два нижестоящих сервиса:

Client
  │  (no traceparent — the edge is the root)

Edge proxy      generates trace-id 4bf9…4736, span 00f0…02b7
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

API service     reads it, creates span a1b2c3d4e5f60718
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718-01

Orders service  reads it, creates span 9f8e7d6c5b4a3928
  │  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-9f8e7d6c5b4a3928-01

Inventory service

Каждый переход делает одно и то же в три шага: читает входящий заголовок, подставляет в parent-id собственный span ID для каждого исходящего вызова и передаёт trace-id и флаги без изменений. Если входящего заголовка нет вовсе — как у клиента выше, — принимающий сервис становится корнем: он генерирует trace-id и принимает решение о сэмплировании за всё, что идёт ниже.

Заголовок можно подставить вручную, чтобы проверить цепочку целиком:

curl -sS -o /dev/null -w '%{http_code}\n' \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  -H 'tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' \
  https://example.com/api

Повторите на staging заголовок, снятый с прода, — и в бэкенде появится тот же самый trace-id. Генератор команд cURL соберёт флаги за вас, если нужно добавить авторизацию или тело запроса, а шпаргалка по curl разбирает опции для заголовков и подробного вывода, которые пригодятся при отладке.

Чтобы увидеть, что сервис получил на самом деле, а не то, что вы думаете, будто отправили, поднимите одноразовый echo-сервер и направьте на него один из переходов:

python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer

class Echo(BaseHTTPRequestHandler):
    def do_GET(self):
        for name, value in self.headers.items():
            print(f"{name}: {value}")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok\n")

HTTPServer(("127.0.0.1", 8080), Echo).serve_forever()
PY

Затем выполните curl -H 'traceparent: …' http://127.0.0.1:8080/ и посмотрите, что вышло с другой стороны. Половина расследований в жанре «прокси съедает мой заголовок» заканчивается здесь.

trace-flags sampled — это решение, а не гарантия

Бит sampled в trace-flags, равный 1, означает, что вышестоящий сервис решил записать эту трассировку. Он не обещает, что данные дошли до вашего бэкенда.

Сэмплирование в начале (head-based) принимает это решение в корне, ещё до того как что-либо произошло, и распространяет его вниз: дёшево, согласованно между сервисами и слепо — знать, что запрос вот-вот упадёт, оно не может. Сэмплирование в конце (tail-based) копит span до завершения трассировки и решает уже потом, поэтому умеет сохранить каждую трассировку с ошибкой — ценой хранения span в памяти и требования, чтобы span всех сервисов попадали в один и тот же коллектор.

При tail-based трассировка может прийти с флагом 01 на каждом переходе и всё равно быть отброшена в конце. Отбросить её могут и лимиты частоты, и квоты на экспорт. Так что 01 на границе и отсутствие трассировки в интерфейсе — не обязательно ошибка распространения контекста; прежде чем идти разбираться с заголовками, проверьте метрики отбрасывания у самого коллектора.

Обратный случай встречается в повседневной работе чаще. Если входящие флаги равны 00, вызывающая сторона отработала свой сэмплер и решила не записывать. В вашем сервисе ничего не настроено неправильно, и аудит собственного сэмплера — потерянное время: вопрос в том, какой вышестоящий сервис решает не сэмплировать.

Конвертация между форматами распространения контекста

W3C Trace Context победил, но множество систем до сих пор говорят на чём-то более старом, а шлюзы переводят между ними. Вот один и тот же пример traceparent, записанный в четырёх форматах:

ФорматЗаголовок(и)Значение для нашего примера
W3Ctraceparent00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
B3 singleb34bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1
B3 multiX-B3-TraceId, X-B3-SpanId, X-B3-Sampled4bf92f3577b34da6a3ce929d0e0e4736, 00f067aa0ba902b7, 1
Datadogx-datadog-trace-id, x-datadog-parent-id, тег _dd.p.tid11803532876627986230, 67667974448284343, 4bf92f3577b34da6
AWS X-RayX-Amzn-Trace-IdRoot=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1

Datadog: разделение на старшие и младшие 64 бита

Идентификаторы Datadog появились раньше 128-битных trace ID, и именно на прослойке совместимости ломается большинство конвертаций. x-datadog-trace-id несёт младшие 64 бита десятичной строкой. Старшие 64 бита едут отдельно, в шестнадцатеричном виде, в теге _dd.p.tid, который сам едет внутри заголовка x-datadog-tags.

const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const [, traceId, parentId] = traceparent.split('-');

const datadogTraceId  = BigInt('0x' + traceId.slice(16)).toString(10);
const higher64Hex     = traceId.slice(0, 16);
const datadogParentId = BigInt('0x' + parentId).toString(10);

console.log('x-datadog-trace-id:',  datadogTraceId);  // 11803532876627986230
console.log('x-datadog-tags:',      higher64Hex);     // _dd.p.tid=4bf92f3577b34da6
console.log('x-datadog-parent-id:', datadogParentId); // 67667974448284343

Классическая ошибка — перевести все 128 бит в одно десятичное число:

BigInt('0x' + traceId).toString(10);
// 100985939111033328018442752961257817910 — matches nothing in the UI

Арифметика здесь не врёт. Это корректная десятичная запись неправильной величины — потому она и проходит ревью, а потом молча не находит ни одной трассировки.

Вторая ловушка — точность чисел. 64-битный идентификатор превышает Number.MAX_SAFE_INTEGER, то есть 9007199254740991, поэтому любой путь исполнения, где trace ID становится числом JavaScript, портит его младшие разряды. Храните trace ID строками и беритесь за BigInt только там, где действительно нужна арифметика; идентификатор, пришедший в JSON без кавычек, уже испорчен к моменту, когда вы его увидели.

AWS X-Ray: timestamp, которого нет

Trace ID в X-Ray выглядит как 1-{8 hex}-{24 hex}, и первые 8 шестнадцатеричных цифр — это время создания в секундах epoch. Конвертация из W3C механическая:

const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const epochHex     = traceId.slice(0, 8);              // 4bf92f35
const epochSeconds = parseInt(epochHex, 16);           // 1274621749
const xrayId       = `1-${epochHex}-${traceId.slice(8)}`;
// 1-4bf92f35-77b34da6a3ce929d0e0e4736
// X-Amzn-Trace-Id: Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1

new Date(epochSeconds * 1000).toISOString();           // 2010-05-23T13:35:49.000Z

Посмотрите на дату. Пример заголовка из спецификации декодируется в май 2010 года — очевидная бессмыслица, и в этом весь смысл: в trace-id формата W3C нет никакого timestamp. Шестнадцать случайных байт с радостью дадут правдоподобное значение epoch, если прочитать первые четыре из них как одно число, но значит это ровно ничего, пока идентификатор действительно не пришёл из X-Ray. Декодировать время из произвольного trace-id — значит прочитать случайное число и поверить ему.

Когда идентификатор и правда пришёл из X-Ray, конвертация полезна: закиньте эти восемь шестнадцатеричных цифр в конвертер Unix timestamp, чтобы получить читаемую дату, а руководство по epoch разбирает ловушки с секундами против миллисекунд и часовыми поясами, которые идут следом.

B3: наследие Zipkin

B3 пришёл из Zipkin, и с ним сталкиваются в более старых service mesh. Однозаголовочная форма — traceId-spanId-sampled, где поле sampled равно 1 или 0, а не шестнадцатеричному байту: биту random-trace-id из Level 2 просто некуда деться, и при переводе он теряется. Многозаголовочная форма раскладывает те же значения по X-B3-TraceId, X-B3-SpanId и X-B3-Sampled.

Историческая заковырка — разрядность. Trace ID в B3 бывают 64-битными, то есть 16 шестнадцатеричных цифр вместо 32. Конвертация 64-битного идентификатора B3 в W3C — это дополнение нулями слева до 32 цифр, а обратная конвертация — решение, обрезать ли значение. Дополнение слева безопасно, обрезание — нет: две трассировки, различающиеся только старшими байтами, схлопываются в одну.

Где traceparent теряется в проде

Всё выше предполагает, что заголовок доезжает. Часто он не доезжает. Вот четыре места, где он пропадает.

Браузер выбрасывает его на кросс-доменных запросах

Симптом: трассировки фронтенда есть, трассировки бэкенда есть, а связи между ними нет. Или кросс-доменный запрос вовсе падает с ошибкой CORS.

Причина: traceparent — нестандартный заголовок, поэтому его добавление делает запрос «непростым» и вызывает предварительный OPTIONS. Если в ответе на preflight сервер не перечислит заголовок в Access-Control-Allow-Headers, браузер заблокирует настоящий запрос. Отдельная история: браузерная инструментация OpenTelemetry отказывается подставлять заголовки трассировки в кросс-доменные запросы, пока ей не укажут разрешённые origin.

Решение: на сервере отдавать в ответ на preflight Access-Control-Allow-Headers: traceparent, tracestate. В браузерном SDK задать propagateTraceHeaderCorsUrls шаблоном, который совпадает с origin вашего API. Нужно и то и другое: по отдельности симптом остаётся прежним. Если preflight возвращается с неожиданным статусом, сверьтесь со шпаргалкой по HTTP-кодам состояния, прежде чем винить заголовок.

Прокси, WAF и балансировщики срезают незнакомые заголовки

Симптом: при прямом обращении к сервису через curl заголовок на месте, а при том же запросе через шлюз его нет.

Причина: проброс по allowlist. Множество конфигураций прокси, наборов правил WAF и управляемых балансировщиков передают дальше только знакомые заголовки, а traceparent в список по умолчанию не входит. Некоторые mesh к тому же переписывают заголовок: генерируют собственный trace-id, а ваш выбрасывают.

Решение: ищите методом деления пополам с помощью echo-сервера из предыдущего раздела — ставьте его по очереди за каждым переходом и смотрите, какой слой теряет заголовок. Затем явно разрешите traceparent и tracestate в правилах проброса этого слоя. Если прокси — nginx, учтите: набор передаваемых заголовков определяет тот блок, который обрабатывает маршрут, а это не всегда тот блок, которого вы ожидаете; правила приоритета location в nginx объясняют, почему конфигурация заголовков может выглядеть полностью проигнорированной.

В очередях сообщений нет HTTP-заголовков

Симптом: трассировка обрывается ровно в тот момент, когда запрос превращается в фоновую задачу.

Причина: через эту границу не проходит ни одного HTTP-запроса, а значит, заголовку не на чем ехать. У Kafka есть заголовки записей, у SQS — атрибуты сообщений, и ни то ни другое HTTP-инструментация за вас не заполнит.

Решение: класть контекст в сообщение на стороне producer и доставать на стороне consumer. Ровно для этого в каждом SDK OpenTelemetry есть inject и extract, а формат передачи — та же самая строка W3C; меняется только носитель: вместо карты HTTP-заголовков приходят метаданные сообщения. Документация по propagator в OpenTelemetry описывает интерфейс носителя для каждого языка.

Регистр и то, что на самом деле приводит к нижнему регистру HTTP/2

Симптом: спор на код-ревью о том, допустимо ли писать Traceparent.

Причина: два разных правила склеиваются в одно. Имена заголовков в HTTP/1.1 нечувствительны к регистру, а HTTP/2 требует кодировать их на проводе в нижнем регистре. Это про имя. Независимо от этого шестнадцатеричные цифры в значении заголовка обязаны быть в нижнем регистре, потому что так велит грамматика W3C, — и ни одна версия протокола это за вас не исправит.

Решение: отправлять имя как traceparent и никогда не переводить значение в верхний регистр. Шлюз, который нормализует имена заголовков, не тронет ваши шестнадцатеричные цифры, а trace-id в верхнем регистре беспрепятственно проходит все транспортные слои и отвергается только приложением, которое в итоге его разбирает.

Стоит ли доверять входящему traceparent?

traceparent, пришедший из публичного интернета, — это ввод под контролем пользователя: строка, которую выбрал анонимный клиент и которую большинство сервисов принимает не задумываясь.

Отсюда три конкретных риска. Первый — склейка трассировок: атакующий, отправивший подсмотренный где-то trace-id, вшивает свой запрос в существующую трассировку, а это засоряет граф и может раскрыть внутренние тайминги тому, кто эту трассировку читает. Второй — сжигание квоты: жёстко прописанный 01 включает сэмплирование на каждом запросе, и умеренный поток превращается в очень крупный счёт за приём данных или, что хуже, вытесняет как раз те трассировки, которые были нужны. Третий — корреляция между тенантами: один и тот же trace-id в запросах разных тенантов связывает записи, которые инструменты затем считают одной логической операцией.

Прагматичная позиция — принимать на границе, но не доверять. Проверяйте грамматику и отбрасывайте некорректные заголовки, а не пропускайте их внутрь. Для неаутентифицированного трафика принимайте решение о сэмплировании заново, вместо того чтобы полагаться на входящий флаг: тогда никакой внешний клиент не переведёт ваш сэмплер в режим «записывать всегда». Для аутентифицированного трафика решение вызывающей стороны обычно можно принять: здесь вы знаете, с кем имеете дело.

И считайте trace-id публичным. Секретом он не был никогда: он попадает в логи, на страницы ошибок, в заголовки ответов и в скриншоты, приложенные к тикетам поддержки. Никогда не кодируйте в нём ID пользователя, имя тенанта или что-то ещё осмысленное и никогда не используйте его как ключ авторизации. Это идентификатор для корреляции, и ничем большим он быть не должен.

FAQ

Чем traceparent отличается от tracestate?

traceparent несёт стандартизованную идентичность — trace-id, parent-id и флаги сэмплирования, — и понимать его обязана каждая реализация. tracestate несёт состояние конкретного вендора, которое незнакомые с ним реализации передают дальше нетронутым. Связь между ними прямая: если traceparent невалиден, спецификация требует проигнорировать и tracestate.

Почему трассировка начинается заново в середине цепочки вызовов?

Трассировка начинается заново почти всегда потому, что один из переходов получил заголовок, не прошедший грамматику, отбросил его и сгенерировал новый trace-id. К этому приводят шестнадцатеричные цифры в верхнем регистре, полностью нулевой trace-id и длина, отличная от ровно 55 символов. Если заголовок корректен, следующие подозреваемые — прокси, который его срезает, и упавший кросс-доменный preflight.

Нужно ли настраивать CORS, чтобы отправлять traceparent из браузера?

Да, настроить CORS необходимо. traceparent — нестандартный заголовок: он делает запрос «непростым» и вызывает preflight, поэтому сервер обязан перечислить его в Access-Control-Allow-Headers. Браузерной инструментации OpenTelemetry вдобавок нужен настроенный propagateTraceHeaderCorsUrls, потому что по умолчанию она не подставляет заголовки трассировки в кросс-доменные запросы.

Как передать контекст трассировки через Kafka или SQS?

На стороне producer записать значение traceparent в заголовок записи Kafka или в атрибут сообщения SQS, а на стороне consumer прочитать обратно и восстановить контекст. В SDK OpenTelemetry для этого есть inject и extract на всех языках. Формат не меняется; меняется только носитель — это уже не карта HTTP-заголовков.

Безопасно ли раскрывать trace ID в логах или ответах?

Да, trace ID можно раскрывать безопасно. Это случайный идентификатор без встроенных сведений о личности и без каких-либо полномочий. Он действительно связывает записи между системами, поэтому никогда не кодируйте в нём ID пользователя или имя тенанта и никогда не принимайте его как доказательство чего-либо. Относитесь к нему как к публичному ключу корреляции — и его безопасно писать в логи, возвращать и показывать.

Кто генерирует заголовок traceparent?

Заголовок traceparent генерирует первый сервис, которому запрос пришёл без него: обычно это пограничный прокси, API-шлюз или браузерный SDK. Он становится корнем трассировки — генерирует trace-id, создаёт первый span и принимает решение о сэмплировании за всю цепочку. Каждый следующий переход переписывает только parent-id.

Обязателен ли заголовок traceparent?

Нет, заголовок traceparent не обязателен. Спецификация его не требует, и запрос без него полностью корректен: принимающий сервис просто становится корнем новой трассировки. Обязательным он оказывается только на практике — без него работу, разнесённую по разным сервисам, нечем свести в одну трассировку.

Даёт ли traceparent заметные накладные расходы?

По существу нет. traceparent занимает 55 байт, tracestate обычно добавляет ещё несколько сотен — пренебрежимо мало на фоне TLS-рукопожатия или любого реального payload. Настоящая цена трассировки — экспорт и хранение попавших в выборку span, а не перенос заголовков распределённой трассировки по сети.

Теги: distributed-tracing opentelemetry observability http-headers w3c

Похожие статьи

Все статьи