---
title: "trace_id и корреляция запросов"
description: "trace_id в логах zerolog: заголовки X-Request-Id и W3C traceparent, генерация crypto/rand, логгер запроса через With, хук с GetCtx, склейка строк запроса."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/22-logi-trejsy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# trace_id и корреляция запросов

*Курс «Zerolog в Go: быстрое JSON-логирование», урок 22 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zerolog/lessons/22-logi-trejsy/*

Сервис из урока про HTTP-логи подписывал записи request_id, и склейка работала, пока запрос жил в одном процессе. Стоит появиться второму сервису, прокси и фоновым вызовам, и одного идентификатора мало: каждая программа заводит свой, и лента одного запроса распадается на куски между машинами. Корреляция логов строится на trace_id — идентификаторе, который рождается у входа в систему и проезжает все вызовы в заголовке. trace id в zerolog не встроен: в ядре нет ни opentelemetry, ни генерации идентификаторов — есть поля, контекст и хуки, из которых вы собираете разметку сами. В этом уроке: откуда берётся trace_id — заголовки X-Request-Id и W3C traceparent плюс генерация crypto/rand; как приклеить поле к каждой записи запроса через With и request-контекст; как хук дозаписывает поле в событие через GetCtx и при чём здесь гонка UpdateContext, уже разбиравшаяся в уроке про горутины.

Строка лога без связи со своим запросом — обрывок: факт есть, истории нет. Под нагрузкой в секунду проходят сотни запросов, их записи перемешиваются в ленте, и без общего идентификатора восстановить цепочку «запрос, сервис, база, ответ» — археология по таймстампам с точностью до миллисекунд, которая ломается на первом же соседнем запросе из того же момента. Идентификатор решает это структурно: каждая запись несёт одно поле, и фильтр по нему собирает полную хронологию во всех сервисах, куда запрос дошёл. Дисциплина вокруг поля простая, но строгая: ключ один во всей системе, значение не меняется до конца запроса, поле пишут все сервисы цепочки. Терминология урока: request_id из [урока про HTTP-логи](/courses/zerolog/lessons/11-log-http/) размечает запрос внутри одного контура, trace_id — сквозной идентификатор, который проезжает границы сервисов; на практике это часто одно и то же значение, и разница — в договорённости, кто его ставит.

### Откуда берётся trace_id: заголовки и генерация

Приоритетный источник — входящий заголовок: кто-то до вас уже пометил запрос, и задача сервиса не потерять значение. В ходу два формата. X-Request-Id — простой непрозрачный токен, его ставят ingress, балансировщик или шлюз; формат свободный, договоритесь о длине и алфавите. W3C traceparent — стандарт трейсинга: строка вида 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01, где поля разделены дефисами — версия, trace-id из 32 шестнадцатеричных символов, parent-id из 16 и флаги из 2. Парсится он своей функцией в десять строк: библиотека для этого не нужна, и в ядре zerolog её нет. Если заголовков нет — идентификатор генерируете вы: crypto/rand даёт шестнадцать случайных байт, hex.EncodeToString превращает их в строку тех же 32 символов. Приоритет прозрачен: traceparent — стандарт, затем X-Request-Id от прокси, затем генерация; любое из значений годится для поля лога.

### Парсер traceparent и генерация идентификатора

```go
func traceID(r *http.Request) string {
        if tp := r.Header.Get("traceparent"); tp != "" {
                if id, ok := parseTraceparent(tp); ok {
                        return id
                }
        }
        if id := r.Header.Get("X-Request-Id"); id != "" {
                return id
        }
        var b [16]byte
        if _, err := rand.Read(b[:]); err != nil {
                return strconv.FormatInt(time.Now().UnixNano(), 16)
        }
        return hex.EncodeToString(b[:])
}

func parseTraceparent(tp string) (string, bool) {
        parts := strings.Split(tp, "-")
        if len(parts) != 4 || parts[0] != "00" {
                return "", false
        }
        if len(parts[1]) != 32 || len(parts[2]) != 16 || len(parts[3]) != 2 {
                return "", false
        }
        if _, err := hex.DecodeString(parts[1]); err != nil {
                return "", false
        }
        return parts[1], true
}
```

Приоритет источников — от стандарта к фолбэку: traceparent, X-Request-Id, генерация crypto/rand. Парсер проверяет структуру по частям: четыре поля, версия 00, длины 32-16-2 и честный hex в trace-id — битая строка молча проваливается к следующему источнику. Генератор возвращает 32 hex-символа — тот же вид, что у стандарта, поэтому поле trace_id выглядит одинаково при любом источнике.

> **Живое место: UpdateContext на общем логгере.** Идея «дописать trace_id в общий пакетный логгер на каждый запрос» ломается на первой параллельной нагрузке: UpdateContext меняет внутренний слайс контекста на месте и прямо помечен не потокобезопасным — два запроса гоняются за одним буфером, и в записях оказываются чужие идентификаторы. Случай уже разбирался в [уроке про горутины](/courses/zerolog/lessons/15-log-v-gorutinah/); здесь он возвращается, потому что middleware — самое соблазнительное место для такого кода. Если trace_id должен жить у каждой записи запроса, то собирайте копию через With и кладите её в request-контекст: hlog.NewHandler из поставки zerolog делает ровно это и по той же причине создаёт копию до записи в контекст. Проверка за минуту: два конкурентных запроса и go run -race — детектор покажет гонку на UpdateContext, а копии через With дадут чистый прогон.

### Логгер запроса: With и request-контекст

Механика та же, что у request_id из урока про HTTP-логи, меняются значение и название поля. В middleware собирается копия: l := log.With().Str("trace_id", id).Logger() — With копирует контекстные байты базового логгера и дописывает поле, и каждая запись этой копии несёт trace_id без единого дополнительного вызова в хендлерах. Контекстные поля из [урока про Logger.With](/courses/zerolog/lessons/06-log-context/) копируются один раз при сборке, дальше копия только читается — поэтому параллельные запросы не мешают друг другу. Копия уезжает вниз по стеку через request-контекст: l.WithContext(r.Context()) и next.ServeHTTP(w, r.WithContext(ctx)); в глубине — сервисный слой, репозиторий, фоновая горутина — логгер достаётся zerolog.Ctx, который никогда не возвращает nil. Нюанс из той же связки: отключённые логгеры в контекст не кладутся — копия с порогом Disabled вернёт дефолт, и записи запроса молча пропадут. Полезный жест — вернуть идентификатор клиенту тем же заголовком: поддержка назовёт его из UI, и фильтр по trace_id найдёт весь путь запроса за секунды.

### Middleware корреляции целиком

```go
func traceMiddleware(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                id := traceID(r)
                w.Header().Set("X-Request-Id", id)

                l := log.With().Str("trace_id", id).Logger()
                ctx := l.WithContext(r.Context())

                next.ServeHTTP(w, r.WithContext(ctx))
        })
}

func handle(w http.ResponseWriter, r *http.Request) {
        l := zerolog.Ctx(r.Context())
        l.Info().Str("route", "/orders").Msg("order received")
        // запись несёт поле trace_id без единого вызова в хендлере
}
```

Middleware делает три вещи: выбирает идентификатор по приоритету источников, возвращает его клиенту заголовком и собирает размеченную копию логгера. With дописывает trace_id в контекст копии, WithContext кладёт её в request-контекст — хендлер достаёт логгер zerolog.Ctx и пишет обычное Info-событие, а поле trace_id в записи появляется само.

### Hook: дозапись поля в событие

Когда логгер параметром не передаётся — чужой код пишет в глобальный log.Logger, а контекст запроса в глубине стека недоступен, — поле доставляет хук. Хук zerolog.Hook с методом Run(e *Event, level Level, message string) срабатывает в момент Msg до записи в writer: событие ещё открыто, и e.Str() из хука попадает в JSON — это факт механики msg(), а не трюк. Контекст до хука доезжает цепочкой .Ctx(ctx) на событии и методом e.GetCtx() внутри Run: док-комментарий Event.Ctx в исходниках называет извлечение трассировочной информации основным сценарием. Схема честная: каждый вызов должен приложить контекст .Ctx(ctx); без него хук увидит пустой context.Background, и в записи не появится ничего. Поэтому Hook — инструмент точечных дозаписей, а разметку целого запроса надёжнее делать With-копией в middleware: она приклеивает поле ко всем событиям разом и не зависит от дисциплины вызовов. Выбор простой: контролируете точки входа — With; событие идёт из чужого кода — хук.

### Хук, достающий trace_id из контекста

```go
type traceKey struct{}

type traceHook struct{}

func (traceHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
        if id, ok := e.GetCtx().Value(traceKey{}).(string); ok {
                e.Str("trace_id", id)
        }
}

var base = zerolog.New(os.Stderr).Hook(traceHook{}).With().Timestamp().Logger()

func doWork(ctx context.Context) {
        base.Info().Ctx(ctx).Str("step", "charge").Msg("working")
        // поле появится, если trace_id лежит в ctx под traceKey
}
```

Хук срабатывает в msg() до записи: e.Str дописывает поле в ещё открытый буфер события, и оно уезжает в JSON. Контекст прикладывается к событию методом Ctx и достаётся в Run через GetCtx — только так хук видит значения запроса. Утверждение с проверкой типа отсекает события без контекста: поле просто не появится в записи.

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

### Кто ставит идентификатор

| Источник | Что ставит | Формат | Место в приоритете |
| --- | --- | --- | --- |
| Инструментированный клиент | traceparent | 00-trace-id(32 hex)-parent(16)-flags(2) | первый: стандарт W3C |
| Ingress или балансировщик | X-Request-Id | непрозрачный токен | второй |
| Ваш middleware | генерация crypto/rand | 16 байт в hex: 32 символа | фолбэк без заголовков |
| hlog.CustomHeaderHandler | поле из заголовка | любое значение | готовый хендлер пакета zerolog |
| Парсер из урока | trace_id | 32 hex-символа | единый вид поля при любом источнике |

### X-Request-Id против traceparent

Два заголовка конкурируют за одну роль, и оставлять выбор на каждый сервис — путь к ленте, где половина записей подписана req_id, половина trace_id, а фильтр работает через раз. Решение — одна запись в стандарте команды: приоритет у traceparent, если строка распарсилась, затем X-Request-Id, затем генерация; схема ровно та, что в коде урока. Тонкость traceparent: второе поле — идентификатор трейса, третье — идентификатор спана, текущего отрезка работы. В логи уезжает trace-id; span_id тоже полезен — он различает два сервиса одной цепочки, — но это отдельное поле, а не замена trace_id. Флаги из двух символов говорят о сэмплинге трейса и в логе не нужны. Если трейсера в системе нет, а клиенты присылают traceparent, парсить и использовать trace-id всё равно стоит: поле останется совместимым с будущим внедрением, и лента не заметит подмены источника. Проверка стандарта — один тест на middleware: битый traceparent, пустой X-Request-Id и генерация дают предсказуемые значения поля.

### Чек-лист корреляции

- идентификатор берётся по приоритету: traceparent, затем X-Request-Id, затем crypto/rand
- поле пишется одним ключом trace_id во всех сервисах цепочки
- копия логгера собирается через With в middleware и едет в request-контексте
- UpdateContext на общем логгере запрещён: не потокобезопасен, гонка под нагрузкой
- хук с GetCtx дозаписывает поле, когда логгер-параметр недоступен; событию нужен .Ctx(ctx)
- идентификатор возвращается клиенту заголовком — поддержка найдёт запрос по нему
- фоновые задачи уносят trace_id в очередь: воркер подписывает хвост цепочки
- тест: битый traceparent, чужой заголовок и генерация дают предсказуемое поле

> **Про песочницу.** Сетевой middleware в песочнице сайта не собрать: она исполняет программы без внешних библиотек, и zerolog туда не входит. Тематическая программа покажет распарсенный traceparent: строка стандарта на входе, четыре поля и извлечённый trace-id на выходе — вся логика урока без HTTP. Если хочется увидеть размеченную ленту целиком, то полный сценарий запускается локально: curl с заголовком X-Request-Id и поток stderr, где каждая запись запроса несёт ваше значение.

### Проверка: приоритет источников

**Вопрос:** Заголовки traceparent (валидный) и X-Request-Id пришли вместе. Что попадёт в поле trace_id?

- X-Request-Id: простой заголовок приоритетнее
- ✅ trace-id из traceparent: стандарт разбирается первым
- Новый id: два заголовка вместе — конфликт
- Пусто: поле не пишется при двух источниках

Приоритет урока: валидный traceparent — первый источник, потому что это стандарт трейсинга и значение совместимо с будущим внедрением. X-Request-Id остаётся фолбэком, генерация crypto/rand — последним.

### Проверка: UpdateContext в middleware

**Вопрос:** Почему middleware не дописывает trace_id через UpdateContext на общем логгере?

- ✅ Метод не потокобезопасен: параллельные запросы испортят контекст друг друга
- UpdateContext не умеет писать строковые поля
- Общий логгер запрещено менять после старта процесса
- Поле trace_id дописывается только ConsoleWriter-ом

UpdateContext меняет байтовый контекст на месте и помечен не потокобезопасным: под нагрузкой запросы получают в записях чужие идентификаторы. Копия через With на каждый запрос решает это value-семантикой.

### Что вернёт парсер на битой строке?

```go
tp := "00-4bf92f3577b34da6a3ce929d0e0e4736-zzz-01"

id, ok := parseTraceparent(tp)
if !ok {
        id = "fallback"
}
fmt.Println(id)
```

**Что выведет программа?**

- ✅ fallback — поле parent не совпало с 16 hex-символами
- 4bf92f... — trace-id годится и без проверки остальных полей
- Паника на Split: лишний дефис
- Пустая строка без ошибки

Третье поле zzz — не 16 шестнадцатеричных символов, проверка длин отсекает строку, и парсер возвращает ok false. Цепочка источников уходит к следующему — X-Request-Id или генерации, и в поле попадёт fallback.

### Соберите middleware корреляции

Восстановите разметку запроса: копия логгера, поле, request-контекст.

```go
func traceMiddleware(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                id := traceID(r)

                l := log.With().Str("trace_id", ___).Logger()
                ctx := l.___(r.___())

                next.ServeHTTP(w, r.WithContext(ctx))
        })
}
```

Подсказка: Значение поля — id, логгер кладёт WithContext, а контекст запроса достаётся r.Context().

Правильные ответы в пропусках: `id`, `WithContext`, `Context`

### Тело хука с GetCtx

Расставьте строки хука в правильном порядке: от объявления типа до дозаписи поля.

Правильный порядок строк:

1. `type traceHook struct{}`
2. `func (traceHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {`
3. `  ctx := e.GetCtx()`
4. `  id, ok := ctx.Value(traceKey{}).(string)`
5. `  if !ok {`
6. `    return`
7. `  }`
8. `  e.Str("trace_id", id)`
9. `}`

### Карточки: корреляция

- **trace_id и request_id** — request_id размечает запрос в одном контуре, trace_id — сквозной через сервисы. На практике часто одно значение; разница — в договорённости.
- **traceparent** — Заголовок W3C: 00-trace-id(32 hex)-parent-id(16 hex)-flags(2). В лог уезжает trace-id; parent-id — это span, отдельное поле.
- **With-копия на запрос** — log.With().Str("trace_id", id).Logger() плюс WithContext(r.Context()): поле у всех записей запроса, гонок нет.
- **zerolog.Ctx** — Логгер из request-контекста в глубине стека; никогда не nil — без значения вернёт DefaultContextLogger или отключённый.
- **Hook и GetCtx** — Хук срабатывает в Msg до записи: e.GetCtx() достаёт контекст, приложенный методом .Ctx(ctx), и e.Str дописывает поле в открытый буфер.
- **Гонка UpdateContext** — Меняет контекст на месте и не потокобезопасна: чужие trace_id в записях. Лечение — With-копия в middleware.

### Что дальше по курсу

Корреляция склеила строки — дальше курс чистит ленту: антипаттерны покажут, как записи теряют смысл ещё до сборщика, а урок про прод — что происходит с ними после writer-а. Справочное закрепление: прогоните тестовую цепочку из двух вызовов и найдите обе записи фильтром по trace_id, затем уберите поле из второго сервиса и увидьте, как история распадается; сам тест разметки собирается по рецепту из [урока про тесты логов](/courses/zerolog/lessons/20-testy-logov/) — буфер и разбор карты покажут поле в каждой записи. Проверьте хук: событие без .Ctx(ctx) не должно нести поле. Если стандарта на заголовки в вашей команде нет, напишите его по таблице выше и приложите тест middleware — договорённость, зафиксированная кодом, переживает смену команды.

## Коротко: что запомнить

- trace_id — сквозной идентификатор: рождается у входа, проезжает сервисы в заголовке, пишется полем события.
- Приоритет источников: валидный traceparent, затем X-Request-Id, затем crypto/rand — 32 hex-символа.
- traceparent: версия 00, trace-id из 32 hex, parent-id из 16, флаги из 2; в лог уезжает trace-id.
- Поле приклеивается With-копией на запрос: log.With().Str("trace_id", id).Logger() и WithContext.
- UpdateContext на общем логгере не потокобезопасен: под нагрузкой в записях чужие идентификаторы.
- Хук срабатывает в Msg до записи: e.GetCtx() достаёт контекст, e.Str дописывает поле.
- Ключ trace_id один во всех сервисах; фоновые задачи уносят его в очередь.
- Идентификатор возвращается клиенту заголовком — поддержка находит запрос по нему.

## Домашнее задание

Соберите middleware корреляции на http.ServeMux: приоритет заголовков, генерация crypto/rand, With-копия в request-контексте, возврат X-Request-Id клиенту. Прогоните curl с готовым traceparent и убедитесь, что в записи уехал именно trace-id, а не вся строка. Пришлите битый traceparent с коротким parent и проверьте фолбэк на X-Request-Id; затем уберите оба заголовка и убедитесь, что поле содержит 32 hex-символа из crypto/rand. Достаньте логгер zerolog.Ctx из вложенной функции и из фоновой горутины — поле обязано быть в обеих записях. Напишите traceHook с GetCtx и добавьте событие без .Ctx(ctx): поле должно исчезнуть, и этот случай стоит увидеть своими глазами. Запустите два конкурентных запроса под go run -race — чистый прогон подтвердит отсутствие UpdateContext. Зафиксируйте приоритет заголовков в README сервиса.

## Вопросы и ответы по уроку

### Чем trace_id отличается от request_id из урока про HTTP-логи?

Механически — почти ничем: и то и другое строковое поле, приклеенное With-копией на запрос. Разница в масштабе договорённости: request_id обычно живёт в контуре одного сервиса или шлюза, trace_id проезжает границы сервисов в заголовке и рассчитан на сквозную цепочку. Если трейсера нет, одно значение часто выполняет обе роли — важен не термин, а один и тот же ключ поля во всех сервисах цепочки.

### Нужна ли библиотека, чтобы распарсить traceparent?

Нет: заголовок — строка фиксированной структуры, и парсера из урока в десять строк достаточно: Split по дефису, проверка четырёх частей, версия 00, длины 32-16-2 и честный hex в trace-id. В ядре zerolog парсера нет — и для поля в логах он не нужен. Полноценная трассировка со спанами и экспортом — уже тема opentelemetry, но это отдельная библиотека, а не часть zerolog.

### Как прокидывать trace_id в фоновые задачи?

Идентификатор уезжает вместе с задачей: в аргументе, в структуре задания или в context.Context рядом с логгером из урока про горутины. Воркер собирает свою With-копию с тем же значением и подписывает записи тем же ключом — иначе хвост цепочки потеряется из фильтра. UpdateContext для этого не подходит: он не потокобезопасен и испортит контекст общего логгера.
