---
title: "Request ID: сквозная трассировка запросов"
description: "request id go логи: генерируем идентификатор через crypto/rand, ставим его в middleware, достаём из context и трассируем всю цепочку вызовов сервиса."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/14-request-id/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Request ID: сквозная трассировка запросов

*Курс «Zap: структурное логирование на Go», урок 14 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zap/lessons/14-request-id/*

Request id go логи связывают в одну цепочку: middleware, хендлер, сервис и SQL-запрос одного HTTP-обращения получают общий идентификатор, и хронология восстанавливается одним поиском. Здесь разберём генерацию через crypto/rand, посредник с logger.With, хранение логгера в request context и трассировку вызовов через границы слоёв.

Пользователь пишет в поддержку: «списание прошло дважды, вот скриншот». Вы открываете логи и видите за эту минуту четыреста записей: чужие заказы, фоновые отчёты, ретраи из воркера. Записи его запроса есть, но они перемешаны с чужими, и отличить их можно только по user_id, который стоит далеко не везде. Корень проблемы в том, что каждый слой писал от своего логгера: middleware от корневого, хендлер от своего, сервис — как получится. Лекарство — общий идентификатор на всё обращение: request id. Он ставится один раз на входе, уходит в каждую запись запроса и, что важно, возвращается клиенту в заголовке ответа — чтобы по жалобе можно было попросить ID и найти всё сразу. В этом уроке соберём полный конвейер: генерация, посредник, хранение в context и прокидывание через слои. Отдельно про скриншот из обращения: клиент видит ошибку с ID в заголовке ответа, пересылает его в поддержку, и та попадает в лог одним поиском — без выгрузки user_id из базы и гаданий по таймстампам.

### Request id go логи: генерация через crypto/rand

Идентификатор должен быть уникальным без центрального координатора, и тут работает простая арифметика случайности. Шестнадцать байт из crypto/rand, переведённые в hex, дают строку в 32 символа со 128 битами энтропии: миллиарды запросов в день не создают практически шанса на коллизию. Отсюда и связка request id go логи, ради которой всё затевается: логгер с этим полем пишется в каждой записи, а выборка по нему в системе сбора мгновенно собирает цепочку. Почему crypto/rand, а не время плюс пид? Времени не хватает на уникальность при высокой нагрузке, а предсказуемые ID превращают его в нумерацию — соседние запросы легко перебрать. Если обращение пришло с готовым заголовком X-Request-Id от балансера или другого сервиса — его надо принять, а не генерировать свой: непрерывность ID через всю цепочку сервисов и есть главная ценность. Формат значения конвенция не фиксирует: подойдёт UUID или номер от централизованного генератора, лишь бы уникальность держалась без координации между процессами — случайные байты решают это дешевле всех.

### Генератор идентификатора

```go
import (
        "crypto/rand"
        "encoding/hex"
)

func newRequestID() string {
        b := make([]byte, 16)
        rand.Read(b) // заполняет весь слайс, ошибки не возвращает
        return hex.EncodeToString(b)
}
```

Шестнадцать байт, hex — 32 символа без дефисов, удобных для grep. Нюанс, который ломает стереотипы: на современных версиях Go crypto/rand.Read документированно не возвращает ошибку и всегда заполняет слайс целиком, поэтому проверка здесь мёртвый код — старые примеры с if err != nil остались от прежних времён. Запускать такой конвейер, как и весь курс, нужно на локальном Go.

### Middleware ставит логгер в context

Посредник — единственное место, где ID и логгер встречаются. План такой: достать X-Request-Id из заголовка или сгенерировать новый; продублировать его в ответ, чтобы клиент увидел; сделать base.With(zap.String("request_id", id)) и положить этого потомка в request context. Дальше по цепочке путешествует не идентификатор, а готовый логгер: каждый, кто достанет его из контекста, пишет с правильным полем, не зная, откуда оно взялось. Ключ контекста — не строка, а собственный пустой тип: строковые ключи в context.WithValue считаются гонкой на коллизию, а несовпадающий тип просто не скомпилируется с чужим ключом. Обратите внимание на порядок: заголовок ответа ставится до первой записи тела, иначе net/http молча выбросит его. Заметьте: посредник не знает ни одного маршрута и не различает методы — сознательно. Access-обвязка не должна меняться при росте роутинга, всё маршрутное живёт в хендлерах и их собственных With.

### Посредник с per-request логгером

```go
type ctxKey struct{}

func RequestLogger(base *zap.Logger) func(http.Handler) http.Handler {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        id := r.Header.Get("X-Request-Id")
                        if id == "" || len(id) > 64 {
                                id = newRequestID()
                        }

                        w.Header().Set("X-Request-Id", id)

                        logger := base.With(zap.String("request_id", id))
                        ctx := context.WithValue(r.Context(), ctxKey{}, logger)

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

Замыкание в замыкании — стандартная форма конструктора посредника: внешняя принимает зависимости, внутренняя обрабатывает запрос. Чужой ID принимается, но с потолком в 64 символа: он пойдёт в заголовок ответа и в чужие системы, а управляющие символы и гигантские строки там делать нечего. With стоит копейки — потомок на каждый запрос законен.

### Достаём логгер: обёртка над context.Value

Сырой вызов r.Context().Value(ctxKey{}) возвращает any, и каждый вызов превращается в type assertion с проверкой. Это прячут в одну функцию-обёртку: LoggerFromContext принимает context.Context и возвращает *zap.Logger — либо найденного потомка с request_id, либо запасного. Запасной вариант — не педантичность, а страховка: фоновые задачи, тесты и код, вызванный мимо посредника, падать не должны, они просто пишут без ID, и по записям сразу видно, что цепочка где-то порвалась. Глобальный zap.L здесь удобнее, чем кажется: после ReplaceGlobals он сконфигурирован корректно, и записи с запасного логгера хотя бы попадают в общий поток с правильным форматом. Жёсткое правило — не хранить логгер в пакетной переменной «на всякий случай»: контекст существует ровно для этого. Обёртка ещё и щит от типовой ошибки: если в контекст положили не логгера, а строку, ok-форма вернёт false, и вы получите честный фолбэк вместо падения на первом вызове метода.

### Обёртка с фолбэком

```go
func LoggerFromContext(ctx context.Context) *zap.Logger {
        if logger, ok := ctx.Value(ctxKey{}).(*zap.Logger); ok {
                return logger
        }

        return zap.L() // логгер без request_id: лучше, чем паника
}
```

Проверка через ok-форму обязательна: Value возвращает any, и nil-логгер в проде хуже любого отсутствия ID — первый же вызов метода упадёт. Фолбэк на zap.L сохраняет формат записей и делает разрыв цепочки видимым: такие записи отличают по отсутствию поля request_id.

### Трассировка цепочки вызовов

Теперь главный выигрыш. Хендлер достаёт логгера из контекста, добавляет свои поля через With — и передаёт контекст дальше: в сервис, из сервиса в репозиторий. Каждый слой делает то же самое: достал, дополнил, передал. Трассировка запросов go-сервиса получается не из системы мониторинга, а из дисциплины: записи «order accepted», «charge started», «slow query» разных слоёв несут один request_id, и поиск по нему выстраивает хронологию с таймстампами. Ориентир для кода команды простой: функция пишет в лог и принимает контекст — значит, пишет через LoggerFromContext; не принимает контекст — либо лог ей не нужен, либо сигнатура неполна. В сервисы контекст приходит параметром — это тот самый шаблон, ради которого в стандартной библиотеке всюду первый аргумент ctx. Если какой-то слой контекст не принимает, цепочка рвётся на этом месте: записи ниже идут без ID, и это первое, что ищут, когда «трассировка не работает».

### Хендлер: достали и дополнили

```go
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
        log := LoggerFromContext(r.Context())

        order, err := h.svc.Create(r.Context(), r.PostFormValue("sku"))
        if err != nil {
                log.Error("order create failed", zap.Error(err))
                http.Error(w, "internal error", http.StatusInternalServerError)
                return
        }

        log.Info("order created",
                zap.String("order_id", order.ID),
                zap.String("sku", order.SKU),
        )
}
```

log здесь — потомок с request_id из посредника; With добавляет order_id поверх, не трогая общего логгера. Ошибка уходит и в ответ клиенту, и в запись с полем error — позже она ляжет рядом с остальными в общей цепочке. Сервис внутри продолжает цепочку тем же приёмом от r.Context().

### Проверка: логгер в горутине

**Вопрос:** Хендлер запустил горутину, передав ей r.Context() до завершения запроса. Что запишет LoggerFromContext(ctx) внутри этой горутины?

- nil: контекст запроса умирает вместе с запросом
- ✅ Потомка с request_id: контекст копируется по значению и доступен наследникам
- Новый случайный ID: горутина считается отдельным запросом
- Панику: Value нельзя вызывать из другой горутины

Значения контекста доступны любой горутине, которой передали сам контекст. Опасность другая: после отмены контекста запроса записи о завершении фонового дела пойдут уже с отменённым ctx — для долгих задач его отпочковывают через context.WithoutCancel или отдельный фон.

### Чужой заголовок: доверять, но проверять

Принимать X-Request-Id снаружи нужно — иначе рвётся сквозная трассировка через балансер и соседние сервисы. Но это пользовательский ввод, и он окажется в заголовке ответа и в строках лога. Два минимума гигиены: потолок длины (64 символа хватает даже для UUID-форматов) и отсечение управляющих символов, которыми теоретически можно подделать строку лога — вставить перевод строки и вписать фальшивую запись. Здесь стоит честное компромиссное решение: строгую валидацию по регулярке можно не делать, если система сбора парсит JSON — там пользовательский ID останется значением одного поля и портить соседние записи не сможет. В текстовых логах без экранирования это уже дыра, и правило меняется: либо валидация, либо JSON.

### request_id и trace_id: не путать

У request id есть взрослый родственник — trace_id из стандартов распределённой трассировки: W3C прокидывает его в заголовке traceparent, а OpenTelemetry раскладывает по спанам. Разница практическая: request id отвечает на вопрос «что делал этот HTTP-запрос», trace_id — «как связаны вызовы через пять сервисов и очередь». Для монолита и небольшого набора сервисов первого достаточно, и он не тянет зависимостей. Когда сервисов становится много, request id превращают в trace_id: поле переименовывается, а значение берут из traceparent — конвейер из этого урока не меняется вовсе. Полное сопоставление логов с трейсами и метриками — отдельная тема, ей посвящён урок курса про наблюдаемость. Если работают обе системы, поля живут рядом: request_id для людей, trace_id для машины, — ключи не конфликтуют, и искать можно по обоим.

### Сколько разных ID?

```go
// запрос А: без заголовка X-Request-Id
// запрос Б: с заголовком X-Request-Id: abc123

// оба прошли через RequestLogger(base)
```

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

- Оба запроса получат одинаковые ID: генератор глобальный
- ✅ У запроса А — новые случайные 32 символа, у Б — abc123
- Оба получат новые случайные ID: входной заголовок игнорируется
- У запроса А ID не будет вовсе: генерация только по заголовку

Посредник сначала смотрит заголовок и только при пустоте генерирует. Запрос А получит свежий hex из crypto/rand, запрос Б унаследует abc123 — и если он пришёл от балансера, цепочка продолжится под тем же ID.

### Проверка: ключ контекста

**Вопрос:** Почему ключ для context.WithValue объявляют как type ctxKey struct{}, а не используют строку?

- Строковые ключи запрещены стандартной библиотекой
- Пустая структура занимает ноль байт, а строка — восемь
- ✅ Свой тип исключает коллизию с чужими пакетами: несовпадающий тип не даст случайно прочитать не то значение
- Со структурой контекст быстрее копируется

Строки допустимы, но любой пакет в мире может запросить Value("logger") и получить чужое. Приватный тип делает ключ уникальным для вашего пакета: чужой код с тем же именем строки увидит своё, а не ваше. Ноль байт — приятный бонус, не причина.

> **Горутинам тоже нужен ctx.** Классическая дыра конвейера: go func() { ... }() внутри хендлера без передачи контекста. Внутри такой горутины LoggerFromContext достанет фолбэк, и записи уйдут без request_id — с виду всё работает, а трассировка молчит. Передавайте ctx аргументом и запускайте горутину с ним же.

### Соберите посредника

Восстановите три строки: заголовок, контекст логгера, контекст запроса.

```go
id := r.Header.___("X-Request-Id")
if id == "" {
        id = newRequestID()
}

logger := base.___(zap.String("request_id", id))
ctx := context.___(r.Context(), ctxKey{}, logger)
```

Подсказка: Заголовок читают Get, поля добавляет With, значение в контекст кладёт WithValue.

Правильные ответы в пропусках: `Get`, `With`, `WithValue`

### Соберите тело посредника

Расставьте строки от чтения заголовка до передачи запроса дальше.

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

1. `id := r.Header.Get("X-Request-Id")`
2. `if id == "" {`
3. `id = newRequestID()`
4. `}`
5. `logger := base.With(zap.String("request_id", id))`
6. `next.ServeHTTP(w, r.WithContext(ctx))`

### Карточки: request id

- **newRequestID** — 16 байт из crypto/rand в hex: 32 символа, 128 бит энтропии. Коллизии практически нет, ID непредсказуем.
- **X-Request-Id** — Заголовок-конвенция: пришёл — принимаем с потолком длины, нет — генерируем. Дублируем в ответ, чтобы клиент мог процитировать.
- **ctxKey struct{}** — Приватный пустой тип-ключ для context.WithValue: исключает коллизии с чужими пакетами.
- **LoggerFromContext** — Обёртка над context.Value: возвращает потомка с request_id или фолбэк zap.L без паники.
- **Разрыв цепочки** — Слой без ctx в сигнатуре или горутина без передачи контекста: записи ниже идут без request_id.
- **request_id vs trace_id** — Первый — про один HTTP-запрос, второй — про связь вызовов между сервисами. Конвейер один, отличается источник значения.

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

Тот же приём в Gin и Echo — через их посредников и c.Set: форма показана в уроке про [логирование HTTP-запросов](/courses/zap/lessons/15-http-middleware/), где per-request логгер дополняется статусом и латентностью. Про то, как request id дорастает до trace_id и связывается с метриками, читайте в материале про [логи и наблюдаемость](/courses/zap/lessons/23-observability/), а приём With из всего этого — частный случай контекстных логгеров, разобранных в уроке про [With и Named](/courses/zap/lessons/12-with-named/). Карта курса — на [странице курса](/courses/zap/).

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

- request_id ставится один раз в посреднике: base.With(request_id) кладётся в request context.
- Генерация — 16 байт crypto/rand в hex: 128 бит, коллизий практически нет, время и пид не нужны.
- Входной X-Request-Id принимаем с потолком длины: он вернётся клиенту и уйдёт в чужие системы.
- Достают логгера обёрткой LoggerFromContext: фолбэк на zap.L вместо паники и nil-ов.
- Контекст прокидывается в каждый слой: разрыв виден по записям без request_id.
- Горутинам контекст передают аргументом — иначе записи идут без ID.
- trace_id — следующий шаг: поле то же, источник значения — заголовок traceparent.

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

Соберите на локальном Go конвейер целиком: посредник RequestLogger, хендлер, сервис и функция-репозиторий, каждый слой дополняет логгера через With своим полем. Отправьте два запроса: один с заголовком X-Request-Id: test-42, другой без — и по JSON-выводу найдите записи обоих, сравнив поля request_id. Добавьте горутину без контекста и убедитесь, что её записи идут через фолбэк без ID.

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

### Почему бы не генерировать ID прямо в хендлере?

Тогда каждый хендлер повторяет одну и ту же обвязку, а записи middleware — access-лог, латентность — остаются без ID. Посредник ставит контекст один раз до всех слоёв, и хендлер только достаёт готовое.

### Что делать с фоновыми задачами, у которых нет HTTP-запроса?

Дайте им свой стабильный идентификатор: With с полем task_id или job_id от корневого логгера. Формат поля другой, принцип тот же — общий ключ, по которому записи задачи собираются в цепочку.

### Можно ли использовать UUID вместо hex из crypto/rand?

Да: google/uuid возвращает 36 символов с дефисами на тех же 128 битах. Разница стилистическая — hex короче и без дефисов удобнее в grep; энтропия и гарантия crypto/rand у обоих вариантов одинаковые.
