---
title: "Итоговый проект: логгер для HTTP-сервиса"
description: "Slog проект: логгер для сервиса go с конфигом уровня из окружения, middleware с полями запроса, маскированием секретов, тестами логов и сбором в stdout."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: логгер для HTTP-сервиса

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

Итоговый slog проект собирает курс в один модуль: логгер для сервиса go с конфигом уровня, middleware с полями запроса, честными уровнями, масками секретов и приёмкой тестами. Ниже — карта решений с отсылками к урокам, ключевой код конструктора и middleware, разбор спорных мест и чек-лист приёмки. Это тот каркас, который можно перенести в сервис за вечер и потом не переделывать.

Проект — это не «большой пример», а набор решений, каждое из которых курс разбирал по отдельности. Уровень из окружения с безопасным дефолтом, JSON-записи в stdout, request ID и поля запроса в middleware, err в записях ошибок, маскирование секретов, тесты логов и сбор платформой — по отдельности это уроки, вместе — каркас сервиса. В этом уроке пройдём проект как ревью: карта модуля, конструктор логгера, middleware с уровнями по статусу, конвенция записей по слоям, приёмка тестами и честное спорное — обёртка над slog или логгер напрямую. Код приводится фрагментами, которые связывают решения; полный модуль собирается в репозитории проекта и прогоняется локально, потому что HTTP-сервер песочнице не по силам.

### Slog проект: карта модуля и решений

Модуль логирования в сервисе состоит из трёх файлов и не растёт дальше без причины. Конструктор NewLogger собирает уровень из окружения, обработчик и дефолт — единственное место, где появляется slog.NewJSONHandler. Middleware ставит поля запроса и пишет итоговую запись. Вспомогательные функции — parseLevel, levelByStatus, newRequestID — чистые и потому тестируются без HTTP. Всё остальное — конвенции, а не код: какой уровень у бизнес-отказа, где живут err и kind, что попадает в лейблы метрик, а что в записи. Карта решений ниже связывает каждое из них с уроком курса: при разборе инцидента или онбординге нового разработчика ссылка «почему так» важнее строк кода. Из работ с логами в проекте сознательно нет файла: логи едут в stdout, и это решение шестнадцатого урока, — ротация и место на диске остаются платформе.

### Конструктор: уровень, обработчик, дефолт

```go
func NewLogger() (*slog.Logger, *slog.LevelVar, error) {
        var level slog.LevelVar
        if err := level.UnmarshalText([]byte(os.Getenv("LOG_LEVEL"))); err != nil {
                level.Set(slog.LevelInfo)
        }

        logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
                Level: &level,
        }))
        slog.SetDefault(logger)
        return logger, &level, nil
}
```

Один конструктор на сервис: UnmarshalText разбирает LOG_LEVEL, пустая или битая переменная оставляет безопасный дефолт Info — механика восемнадцатого урока. LevelVar возвращается наружу: это точка динамической смены уровня на инциденте. SetDefault делает видимыми пакетные вызовы и мост легаси-пакета log из десятого урока. Если в сервисе есть Viper, уровень становится ключом конфига с тем же UnmarshalText — приоритеты источников разбирает курс по Viper.

### Поля запроса: почему именно эти

Набор полей middleware — предмет споров в каждом втором проекте, поэтому короткая расшифровка. request_id — ключ склейки записей запроса, из пятнадцатого урока; при подключении трейсера рядом появляется trace_id из двадцать второго, middleware не меняется — добавляется один With. route — путь шаблона, а не фактический URL: иначе значения параметров раздувают кардинальность поля, о чём говорили семнадцатый и двадцать первый уроки. status и duration_ms — пара, по которой строится и поиск медленных запросов, и график; уровень итоговой записи выбирает дежурный, и levelByStatus — его семантика в коде. Логгер кладётся в контекст через withLogger: глубинные функции пишут записи с полями запроса, не зная про middleware. Конструктор при этом не паникует на битой переменной уровня — Warn-запись и дефолт: старт сервиса важнее, а молчать об опечатке нельзя. Честная граница: паника в обработчике гасит итоговую запись, поэтому recovery ставится выше middleware логов — об этом ниже.

### Middleware: поля запроса и уровень по статусу

```go
func Logging(base *slog.Logger) func(http.Handler) http.Handler {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        start := time.Now()
                        logger := base.With("request_id", newRequestID(r), "route", r.URL.Path)

                        sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
                        next.ServeHTTP(sw, r.WithContext(withLogger(r.Context(), logger)))

                        logger.Log(r.Context(), levelByStatus(sw.status),
                                "запрос обработан",
                                "status", sw.status,
                                "duration_ms", time.Since(start).Milliseconds(),
                        )
                })
        }
}
```

