feat(observability): configurable logging, request ids and error logs

- internal/logging: LOG_LEVEL/LOG_FORMAT parsing and slog handler
- request_id middleware (X-Request-ID or generated, echoed) + debug access log
- httpx registers errors; middleware logs internal causes with request_id
- health excluded, objects/sse debug-only, no query/body/secrets in logs
- new observability capability spec; README/.env.example docs
This commit is contained in:
iu
2026-10-11 13:40:11 +03:00
parent a5d0cee0eb
commit 6361e48b20
18 changed files with 998 additions and 5 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,112 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, важное для подхода:
- Логирование инициализируется в `backend/cmd/server/main.go:34` жёстко
(`slog.NewJSONHandler(os.Stdout, LevelInfo)`), без чтения конфига.
- Роутер — `gin.New()` + `gin.Recovery()` (`internal/app/app.go:94`); access-логов,
`request_id` и middleware корреляции нет. Есть только `auth.Middleware` на группе `/api`
(`app.go:122`).
- `httpx.RespondError`/`Abort` (`internal/httpx/httpx.go:55-71`) для не-`AppError`
отвечают generic `500` и не логируют причину.
- `slog` уже используется точечно (`internal/db/db.go`, `internal/photos/worker.go`),
версия Go 1.26, `gin v1.12`.
## Goals / Non-Goals
**Goals:**
- Управляемые уровень и формат логов без изменения кода.
- Один след на запрос: `request_id` во всех строках, порождённых этим запросом.
- Видимость причины внутренних ошибок в логах.
- Отсутствие утечки секретов (токены, пароли, query, тела, подписанные URL).
**Non-Goals:**
- Доменные (операционные) логи в сервисах (`album.created`, `photo.uploaded`) — слой C,
не входит.
- Метрики, трейсинг, экспорт во внешние системы.
- Изменение CLI-команд (`migrate`, `reconcile`, `backfill-exif`).
- Изменение формата ответов API.
## Decisions
### D1. Конфигурация уровня и формата
Новые env `LOG_LEVEL` (`debug|info|warn|error`, default `info`) и `LOG_FORMAT`
(`json|text`, default `json`). Значения валидируются в `config.Load`; недопустимое
значение — ошибка старта. `main.go` строит handler из конфига.
- Альтернатива: булев `GALO_DEBUG` — отклонено, слишком грубо (нет warn/error отдельно)
и не даёт формат.
- Новый пакет `internal/logging` инкапсулирует разбор уровня и сборку handler
(`NewHandler(format, level)`), чтобы не размазывать `slog`-детали по `config` и `main`.
### D2. Идентификатор запроса
Middleware (B) берёт `X-Request-ID` из входящего запроса, если он корректной формы
(ограничение длины и допустимые символы), иначе генерирует новый. `request_id`:
- кладётся в `context.Context` запроса (`logging.WithRequestID`);
- возвращается клиенту в заголовке ответа `X-Request-ID`.
Так связка «запрос клиента ↔ строка в логе» работает без доступа к внутреннему состоянию.
- Альтернатива: не принимать клиентский id и всегда генерировать — проще, но теряет
сквозную корреляцию с внешним прокси. Выбрано «принять валидный, иначе сгенерировать».
### D3. Логирование внутренних ошибок
`httpx.RespondError`/`Abort` перестают молча выдавать `500`: для ошибки, не являющейся
`*AppError`, они вызывают `c.Error(err)` (gin накапливает, не пишет ответ). Middleware (B)
после `c.Next()` записывает все собранные ошибки на уровне `error` с причиной и `request_id`.
- Классификация: `*AppError` со статусом `< 500` → `debug`; `*AppError` со статусом `>= 500`
и любая не-`AppError` → `error`.
- Альтернатива: логировать прямо в `httpx` через `slog.ErrorContext` — меньше правок, но
два независимых места логирования и дублирование `request_id`. Выбрана централизация в
middleware.
- `log/slog` не имеет контекстных атрибутов «из коробки»; `request_id` подставляется
явно из `logging.RequestID(ctx)`.
### D4. Access-лог
Один middleware в `app.Router()` до групп маршрутов; после `c.Next()` пишет строку
уровня `debug`: `method`, `route` (`c.FullPath()`, **без query**), `status`, `latency`,
`bytes`, `client_ip`, `user_id` (`auth.UserID`, если установлен), `request_id`.
- `user_id` доступен, т.к. middleware оборачивает `auth.Middleware` и читает контекст
после завершения цепочки.
### D5. Уровни (сводно)
| Событие | Уровень |
|---|---|
| Старт, бэкенды, миграции, storage ready (как сейчас) | info |
| Причина внутренней ошибки (не-`AppError`, 5xx) | error |
| Access-строка запроса | debug |
| Отказ 4xx (`AppError < 500`) | debug |
### D6. Фильтрация и приватность
- `/api/health` — всегда исключён из access-лога.
- `/api/objects/*` и `/api/sse/*` — access-логи только на `debug` (объём/долгие соединения).
- Никогда не логируются: query-строка целиком (там `?token=` для SSE), подписанные URL,
заголовок `Authorization`, тела запросов, пароли.
## Risks / Trade-offs
- [Рост объёма на `debug` от `/api/objects/*` и SSE] -> фильтры из D6; на `info`
объём не растёт (access/4xx — `debug`).
- [Спуфинг `X-Request-ID` клиентом] -> принимаем только валидную форму с ограничением
длины; иначе генерируем.
- [Ложная latency для SSE (весь срок соединения)] -> SSE-строки только на `debug`;
поле трактуется как длительность соединения.
- [Утечка секретов через логи] -> D6 запрещает query/body/Authorization; явные поля — только
route и метаданные.
- [Двойной ответ при `c.Error`] -> `c.Error` только регистрирует ошибку, ответ уже
отправлен `httpx`; middleware лишь читает `c.Errors`.
## Migration Plan
Изменение кода и конфигурации, без миграции данных. При отсутствии новых env поведение
соответствует `LOG_LEVEL=info`, `LOG_FORMAT=json` — обычные логи почти не меняются. Для
диагностики выставить `LOG_LEVEL=debug`. Откат — вернуть прежний жёсткий handler.
## Open Questions
Нет.
@@ -0,0 +1,40 @@
# Proposal
## Why
Диагностика инцидентов идёт вслепую: логов операций нет ни на каком уровне,
access-логов нет, а внутренние ошибки (не-`AppError`) отдаются клиенту как
generic `500` и **нигде не логируются** — таких мест 82. Уровень логов зашит
(`slog.LevelInfo`, `main.go:34`), переключателя нет. Нужны управляемая детализация,
след каждого запроса и регистрация причин внутренних ошибок.
## What Changes
- Конфигурация логирования: `LOG_LEVEL` (`debug|info|warn|error`, по умолчанию `info`)
и `LOG_FORMAT` (`json|text`, по умолчанию `json`); `main.go` строит `slog`-handler
из конфига вместо жёсткого `LevelInfo`.
- Gin-middleware: `request_id` (из заголовка `X-Request-ID` либо сгенерированный)
кладётся в контекст запроса; access-лог уровня `debug` с полями method, route,
status, latency, bytes, client_ip, user_id, request_id.
- Регистрация внутренних ошибок: не-`AppError` из `httpx.RespondError`/`Abort`
становятся регистрируемыми и пишутся на уровне `error` с `request_id` и причиной;
отказы 4xx — на уровне `debug`.
- Приватность: не логируются query-строка (`?token=`), подписанные URL и тела
запросов; `/api/health` исключён всегда, `/api/objects/*` и `/api/sse/*` — только `debug`.
- CLI-команды (`migrate`, `reconcile`, `backfill-exif`) не меняются.
## Capabilities
### New Capabilities
- `observability`: конфигурируемое структурированное логирование сервера — уровень и формат вывода, access-лог запросов с корреляцией и регистрация причин внутренних ошибок.
### Modified Capabilities
<!-- Нет: формат и поведение API для клиента не меняются. -->
## Impact
- `backend/internal/config` (секция `Log` + env), `backend/cmd/server/main.go` (сборка handler из конфига).
- `backend/internal/app` (middleware логирования), `backend/internal/httpx` (регистрация внутренних ошибок).
- `.env.example`, `README.md`.
- API, схема БД и хранилище не меняются. При дефолтном `LOG_LEVEL=info` объём обычных
логов почти не растёт: access-строки и 4xx остаются на уровне `debug`.
@@ -0,0 +1,102 @@
# Spec Delta
## Purpose
Конфигурируемое структурированное логирование сервера: уровень и формат вывода,
access-лог запросов с корреляцией по идентификатору и регистрация причин внутренних
ошибок.
## ADDED Requirements
### Requirement: Уровень и формат логов
Сервер SHALL настраивать уровень и формат логирования из переменных окружения `LOG_LEVEL`
(`debug|info|warn|error`) и `LOG_FORMAT` (`json|text`) со значениями по умолчанию `info`
и `json` соответственно.
#### Scenario: Подробный режим
- **WHEN** задан `LOG_LEVEL=debug`
- **THEN** выводятся подробные строки, включая access-строки и отказы 4xx
#### Scenario: Некорректное значение
- **WHEN** `LOG_LEVEL` или `LOG_FORMAT` содержит недопустимое значение
- **THEN** сервер не стартует и сообщает явную ошибку конфигурации
#### Scenario: Текстовый формат
- **WHEN** задан `LOG_FORMAT=text`
- **THEN** логи выводятся в текстовом формате
### Requirement: Идентификатор запроса
Сервер SHALL присваивать каждому HTTP-запросу идентификатор: принимать валидный
`X-Request-ID` либо генерировать новый, SHALL включать его во все логи этого запроса и
SHALL возвращать его в заголовке ответа `X-Request-ID`.
#### Scenario: Идентификатор передан клиентом
- **WHEN** запрос содержит валидный заголовок `X-Request-ID`
- **THEN** это значение используется как идентификатор запроса
#### Scenario: Идентификатор сгенерирован
- **WHEN** заголовок `X-Request-ID` отсутствует или невалиден
- **THEN** сервер генерирует новый идентификатор
#### Scenario: Эхо в ответе
- **WHEN** запрос обработан
- **THEN** ответ содержит заголовок `X-Request-ID` с использованным значением
### Requirement: Access-лог запроса
На уровне `debug` сервер SHALL писать одну строку на завершённый HTTP-запрос с методом,
шаблоном маршрута (без query-строки), статусом, длительностью, объёмом ответа, клиентским
IP, идентификатором пользователя (если аутентифицирован) и идентификатором запроса.
#### Scenario: Режим debug
- **WHEN** задан `LOG_LEVEL=debug`
- **THEN** после обработки запроса появляется access-строка
#### Scenario: Обычный режим
- **WHEN** задан `LOG_LEVEL=info`
- **THEN** access-строки не пишутся
#### Scenario: Без query-строки
- **WHEN** запрос содержит query-параметры
- **THEN** query-параметры в access-строку не попадают
### Requirement: Регистрация внутренних ошибок
Сервер SHALL логировать причину внутренних ошибок: ошибки, не являющиеся ожидаемыми
`AppError`, SHALL писаться на уровне `error` с исходной причиной и идентификатором
запроса; ожидаемые отказы со статусом ниже 500 SHALL писаться на уровне `debug`, а
со статусом 500 и выше — на уровне `error`.
#### Scenario: Внутренняя ошибка
- **WHEN** обработка запроса завершается неожиданной ошибкой
- **THEN** её причина логируется на уровне `error` вместе с идентификатором запроса
#### Scenario: Ожидаемый отказ
- **WHEN** запрос отклонён ожидаемой ошибкой со статусом 4xx
- **THEN** на уровне `info` причина не логируется, а на уровне `debug` — логируется
#### Scenario: Клиентское сообщение не подменяет причину
- **WHEN** клиент получает обобщённый ответ о внутренней ошибке
- **THEN** в логе сохраняется исходная причина
### Requirement: Фильтрация access-логов
Сервер SHALL всегда исключать маршрут `/api/health` из access-логов и SHALL писать
access-логи для маршрутов `/api/objects/*` и `/api/sse/*` только на уровне `debug`.
#### Scenario: Health исключён
- **WHEN** запрошен `/api/health`
- **THEN** access-строка не пишется даже на уровне `debug`
#### Scenario: Объекты и SSE
- **WHEN** запрошен `/api/objects/*` или `/api/sse/*` и задан `LOG_LEVEL=debug`
- **THEN** access-строка пишется
### Requirement: Приватность логов
Сервер SHALL не записывать в логи query-строку запроса, заголовок `Authorization`, тела
запросов, пароли и подписанные URL.
#### Scenario: Токен в query
- **WHEN** авторизация передана через query-параметр `token`
- **THEN** его значение не попадает в логи
#### Scenario: Тело запроса не логируется
- **WHEN** запрос содержит тело (JSON или multipart)
- **THEN** содержимое тела не попадает в логи
@@ -0,0 +1,24 @@
# Tasks
## 1. Конфигурация логирования (A)
- [x] 1.1 Добавить пакет `backend/internal/logging` с разбором уровня (`ParseLevel`) и сборкой handler (`NewHandler(format, level)`); проверка — `go test ./internal/logging` покрывает разбор `debug|info|warn|error`, `json|text` и ошибки.
- [x] 1.2 Добавить секцию `Log` в `config.Config` и чтение `LOG_LEVEL`/`LOG_FORMAT` с валидацией; проверка — `go test ./internal/config` покрывает значения по умолчанию и отказ на недопустимом значении.
- [x] 1.3 Строить `slog`-handler в `cmd/server/main.go` из `cfg.Log` вместо жёсткого `LevelInfo`; проверка — запуск с `LOG_LEVEL=debug`/`LOG_FORMAT=text` меняет вывод, а недопустимый `LOG_LEVEL` завершает старт явной ошибкой.
- [x] 1.4 Описать `LOG_LEVEL`/`LOG_FORMAT` в `.env.example` и `README.md`; проверка — задокументированный запуск с `LOG_LEVEL=debug` выводит подробные строки.
## 2. Идентификатор запроса и access-лог (B)
- [x] 2.1 Middleware в `app.Router()`: `request_id` из валидного `X-Request-ID` либо сгенерированный, запись в контекст запроса и заголовок ответа `X-Request-ID`; проверка — тест: переданный id сохраняется и возвращается, отсутствующий генерируется.
- [x] 2.2 Access-строка уровня `debug` (method, route `c.FullPath()`, status, latency, bytes, client_ip, user_id, request_id) без query; проверка — тест: на `debug` строка пишется, на `info` нет, query-параметры не попадают.
- [x] 2.3 Фильтрация access-логов: `/api/health` исключён всегда, `/api/objects/*` и `/api/sse/*` — только `debug`; проверка — тест на наборе маршрутов и уровне.
## 3. Регистрация внутренних ошибок (D)
- [x] 3.1 В `httpx.RespondError`/`Abort` регистрировать не-`AppError` через `c.Error(err)`; проверка — тест: неизвестная ошибка попадает в `c.Errors`, ответ остаётся обобщённым `500`.
- [x] 3.2 Логировать собранные ошибки в middleware: не-`AppError` и `AppError` со статусом `>=500` — уровень `error` с причиной и request_id, `AppError <500` — уровень `debug`; проверка — тест с буфером лога на обе ветки.
- [x] 3.3 Гарантировать приватность: не логировать query-строку, `Authorization` и тела запросов; проверка — тест с `?token=...` и телом убеждается, что значения не встречаются в буфере лога.
## 4. Интеграционная проверка
- [x] 4.1 Прогнать локальный инстанс с `LOG_LEVEL=debug`, выполнить несколько запросов (включая аутентифицированный и `/api/health`) и убедиться: access-строки есть, `X-Request-ID` возвращается в ответе, `/api/health` не логируется, секреты в логах отсутствуют.
+102
View File
@@ -0,0 +1,102 @@
# observability Specification
## Purpose
Конфигурируемое структурированное логирование сервера: уровень и формат вывода,
access-лог запросов с корреляцией по идентификатору и регистрация причин внутренних
ошибок.
## Requirements
### Requirement: Уровень и формат логов
Сервер SHALL настраивать уровень и формат логирования из переменных окружения `LOG_LEVEL`
(`debug|info|warn|error`) и `LOG_FORMAT` (`json|text`) со значениями по умолчанию `info`
и `json` соответственно.
#### Scenario: Подробный режим
- **WHEN** задан `LOG_LEVEL=debug`
- **THEN** выводятся подробные строки, включая access-строки и отказы 4xx
#### Scenario: Некорректное значение
- **WHEN** `LOG_LEVEL` или `LOG_FORMAT` содержит недопустимое значение
- **THEN** сервер не стартует и сообщает явную ошибку конфигурации
#### Scenario: Текстовый формат
- **WHEN** задан `LOG_FORMAT=text`
- **THEN** логи выводятся в текстовом формате
### Requirement: Идентификатор запроса
Сервер SHALL присваивать каждому HTTP-запросу идентификатор: принимать валидный
`X-Request-ID` либо генерировать новый, SHALL включать его во все логи этого запроса и
SHALL возвращать его в заголовке ответа `X-Request-ID`.
#### Scenario: Идентификатор передан клиентом
- **WHEN** запрос содержит валидный заголовок `X-Request-ID`
- **THEN** это значение используется как идентификатор запроса
#### Scenario: Идентификатор сгенерирован
- **WHEN** заголовок `X-Request-ID` отсутствует или невалиден
- **THEN** сервер генерирует новый идентификатор
#### Scenario: Эхо в ответе
- **WHEN** запрос обработан
- **THEN** ответ содержит заголовок `X-Request-ID` с использованным значением
### Requirement: Access-лог запроса
На уровне `debug` сервер SHALL писать одну строку на завершённый HTTP-запрос с методом,
шаблоном маршрута (без query-строки), статусом, длительностью, объёмом ответа, клиентским
IP, идентификатором пользователя (если аутентифицирован) и идентификатором запроса.
#### Scenario: Режим debug
- **WHEN** задан `LOG_LEVEL=debug`
- **THEN** после обработки запроса появляется access-строка
#### Scenario: Обычный режим
- **WHEN** задан `LOG_LEVEL=info`
- **THEN** access-строки не пишутся
#### Scenario: Без query-строки
- **WHEN** запрос содержит query-параметры
- **THEN** query-параметры в access-строку не попадают
### Requirement: Регистрация внутренних ошибок
Сервер SHALL логировать причину внутренних ошибок: ошибки, не являющиеся ожидаемыми
`AppError`, SHALL писаться на уровне `error` с исходной причиной и идентификатором
запроса; ожидаемые отказы со статусом ниже 500 SHALL писаться на уровне `debug`, а
со статусом 500 и выше — на уровне `error`.
#### Scenario: Внутренняя ошибка
- **WHEN** обработка запроса завершается неожиданной ошибкой
- **THEN** её причина логируется на уровне `error` вместе с идентификатором запроса
#### Scenario: Ожидаемый отказ
- **WHEN** запрос отклонён ожидаемой ошибкой со статусом 4xx
- **THEN** на уровне `info` причина не логируется, а на уровне `debug` — логируется
#### Scenario: Клиентское сообщение не подменяет причину
- **WHEN** клиент получает обобщённый ответ о внутренней ошибке
- **THEN** в логе сохраняется исходная причина
### Requirement: Фильтрация access-логов
Сервер SHALL всегда исключать маршрут `/api/health` из access-логов и SHALL писать
access-логи для маршрутов `/api/objects/*` и `/api/sse/*` только на уровне `debug`.
#### Scenario: Health исключён
- **WHEN** запрошен `/api/health`
- **THEN** access-строка не пишется даже на уровне `debug`
#### Scenario: Объекты и SSE
- **WHEN** запрошен `/api/objects/*` или `/api/sse/*` и задан `LOG_LEVEL=debug`
- **THEN** access-строка пишется
### Requirement: Приватность логов
Сервер SHALL не записывать в логи query-строку запроса, заголовок `Authorization`, тела
запросов, пароли и подписанные URL.
#### Scenario: Токен в query
- **WHEN** авторизация передана через query-параметр `token`
- **THEN** его значение не попадает в логи
#### Scenario: Тело запроса не логируется
- **WHEN** запрос содержит тело (JSON или multipart)
- **THEN** содержимое тела не попадает в логи