Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 14 из 25 ~18 мин +50 XP

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

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

Содержание урока · 10 разделов

Пользователь пишет в поддержку: «списание прошло дважды, вот скриншот». Вы открываете логи и видите за эту минуту четыреста записей: чужие заказы, фоновые отчёты, ретраи из воркера. Записи его запроса есть, но они перемешаны с чужими, и отличить их можно только по 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) внутри этой горутины?

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

Принимать 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?

Прочитайте код и выберите, что он выведет:

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

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

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

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

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

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

id := r.Header.("X-Request-Id") if id == "" { id = newRequestID() } logger := base.(zap.String("request_id", id)) ctx := context.(r.Context(), ctxKey{}, logger)

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: request id

Термин

newRequestID

нажмите, чтобы перевернуть
Определение

16 байт из crypto/rand в hex: 32 символа, 128 бит энтропии. Коллизии практически нет, ID непредсказуем.

1 / 6

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

Тот же приём в Gin и Echo — через их посредников и c.Set: форма показана в уроке про логирование HTTP-запросов, где per-request логгер дополняется статусом и латентностью. Про то, как request id дорастает до trace_id и связывается с метриками, читайте в материале про логи и наблюдаемость, а приём With из всего этого — частный случай контекстных логгеров, разобранных в уроке про With и Named. Карта курса — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Zap · Request ID: сквозная трассировка запросов)

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

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

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

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

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

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

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

Термины урока

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Colly25 уроков~9 чпрактика25 уроков уже доступны

Colly: парсинг и веб-скрапинг на Go

Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.

  • Писать скраперы на Colly от первого Visit до обхода целого сайта
  • Вынимать данные через CSS-селекторы и складывать в структуры Go
  • Настраивать темп: задержки, параллелизм, лимиты на домен
  • Работать с капризными сайтами: заголовки, cookies, прокси