Поля ставятся один раз через With: request_id берётся из заголовка или генерируется, route — путь без значений параметров. statusWriter ловит код ответа, итоговая запись выходит через Log с уровнем по статусу: 5xx — Error, 4xx — Warn, остальное — Info. duration_ms — целые миллисекунды: с парным histogram это та же пара, что в двадцать первом уроке.

> **Паника гасит итоговую запись.** Кейс: nil в шаблоне ответа, обработчик запаниковал, и строки «запрос обработан» не случилось: next.ServeHTTP завершился паникой, а вызов logger.Log после него не выполняется. Конвейер при этом получил стек паники — и разъехал его на отдельные записи, о чём предупреждал семнадцатый урок. Разбор инцидента упёрся в пустоту: запроса в логах нет, стек есть, связи между ними нет. Фикс: recovery-middleware стоит выше Logging, ловит панику, отвечает 500 и пишет Error со стеком одним полем — механика recover из курса по продвинутому Go. После этого итоговая запись логов не гарантирована только при панике, а паника сама становится записью. Проверка приёмкой: тест, который паникует внутри обработчика, обязан увидеть и 500, и запись Error.

### Записи сервиса: конвенция по слоям

Middleware отвечает за периметр, а записи внутри сервиса держит конвенция, которую курс собирал по урокам. Ошибки хранилища и внешних вызовов пишутся на Error с err в поле — оборачивание через %w, детали из одиннадцатого урока. Бизнес-отказы — на Warn с kind из классификации: заказ не оплачен из-за нехватки средств — это не сбой сервиса, и Error тут был бы спамом уровней из двадцать третьего урока. События — на Info с полями, по которым ищут: order, user_id, kind. Отладочные детали — на Debug, и прод-уровень их отсекает. Маскирование подключается в конструкторе: ReplaceAttr с картой чувствительных ключей и тип Token с LogValue из девятнадцатого урока — тест на маску в проекте обязательный, потому что секреты уезжают в хранилище молча. Метрики ошибок инкрементятся рядом с записью — пара из двадцать первого урока, — и лейблы держат только малые наборы.

### Приёмка: тесты и чек-лист

Проект считается готовым, когда логи проверяются тестами из двадцатого урока. Первый тест — маскирование: фикстура с токеном проходит через логгер, в буфере маска, а не значение. Второй — поля запроса: после запроса в буфере есть request_id, route, status, duration_ms, и уровень итоговой записи соответствует статусу. Третий — отсутствие записей Error в штатном сценарии: успешный запрос не пишет ошибок, и регресс ловится в CI. Четвёртый — уровень: таблица статусов против levelByStatus. Сбор проверяется руками: сервис под journald или в контейнере, journalctl -u и docker logs показывают те же записи, что и буфер теста — это связка двадцать четвёртого урока. Приёмка завершается чек-листом ниже: он короткий, и каждый пункт из него когда-то был инцидентом в чужом проекте.

### Спорное: slog напрямую или обёртка сервиса

Мнение, которое стоит оспорить на старте проекта: логирование надо спрятать за своим интерфейсом, чтобы когда-нибудь сменить логгер. За: интерфейс Logger с методами Info и Error ограничивает поверхность и упрощает моки в тестах. Против: slog — стандартная библиотека, и смена на что-то другое — гипотетическая цена; обёртка теряет механику: Enabled до сборки полей из четырнадцатого урока, LogValuer, группами и With-копиями из восьмого. Практика обёрток тоже честно печальна: в каждом втором проекте своя обёртка, и каждая со своими особенностями. Компромисс проекта: slog.Logger живёт в коде напрямую, без посредников; обёртка уместна на одной границе — доменный фасад, который публикует бизнес-события, и под ней уже slog. Граница по тестам: тесты логов пишутся на Handler и буфер, а не на мок интерфейса — буфер проверяет то же, что увидит прод.

### Реестр решений проекта

| Решение | Выбор | Урок |
| --- | --- | --- |
| Уровень | LOG_LEVEL и LevelVar, дефолт Info | восемнадцатый |
| Формат и место | JSONHandler в stdout, без файлов | пятый и шестнадцатый |
| Сквозные поля | request_id в middleware, trace_id при трейсере | пятнадцатый и двадцать второй |
| Ошибки | err в записи, kind в метрике | одиннадцатый и двадцать первый |
| Секреты | ReplaceAttr и LogValuer, тест на маску | девятнадцатый |
| Приёмка | тесты логов и чек-лист | двадцатый |

