Один вызов API оставляет тридцать записей: обработчик, сервис, репозиторий, кэш, внешние вызовы, финальная строка с длительностью. Без общего поля эти записи размазаны по потоку вперемешку с соседними вызовами, и разбор инцидента превращается в сопоставление по времени — занятие, которое при нагрузке в тысячу запросов в секунду никуда не годится. Механику логгера на запрос — With и контекст — показал восьмой урок; этот урок собирает полную картину: откуда идентификатор приходит, в каком формате его генерировать без зависимостей, как слои достают логгер из контекста, что происходит с полями в фоновых задачах и как поддержка находит запрос по коду обращения, который увидел пользователь. Отдельный сюжет — связь с трейсингом: request_id закрывает вопросы внутри сервиса, trace_id связывает сервисы между собой, и между ними есть ясная граница. К концу урока middleware, слои и фоновые задачи складываются в одну цепочку, где поле добавляется один раз и работает трижды: в логе, в метриках и в разговоре с пользователем.
Request id логи go: откуда берётся идентификатор#
У идентификатора два источника, и разбор request id логи go начинается с первого: заголовок X-Request-ID, который уже проставил балансир или шлюз. Его переиспользуют: инфраструктура до вашего сервиса уже собрала историю под этим номером, и переиспользование склеивает её со строками приложения — одна сквозная цепочка от входа в кластер до строки в репозитории. Заголовок подделываем, и это нормально: он не авторизация, а корреляция, доверять ему решениями не нужно. Если заголовок пуст — идентификатор генерирует middleware: восемь-шестнадцать случайных байт из crypto/rand, закодированные в hex или собранные в UUID-подобный формат без внешних зависимостей. Третье место, где идентификатор живёт, — ответ: middleware отдаёт его в заголовке ответа и в тексте ошибок, и пользователь с кодом обращения в руках даёт поддержке прямой ключ к своей истории.
Middleware: источник, контекст, ответ
GOtype loggerKey struct{}
func withRequestID(base *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newRequestID()
}
logger := base.With("request_id", id, "method", r.Method)
ctx := context.WithValue(r.Context(), loggerKey{}, logger)
w.Header().Set("X-Request-ID", id)
start := time.Now()
next.ServeHTTP(w, r.WithContext(ctx))
logger.Info("запрос завершён", "duration", time.Since(start))
})
}
func newRequestID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(b)
}Заголовок балансира переиспользуется, пустой — рождается свой. Логгер с request_id и method кладётся в контекст под типом-ключом, идентификатор отдаётся клиенту в ответе до первого WriteHeader, а финальная запись с длительностью печатается от того же потомка. rand здесь — crypto/rand: ошибка генерации уводит на метку времени, чтобы middleware не встал.
Логирование http go: слои после middleware#
Дальше начинается дисциплина разбор логирование http go: поле request_id добавляет ровно один слой — middleware, и повторного With с этим ключом в коде нет, иначе в записи появятся дубли из ловушки восьмого урока. Слои достают логгер из контекста: slog.FromContext(ctx) возвращает потомка с предзаданными полями, а если middleware не было — дефолтный логгер, и приложение продолжает работать с меньшим контекстом. Контекст уже передаётся параметром через обработчик, сервис и репозиторий, поэтому логгер едет внутри без нового аргумента; там, где контекста нет, логгер передают полем структуры — правило из восьмого урока остаётся в силе. Аутентификационный middleware встраивается в цепочку после request-id: он берёт логгер из контекста, добавляет своим With поле user_id и кладёт обновлённого потомка обратно — к обработчику едет уже логгер с двумя сквозными полями, и записи знают и запрос, и пользователя. Такая цепочка собирается и для других сквозных полей — язык, регион, версия клиента: каждый middleware добавляет своё поле и передаёт дальше, а вызов записи в глубине слоёв получает всё сразу.
Слои достают логгер из контекста
GOfunc (s *Service) AcceptOrder(ctx context.Context, orderID string) error {
logger := slog.FromContext(ctx)
logger.Info("заказ принят", "order_id", orderID)
if err := s.repo.Save(ctx, orderID); err != nil {
logger.Error("сохранение не удалось", "err", err)
return err
}
return nil
}FromContext вернул потомка middleware: обе записи несут request_id и method без единого упоминания в сервисном коде. Если запрос пришёл без middleware, вернётся дефолтный логгер — записи потеряют request_id, но не сломаются.
Фоновые задачи из запроса: потерянные поля#
Контекст запроса отменяется при выходе из обработчика, и фоновая работа, уехавшая с ним, получает отмену вместо результата. Наивный обход — go func с context.Background() — решает проблему отмены и ломает логгер: поля запроса жили в контексте, Background их не содержит, и записи фоновой горутины печатаются без request_id. Правильный ход — context.WithoutCancel: он сохраняет все значения контекста, включая логгер, и снимает отмену; записи продолжают нести request_id, а их видимость не зависит от жизни запроса. Для задач с собственным жизненным циклом — очереди писем, ночные пересчёты, вебхуки — поверх сохранённого контекста собирают потомка с task_id: поля request_id и task_id вместе отвечают на вопросы «из какого запроса выросла задача» и «что сделала задача сама». Если трейс уже заведён, уходит и trace_id — цепочка переживает переезд из запроса в фоновую работу.
Кейс: обработчик принимал заказ и уходил в go func(context.Background()) — отправка письма не должна была задерживать ответ. Письмо начало падать ночью по исчерпанному лимиту SMTP, и разбор зашёл в стену: в записях отправки нет request_id, фильтр по пользователю без id не работает, а по времени рядом — тысячи чужих строк. Лечение: контекст detached через context.WithoutCancel, поле task_id появилось у задачи, и одна строка с ошибкой письма вернулась в историю породившего запроса. Дежурный нашёл письмо фильтром по task_id, а по request_id — весь запрос целиком: заказ, пользователь, обработчик, сбой SMTP. Примета для поиска: записи без request_id в сервисе, где middleware стоит у всех маршрутов, — почти срыв кого-то из фоновых горутин.
Склейка строк: фильтр и работа поддержки#
Смысл поля раскрывается у потребителя. Записи одного вызова склеивает фильтр: jq по request_id вытягивает полный маршрут — метод, шаги обработчика, ошибки, финальную длительность; то же делает дашборд с фильтром по полю. Длительность из финальной записи собирается в распределение latency, и медленные вызовы находятся до того, как их найдёт пользователь. Второй потребитель — поддержка: идентификатор возвращается в заголовке ответа и в тексте ошибки — «внутренняя ошибка, код обращения ab12c3d4» — и пользователь приносит этот код первой строкой обращения. Третий — безопасность: сам по себе идентификатор не персональные данные, но записи рядом с ним живут user_id и адреса, и правила девятнадцатого урока про секреты и ПДн на них распространяются целиком. Так одно короткое поле работает трижды: склеивает лог, кормит метрики и заменяет час расспросов пользователя. Побочный эффект склейки — дисциплина в самих записях: когда история вызова читается целиком, пустые и бесполезные строки видны сразу, и их убирают — связка с антипаттернами двадцать третьего урока прямая.
Путь request id по сервису#
| Этап | Что происходит | Кто отвечает |
|---|---|---|
| вход | чтение X-Request-ID или генерация из crypto/rand | middleware |
| прокидка | base.With(request_id) и context.WithValue под typed-ключом | middleware |
| слои | slog.FromContext(ctx) и записи без упоминания поля | обработчик, сервис, репозиторий |
| фоновые задачи | context.WithoutCancel плюс потомок с task_id | код, запускающий задачу |
| ответ | заголовок X-Request-ID и код обращения в ошибке | middleware и обработчик |
| разбор | фильтр по request_id склеивает историю вызова | дежурный и поддержка |
Граница с трейсингом заслуживает отдельного абзаца. Request_id — локальная склейка: он живёт внутри вашего сервиса и его коллектора, и этого хватает, пока сервис один или их два. Trace_id из трейсера глобальнее: он связывает десятки сервисов, спаны и очереди, и его прокидка через заголовки — работа инструментации, а не вашего middleware. Практический порядок такой: request_id ставят с первого дня, потому что он бесплатен; когда система растёт до связки сервисов, trace_id приходит из трейсера и по совместительству вытесняет request_id из части дашбордов — двадцать второй урок разбирает эту связку. Переезд делается без драм: оба поля могут сосуществовать в одной записи, каждое отвечает своему фильтру. Имена полей в любом случае фиксируются заранее: коллектор семнадцатого урока ждёт request_id и user_id ровно с такими именами, и переименование поля потом превращается в миграцию схем.
crypto/rand.Read в восемь байт и hex.EncodeToString дают шестнадцать символов без внешних пакетов — этого хватает для одного сервиса с запасом. Шестнадцать байт дают тридцать два символа в духе UUID и удобны, когда идентификатор уезжает между системами. Метка времени в резерве защищает от отказа источника случайности: middleware без идентификатора хуже, чем middleware с редким дубликатом.
Пакета log/slog и crypto/rand в песочнице нет — работают fmt, strings, strconv, math, sort, errors и time. Форматирование идентификатора честно разминают на math и fmt: соберите функцию, которая из числа строит hex-строку фиксированной длины через strconv и дописывает нулями, и проверьте её на граничных значениях. Полная middleware с crypto/rand запускается локально через go run; интерактивы ниже проверят источник идентификатора, доставание логгера в слое и сборку цепочки.
Правила request id#
- Заголовок X-Request-ID от балансира переиспользуют: история склеивается сквозь инфраструктуру.
- Свой идентификатор — crypto/rand плюс hex: без внешних зависимостей и без метки времени в роли основы.
- Поле request_id добавляет ровно middleware: повторный With даёт дубли в каждой записи.
- Слои достают логгер через slog.FromContext: без middleware вернётся дефолтный, и это не поломка.
- Фоновая работа уходит с context.WithoutCancel и своим task_id, а не с потерянным Background.
- Идентификатор отдаётся в ответе и в тексте ошибки: код обращения экономит час расспросов.
Связи с другими уроками: восьмой урок про With показывает механику потомков и ловушку дублей, семнадцатый — схему полей для Loki и ELK, девятнадцатый — границы того, что пишут рядом с пользователем. Связку с трейсером разбирает двадцать второй урок, тесты middleware с захватом вывода — двадцатый. Со стороны фреймворков ту же задачу решают middleware gin, а механику контекста раскладывает урок про context курса по продвинутому Go. Карта курса — на странице курса.
Проверка: внешний заголовок
Балансир присылает заголовок X-Request-ID. Что с ним делает правильная middleware?
Проверка: фоновая горутина
Обработчик запустил go func с context.Background() внутри. Что окажется в записях этой горутины?
Какие поля в записи обработчика?
Прочитайте код и выберите, что он выведет:
// middleware положил логгер с полем request_id в контекст
func handler(w http.ResponseWriter, r *http.Request) {
logger := slog.FromContext(r.Context())
logger.Info("заказ принят", "order_id", "o-17")
}Соберите middleware и слой
Допишите: чтение заголовка, заполнение буфера, укладку логгера в контекст и достаёт-в-слое.
Middleware по строкам
Расставьте цепочку по порядку: чтение заголовка, генерация, логгер, контекст, ответ, передача дальше.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: request id
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Идентификатор приходит в заголовке балансира или рождается из crypto/rand — и в обоих случаях склеивает историю.
- Middleware добавляет request_id один раз: потомок с полями едет в контексте под типом-ключом.
- Слои достают логгер slog.FromContext: записи несут запрос без единого упоминания поля в коде.
- Фоновая работа уходит с context.WithoutCancel и task_id, иначе записи теряют request_id.
- Код обращения в ответе и в ошибке даёт поддержке прямой ключ к истории вызова.
- Request_id закрывает локальную склейку; между сервисами работает trace_id из трейсера.
Домашнее задание
Соберите локально middleware из урока и повесьте на два маршрута: проверьте, что записи одного вызова склеиваются фильтром по request_id, а заголовок ответа содержит идентификатор. Затем добавьте аутентификационную middleware с полем user_id поверх request-id и убедитесь, что обе записи несут оба поля без дублей.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли доверять заголовку X-Request-ID от клиента?
Как корреляции — да, как решению — нет: клиент может подделать или повторить чужой идентификатор. Переиспользование склеивает историю через инфраструктуру, а безопасность решают другие механизмы; длину и формат входного значения всё же проверяют.
Чем request_id отличается от trace_id?
Request_id — локальный идентификатор вашего сервиса: он склеивает записи вызова в одном коллекторе. Trace_id приходит из трейсера и связывает вызов между десятками сервисов и спанами. Практика: request_id с первого дня, trace_id — когда сервисы связываются в цепочку.
Как не потерять request_id в фоновых задачах?
Уходите из запроса с context.WithoutCancel — значения контекста, включая логгер, сохраняются при снятой отмене. Задаче с собственным жизненным циклом добавляют task_id через With, и в логе видны и породивший запрос, и судьба задачи.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Куда пишут логи: stdout, файл и ротация
- Логи для сбора: поля для Loki и ELK
- Уровни по окружению: debug в dev, info в прод
- Секреты в логах: токены, пароли и ПДн
- Производительность: Enabled и горячий путь
- Конкурентность: один логгер на сервис
- Свой Handler: интерфейс, Record и WithAttrs
- Ошибки в логах: err как атрибут, wrap и детали
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY