---
title: "Сквозные поля: request ID через слои"
description: "Request id в логах go: генерация или заголовок балансира, middleware и slog.FromContext, фоновые задачи и склейка строк — логирование http go."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/15-request-id/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Сквозные поля: request ID через слои

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

Тридцать записей одного HTTP-вызова собираются одним фильтром — за это отвечают request id логи go: идентификатор рождается в middleware, живёт в контексте и приклеивается к каждой записи через With. Разберём оба источника идентификатора, прокидку через слои, фоновые задачи, которые теряют поля, и работу поддержки с кодом обращения.

Один вызов API оставляет тридцать записей: обработчик, сервис, репозиторий, кэш, внешние вызовы, финальная строка с длительностью. Без общего поля эти записи размазаны по потоку вперемешку с соседними вызовами, и разбор инцидента превращается в сопоставление по времени — занятие, которое при нагрузке в тысячу запросов в секунду никуда не годится. Механику логгера на запрос — With и контекст — показал восьмой урок; этот урок собирает полную картину: откуда идентификатор приходит, в каком формате его генерировать без зависимостей, как слои достают логгер из контекста, что происходит с полями в фоновых задачах и как поддержка находит запрос по коду обращения, который увидел пользователь. Отдельный сюжет — связь с трейсингом: request_id закрывает вопросы внутри сервиса, trace_id связывает сервисы между собой, и между ними есть ясная граница. К концу урока middleware, слои и фоновые задачи складываются в одну цепочку, где поле добавляется один раз и работает трижды: в логе, в метриках и в разговоре с пользователем.

### Request id логи go: откуда берётся идентификатор

У идентификатора два источника, и разбор request id логи go начинается с первого: заголовок X-Request-ID, который уже проставил балансир или шлюз. Его переиспользуют: инфраструктура до вашего сервиса уже собрала историю под этим номером, и переиспользование склеивает её со строками приложения — одна сквозная цепочка от входа в кластер до строки в репозитории. Заголовок подделываем, и это нормально: он не авторизация, а корреляция, доверять ему решениями не нужно. Если заголовок пуст — идентификатор генерирует middleware: восемь-шестнадцать случайных байт из crypto/rand, закодированные в hex или собранные в UUID-подобный формат без внешних зависимостей. Третье место, где идентификатор живёт, — ответ: middleware отдаёт его в заголовке ответа и в тексте ошибок, и пользователь с кодом обращения в руках даёт поддержке прямой ключ к своей истории.

### Middleware: источник, контекст, ответ

```go
type 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 добавляет своё поле и передаёт дальше, а вызов записи в глубине слоёв получает всё сразу.

### Слои достают логгер из контекста

```go
func (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 — цепочка переживает переезд из запроса в фоновую работу.

> **Письмо улетело с Background: история без request_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](/courses/slog/lessons/08-with-predlog/) показывает механику потомков и ловушку дублей, [семнадцатый](/courses/slog/lessons/17-logi-dlya-sbora/) — схему полей для Loki и ELK, [девятнадцатый](/courses/slog/lessons/19-sekrety-v-logah/) — границы того, что пишут рядом с пользователем. Связку с трейсером разбирает [двадцать второй урок](/courses/slog/lessons/22-logi-i-trejsy/), тесты middleware с захватом вывода — [двадцатый](/courses/slog/lessons/20-testy-logov/). Со стороны фреймворков ту же задачу решают [middleware gin](/courses/gin/lessons/06-middleware/), а механику контекста раскладывает [урок про context](/courses/go-advanced/lessons/09-context/) курса по продвинутому Go. Карта курса — на [странице курса](/courses/slog/).

### Проверка: внешний заголовок

**Вопрос:** Балансир присылает заголовок X-Request-ID. Что с ним делает правильная middleware?

- ✅ Переиспользует его: идентификатор склеит историю по всей инфраструктуре
- Игнорирует и генерирует свой: чужим заголовкам доверять нельзя
- Логирует и генерирует второй: два поля рядом
- Отклоняет запрос: маршруты принимают только свой формат

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

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

**Вопрос:** Обработчик запустил go func с context.Background() внутри. Что окажется в записях этой горутины?

- ✅ Записи без request_id: поля остались в контексте запроса
- request_id на месте: он в глобальном логгере сервиса
- Записей не будет: логгер потерялся вместе с контекстом
- Паника: из другой горутины писать нельзя

Логгер с request_id живёт в контексте запроса; уехав с Background, горутина его не видит и пишет дефолтным логгером. Лечение: context.WithoutCancel, который сохраняет значения, или копия полей через With.

### Какие поля в записи обработчика?

```go
// middleware положил логгер с полем request_id в контекст
func handler(w http.ResponseWriter, r *http.Request) {
        logger := slog.FromContext(r.Context())
        logger.Info("заказ принят", "order_id", "o-17")
}
```

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

- ✅ request_id и order_id: FromContext вернул потомка middleware
- Только order_id: request_id добавляет сам обработчик
- Только request_id: order_id потерялся в контексте
- Ни одного: контекст не доходит до обработчика

FromContext достаёт логгер, положенный middleware, вместе с предзаданными полями, а аргументы вызова идут после них: request_id, затем order_id.

### Соберите middleware и слой

Допишите: чтение заголовка, заполнение буфера, укладку логгера в контекст и достаёт-в-слое.

```go
id := r.___.Get("X-Request-ID")
if id == "" {
        b := make([]byte, 8)
        rand.___(b)
        id = hex.EncodeToString(b)
}
logger := base.With("request_id", id)
ctx := context.___(r.Context(), loggerKey{}, logger)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))

// в сервисном слое:
l := slog.___(ctx)
l.Info("заказ принят", "order_id", orderID)
```

Подсказка: Заголовок читает r.Header, буфер наполняет rand.Read, в контекст кладёт WithValue, а достаёт логгер FromContext.

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

### Middleware по строкам

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

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

1. `id := r.Header.Get("X-Request-ID")`
2. `if id == "" { id = newRequestID() }`
3. `logger := base.With("request_id", id)`
4. `ctx := context.WithValue(r.Context(), loggerKey{}, logger)`
5. `w.Header().Set("X-Request-ID", id)`
6. `next.ServeHTTP(w, r.WithContext(ctx))`

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

- **два источника ID** — Заголовок X-Request-ID от балансира переиспользуют; без него — crypto/rand и hex без зависимостей.
- **логгер на запрос** — base.With("request_id", id) в middleware, в контекст под typed-ключом, слои достают FromContext.
- **правило одного владельца** — request_id добавляет ровно middleware: повторный With даёт дубли пар в записи.
- **фоновая задача** — context.WithoutCancel сохраняет значения без отмены; задача получает свой task_id через With.
- **код обращения** — ID возвращают в заголовке ответа и тексте ошибки: поддержка находит запрос по одному значению.
- **request_id и trace_id** — Локальная склейка против глобальной из трейсера; вместе дают сквозную историю по сервисам.

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

- Идентификатор приходит в заголовке балансира или рождается из 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, и в логе видны и породивший запрос, и судьба задачи.