> **Что подключать после проекта.** Каркас закрыт, и расширения не меняют его структуру. Метрики ошибок — счётчики рядом с записями и экспозиция /metrics из курса по Prometheus, алерты по rate, а не по записям. Трейсинг — SDK OpenTelemetry из двадцать второго урока: middleware дополучит trace_id, код сервиса не изменится. Сбор — агент на ноде из двадцать четвёртого, поля уже готовы к хранилищу. Дедупликация спама — агрегатор из двадцать первого. Каждое расширение — один новый middleware или пакет, и ни одно не переписывает конструктор.

### Чек-лист приёмки логгера

- Без LOG_LEVEL сервис стартует на Info и пишет JSON в stdout.
- Каждая запись запроса несёт request_id, route, status и duration_ms.
- Уровень итоговой записи: 5xx — Error, 4xx — Warn, остальное — Info.
- Тест на маску проходит: токен не доезжает до буфера и хранилища.
- Штатный сценарий не пишет Error: тест в CI это проверяет.
- Recovery стоит выше Logging: паника даёт 500 и запись Error со стеком.

> **Песочница: фрагменты проекта без HTTP.** Честно: многокомпонентный HTTP-сервис песочница не запустит — там нет ни slog, ни os, ни net/http; полный проект собирается локально через go run. В песочнице работают fmt, strings, strconv, math, sort, errors и time, и на них разминаются фрагменты честно: parseLevel как функция от строки через strings.ToUpper со switch, levelByStatus как int-функция с печатью результата, newRequestID из времени и счётчика через strconv.Itoa. Итоговые записи — fmt.Fprintf в strings.Builder: проверка полей той же механикой, что и тесты логов.

### Проверка: где собирать логгер

**Вопрос:** Где в HTTP-сервисе правильное место для slog.NewJSONHandler?

- ✅ В одном конструкторе на старте: middleware и код получают готовый логгер
- В каждом обработчике: каждый собирает свой логгер под себя
- В middleware на каждый запрос: конфигурация едет вместе с полем
- Только в глобальной переменной: SetDefault сам всё соберёт

Конструктор — единственное место сборки: один обработчик, один источник уровня, один SetDefault. Логгеры на каждый запрос — это With-копии от base, а не новые обработчики; глобальная переменная без конструктора теряет контроль над уровнем и дефолтами.

### Проверка: уровень для 404

**Вопрос:** Какой уровень вернёт levelByStatus для запроса, завершившегося 404?

- ✅ Warn: запрошенного ресурса нет — обработано с отклонением
- Error: клиент получил ошибку, значит нужен человек
- Info: обычный ответ сервиса
- Debug: статус не влияет на уровень итоговой записи

404 — бизнес-отказ, а не сбой сервиса: код отработал, ресурса нет. Error для этого стал бы спамом уровней из двадцать третьего урока, Info прячет отклонение. 5xx — Error, 4xx — Warn, остальное — Info.

### Что напечатает таблица уровней?

```go
func levelByStatus(status int) slog.Level {
        if status >= 500 {
                return slog.LevelError
        }
        if status >= 400 {
                return slog.LevelWarn
        }
        return slog.LevelInfo
}

fmt.Println(levelByStatus(404), levelByStatus(500), levelByStatus(200))
```

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

- ✅ WARN ERROR INFO: 404 — Warn, 500 — Error, 200 — Info
- ERROR WARN INFO: чем выше статус, тем выше уровень
- INFO INFO INFO: уровень не зависит от статуса
- Ошибка: slog.Level нельзя печатать напрямую

Level печатается строкой: WARN, ERROR, INFO. Пороги идут сверху вниз: пятьсот и выше — Error, четыреста и выше — Warn, всё остальное — Info. Тот же табличный тест лежит в основе приёмки уровней.

### Соберите конструктор

Допишите: держатель уровня, безопасный дефолт и тип обработчика.

```go
func NewLogger() *slog.Logger {
        var level slog.___
        level.___(slog.LevelInfo)

        return slog.New(slog.New___Handler(os.Stdout, &slog.HandlerOptions{Level: &level}))
}
```

Подсказка: Уровень хранит LevelVar, дефолт ставит Set, записи в stdout пишет NewJSONHandler.

Правильные ответы в пропусках: `LevelVar`, `Set`, `JSON`

### Сборка сервиса по строкам

Расставьте по порядку: уровень, обработчик, дефолтный логгер, маршрутизатор, middleware на ручке, запуск сервера.

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

1. `level := parseLevel(os.Getenv("LOG_LEVEL"))`
2. `logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))`
3. `slog.SetDefault(logger)`
4. `mux := http.NewServeMux()`
5. `mux.Handle("/checkout", Logging(logger)(http.HandlerFunc(checkout)))`
6. `http.ListenAndServe(":8080", mux)`

