Проект — это не «большой пример», а набор решений, каждое из которых курс разбирал по отдельности. Уровень из окружения с безопасным дефолтом, JSON-записи в stdout, request ID и поля запроса в middleware, err в записях ошибок, маскирование секретов, тесты логов и сбор платформой — по отдельности это уроки, вместе — каркас сервиса. В этом уроке пройдём проект как ревью: карта модуля, конструктор логгера, middleware с уровнями по статусу, конвенция записей по слоям, приёмка тестами и честное спорное — обёртка над slog или логгер напрямую. Код приводится фрагментами, которые связывают решения; полный модуль собирается в репозитории проекта и прогоняется локально, потому что HTTP-сервер песочнице не по силам.
Slog проект: карта модуля и решений#
Модуль логирования в сервисе состоит из трёх файлов и не растёт дальше без причины. Конструктор NewLogger собирает уровень из окружения, обработчик и дефолт — единственное место, где появляется slog.NewJSONHandler. Middleware ставит поля запроса и пишет итоговую запись. Вспомогательные функции — parseLevel, levelByStatus, newRequestID — чистые и потому тестируются без HTTP. Всё остальное — конвенции, а не код: какой уровень у бизнес-отказа, где живут err и kind, что попадает в лейблы метрик, а что в записи. Карта решений ниже связывает каждое из них с уроком курса: при разборе инцидента или онбординге нового разработчика ссылка «почему так» важнее строк кода. Из работ с логами в проекте сознательно нет файла: логи едут в stdout, и это решение шестнадцатого урока, — ротация и место на диске остаются платформе.
Конструктор: уровень, обработчик, дефолт
GOfunc 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: поля запроса и уровень по статусу
GOfunc 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-сервис песочница не запустит — там нет ни 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?
Проверка: уровень для 404
Какой уровень вернёт levelByStatus для запроса, завершившегося 404?
Что напечатает таблица уровней?
Прочитайте код и выберите, что он выведет:
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))Соберите конструктор
Допишите: держатель уровня, безопасный дефолт и тип обработчика.
Сборка сервиса по строкам
Расставьте по порядку: уровень, обработчик, дефолтный логгер, маршрутизатор, middleware на ручке, запуск сервера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: итоговый проект
Проект собран, и каждое его решение ведёт в урок: уровень из окружения — в восемнадцатом, формат — в пятом, With-копии и поля запроса — в восьмом и пятнадцатом, ошибки — в одиннадцатом, маскирование — в девятнадцатом, тесты — в двадцатом, связка с метриками и трейсингом — в двадцать первом и двадцать втором, сбор — в двадцать четвёртом. Middleware в целом — в курсе chi и собственных middleware Fiber; уровень из конфига — в курсе Viper; метрики и алерты — в курсе Prometheus. Карта курса — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Проект — набор решений с отсылками к урокам: уровень, формат, поля, ошибки, секреты, приёмка.
- 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, чтобы тест проверял то, что увидит прод.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY