# Логирование

Конвенция: *как* и *когда* писать логи в transcriber. Это правила оформления
кода (How), а не спецификация поведения — наблюдаемые требования к логам (что
система обязана залогировать как часть контракта capability) живут в спеках
OpenSpec.

**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: обработчик текстовый, а не JSON; уровень зашит `INFO` и не
настраивается; `msg` — предложение с заглавной буквы, а не константная
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.

**Механизировано:** форма вызова — `sloglint`: только пары
«ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не
употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println` —
`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
[go-linters.md](go-linters.md), «Механизировано».

## Принципы

- Структурированный JSON (`slog.JSONHandler`), один формат для разработки и для
  продакшена.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
  отдельный ключ с типизированным значением: это даёт отбор и сведение через
  `jq` без регулярных выражений.

```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
```

*Расхождение:* текстовый обработчик ставит `cmd/transcriber` —
`slog.NewTextHandler(os.Stdout, …)`.

*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
выглядел бы хуже, чем есть.

## Сообщение

- `msg` — короткая константа в нижнем регистре: `record accepted`,
  `recognition done`, `conversion failed`. Данные — в атрибутах:
  `log.Info("record accepted", "record_id", id, "source", "api")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
  `recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
  `from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
  жизненный цикл собирался одним отбором:
  `jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
  сверх перехода — отдельная запись своей категории (`file converted`,
  `text delivered`), она запись перехода не подменяет.

*Расхождение:* сегодня `msg` — предложение вида `Starting conversion job`,
поля `capability` нет, отдельной категории перехода нет.

## Уровни

Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько громко
сломалось». `slog` даёт четыре уровня; их и используем.

| Уровень | Кому и когда | Примеры в transcriber |
| --- | --- | --- |
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |

Правила:

- Уровень **не зависит от capability**: `ERROR` в приёме и в распознавании
  одинаково серьёзны.
- `WARN` не значит «ничего страшного». `WARN` значит «может стать проблемой».
  Если это не «может» — это `INFO`.
- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
  `DEBUG` (норма, владельцу разбирать нечего), а не `ERROR`.
- **Событийное — `INFO`, рутинно-частое — `DEBUG`.** Операция по реальному
  действию (приём записи, запуск распознавания, отправка текста) идёт на `INFO`.
  Повторяющаяся служебная операция, которую запускает таймер или опрос и которая
  сама по себе события не несёт (проверка здоровья, пустой прогон воркера,
  опрос готовности операции), — на `DEBUG`: на `INFO` она зашумляет разбор.
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
  завершаем процесс с ненулевым кодом.

*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.

*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а
идёт на `INFO` — уровень и его довод нормирует спека
[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в
журнале».

## Время

- Поле — `time` (ключ `slog` по умолчанию).
- UTC, RFC 3339 с долями секунды, суффикс `Z`.
- Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
  лексикографическую сортировку. Часовой пояс есть только у **отображения**.

## Поля: словарь имён

Главное условие — **единый словарь**: одно поле, одно имя по всему коду.

- Доменные поля — плоский `snake_case`.
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
  `ext.*`, `webapp.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.

| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |

Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.

*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.

## Корреляция по id сущности

Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.

- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
  Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии,
  чтобы ключ дописывался на каждую запись сам:

```go
log := log.With("record_id", record.Id, "capability", "conversion")
```

- Все записи одной задачи собираются одним отбором:
  `jq 'select(.record_id=="…")' app.jsonl`.

## Ошибки

Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.

- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
  оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
  накапливается в цепочке `%w`.
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
  исход операции. Логирует эта единая точка, а не каждый транспорт — так
  транспорты остаются тонкими. Границы в transcriber:
  - приём записи (`CreateJobFromApi`);
  - **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
    `FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
  - завершение и отказ задачи (`completeJob`, `failJob`).
- Транспорты переводят возвращённую ошибку в свой ответ и **не логируют** её
  повторно — иначе один сбой даёт дубли.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой доменной
  ошибки ровно один логирующий; уровень выбирает он.

  | Класс отказа | Кому | Уровень |
  | --- | --- | --- |
  | негодный ввод, задача не найдена, действие сейчас недопустимо | пользователю (он уже получил ответ на поверхности) | `DEBUG` |
  | запись распознана пустой, задача досталась повторно | владельцу, «может стать проблемой» | `WARN` |
  | сбой БД, диска, недоступность внешнего сервиса | владельцу, в разбор | `ERROR` |

- **Повторяющийся сбой фонового цикла — `WARN`, а не `ERROR`.** Одиночный
  промах шага временный: задача останется в своём состоянии, и следующий тик
  повторит. Тот же класс сбоя в синхронной операции приёма — `ERROR`, потому что
  операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а
  наличие штатного повтора.
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о поведении
  зависимости, а не дубль доменной ошибки.
- Глушить ошибку без лога — только с однострочным комментарием «почему».

*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи.

## Внешние сервисы: логируем все вызовы

**Каждый** вызов внешнего сервиса логируется. Поля:

- `ext.service` — `speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
  `PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были.

Уровни вызова:

- `INFO` — успешный **событийный** вызов (заливка объекта, запуск распознавания,
  отправка сообщения, конвертация);
- `DEBUG` — успешный **рутинно-частый** вызов (опрос готовности операции,
  длинный опрос обновлений);
- `WARN` — попытка не удалась, делаем повтор;
- `ERROR` — повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx —
  это успех на транспортном уровне; решение «это ошибка» принимает доменный
  вызывающий.

Тело запроса и ответа — только на `DEBUG` и **после** вычистки секретов.

*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage и опрос операции не логируются никак.

## HTTP и проверка здоровья

- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
  `http.status_code`, `duration_ms`, `http.path_length`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
  доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
  потребитель молча оставит одно из значений. Правило проверяется чтением,
  линтером не выражается.
- **`GET /health` и `GET /metrics` логируем на `DEBUG`** — их дёргают
  периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
  `INFO` они не пишутся.

Расхождения здесь больше нет: слой журналирования запросов свой —
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
то есть при боевом `INFO` не пишутся вовсе.

**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.

## Безопасность: что не логируем

Никаких секретов в полях и сообщениях. Под запретом:

- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
  переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем:
  ни укороченным именем, ни отпечатком от него — отпечаток та же приватная
  величина, а корреляцию держат идентификаторы сущностей. Чем при этом
  прослеживается приём, нормирует спека `intake`, а не эта запись.

Дополнительно:

- Тела запросов и ответов внешних сервисов — только на `DEBUG`, с вычисткой
  секретов и обрезкой по длине.
- При сомнении не логируем значение, логируем факт его наличия
  (`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
  `*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
  живёт прямо в пути. Такую ошибку разворачивают в
  первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
  проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
  в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.

Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
неверной.

*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением. В журнал оно идёт **собственным полем** строки приёма — это
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).

## Куда пишем и уровень

- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
  раскладываем по файлам.
- Базовый уровень в продакшене — `INFO`; `DEBUG` включается конфигом при
  необходимости. При разработке — `DEBUG`.

*Расхождение:* поля конфигурации под уровень лога нет.

## Анализ

- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