### Карточки: итоговый проект

- **NewLogger** — Единственный конструктор: уровень из LOG_LEVEL через UnmarshalText, JSONHandler в stdout, SetDefault. LevelVar возвращается наружу.
- **LevelVar наружу** — Точка динамической смены уровня: админ-точка на инциденте меняет минимум без деплоя.
- **statusWriter** — Обёртка ResponseWriter, ловящая код ответа: итоговая запись знает статус без изменения обработчиков.
- **levelByStatus** — 5xx — Error, 4xx — Warn, остальное — Info: семантика уровня для дежурного в одной функции.
- **recovery выше логов** — Паника гасит итоговую запись: recovery ловит её, отвечает 500 и пишет Error со стеком одним полем.
- **приёмка** — Тест маскирования, тест полей запроса, отсутствие Error в штатном сценарии и journalctl или docker logs на месте.

Проект собран, и каждое его решение ведёт в урок: уровень из окружения — в [восемнадцатом](/courses/slog/lessons/18-urovni-po-env/), формат — в [пятом](/courses/slog/lessons/05-text-vs-json/), With-копии и поля запроса — в [восьмом](/courses/slog/lessons/08-with-predlog/) и [пятнадцатом](/courses/slog/lessons/15-request-id/), ошибки — в [одиннадцатом](/courses/slog/lessons/11-oshibki-v-logah/), маскирование — в [девятнадцатом](/courses/slog/lessons/19-sekrety-v-logah/), тесты — в [двадцатом](/courses/slog/lessons/20-testy-logov/), связка с метриками и трейсингом — в [двадцать первом](/courses/slog/lessons/21-logi-i-metriki/) и [двадцать втором](/courses/slog/lessons/22-logi-i-trejsy/), сбор — в [двадцать четвёртом](/courses/slog/lessons/24-logi-na-prode/). Middleware в целом — в [курсе chi](/courses/chi/lessons/05-middleware/) и [собственных middleware Fiber](/courses/fiber/lessons/07-svoi-middleware/); уровень из конфига — в [курсе Viper](/courses/viper/); метрики и алерты — в [курсе Prometheus](/courses/prometheus/). Карта курса — на [странице курса](/courses/slog/).

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

- Проект — набор решений с отсылками к урокам: уровень, формат, поля, ошибки, секреты, приёмка.
- NewLogger — единственный конструктор: LOG_LEVEL, дефолт Info, JSONHandler в stdout, LevelVar наружу.
- Middleware ставит поля один раз: request_id и route через With, statusWriter ловит статус, итоговая запись — с duration_ms.
- levelByStatus: 5xx — Error, 4xx — Warn, остальное — Info; уровень выбирает дежурный, код лишь выражает семантику.
- Recovery выше Logging: паника даёт 500 и запись Error со стеком, итоговая запись при панике не теряется молча.
- Приёмка — тестами логов: маска, поля запроса, отсутствие Error в штатном сценарии, локальный docker logs.

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

Соберите модуль проекта в своём сервисе: NewLogger с LOG_LEVEL и дефолтом Info, middleware с request_id, route, status и duration_ms, levelByStatus. Прогоните приёмку: тест маскирования с токеном, тест полей запроса по буферу, тест отсутствия Error в штатном сценарии. Затем запустите сервис в контейнере с log-opts ротации, найдите свои записи через docker logs и сверьте поля с тем, что печатал буфер теста.

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

### Нужна ли своя обёртка над slog?

Для сервиса — обычно нет: slog — стандартная библиотека, и обёртка теряет Enabled до сборки полей, LogValuer и With-копии. Обёртка уместна на одной границе — доменный фасад бизнес-событий — и под ней всё равно slog. Смена логгера в Go-сервисе — гипотетический сценарий: контракт slog де-факто общий для библиотек.

### Почему route — шаблон пути, а не фактический URL?

Фактический URL несёт значения параметров: /orders/a-42, /orders/b-77 — и поле разрастается до кардинальности заказов, ломая группировки и поиск. Шаблон /orders/{id} держит малое число значений — число маршрутов. Значения параметров при этом живут в полях события: order, user_id — там им место.

### Где инициализировать логгер в тестах?

Тесты не используют конструктор сервиса: они создают свой логгер через slog.New с буфером из двадцатого урока. Конструктор NewLogger — это старт сервиса и окружение; тестовый логгер — контролируемый буфер с фиксированным уровнем. Общее между ними — HandlerOptions, чтобы тест проверял то, что увидит прод.
