Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций на сотни строк размазывает внимание по тривиальному — модель добросовестно проверит именование полей лога и не дойдёт до формы решения. Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*, os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard (сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное время в новых миграциях, матчинг ошибки по тексту. Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера, slog.DiscardHandler в тестах. Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой осталось только то, что правилом не выражается. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
Логирование
Конвенция: как и когда писать логи в jellybit. Это правила оформления
кода (How), а не спецификация поведения — наблюдаемые требования к логам
(что система ОБЯЗАНА залогировать как часть контракта capability) живут в
OpenSpec-спеках (### Requirement с SHALL).
Краткая выжимка и инварианты — в CLAUDE.md, раздел «Конвенции кода».
Механизировано (.golangci.yml): slog вместо fmt.Print* — forbidigo;
константный msg и стиль ключ-значение — sloglint. Ниже — только то, что
правилом не выражается.
Принципы
- Структурированный JSON (
slog.JSONHandler), один формат для dev и prod. - Сообщение (
msg) — категория события; данные — в полях. Каждое поле — отдельный ключ с типизированным значением: это даёт фильтрацию и агрегацию черезjq/DuckDB без регулярок.
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
Сообщение
msg— короткая константа в нижнем регистре:download accepted,recognition done,layout failed. Данные — в атрибутах:log.Info("download accepted", "download_id", id, "media_type", "movie").msg— чистая категория без неймспейс-префикса:recognition done, а неrecognize: done. Подсистему выносим в полеcapability(ingest/recognition/file-layout/review), не в текст.- Смена состояния загрузки — единая категория
state transitionс полямиfrom/to/code(какое именно состояние и по какой причине — это данные, не текст). Любой переход (в т.ч.cancel/retry/relink) пишет этотmsg, чтобы весь жизненный цикл собирался одним фильтром:jq 'select(.msg=="state transition" and .download_id=="…")'. Физический эффект сверх перехода — отдельная запись своей категории (layout linked,layout reverted,review hint added), не подменяет запись перехода.
Уровни
Принцип: уровень — это адресат («кому сообщение»), а не «насколько
громко сломалось». slog даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в jellybit |
|---|---|---|
DEBUG |
разработчику при отладке; в проде выключен | healthcheck-эндпоинты, поллинг статуса в qBittorrent, авто-рефреш UI, тела запросов/ответов внешних API, промежуточные шаги распознавания |
INFO |
команде, аудит постфактум | приём загрузки, распознан фильм/сериал, раскладка выполнена, старт процессов, событийный вызов внешнего сервиса (по реальному действию) |
WARN |
команде, «может стать проблемой» | retry внешнего вызова, низкая уверенность распознавания (ушло в ревью), приближение к лимиту |
ERROR |
команде, в техдолг / разбор | внешний сервис недоступен после ретраев, операция загрузки не выполнена, необработанная ошибка |
Правила:
- Уровень не зависит от capability —
ERRORвingestи вfile-layoutодинаково серьёзны. WARN≠ «ничего страшного».WARN= «может стать проблемой». Если это не «может» — этоINFO.- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
это
DEBUG(норма, команде разбирать нечего), а неERROR. - Событийное → INFO, рутинно-частое → DEBUG. Операция, срабатывающая по
реальному действию/изменению (приём загрузки, добавление торрента, вызов
LLM, раскладка), идёт на
INFO. Повторяющаяся служебная операция, которую запускает таймер/поллинг и которая сама по себе не несёт события (healthcheck, поллинг статуса в qBittorrent, авто-рефреш UI), — наDEBUG: наINFOона зашумляет аудит. Такие записи смотрят редко, при предметной отладке (DEBUG включают точечно). slogне разделяет CRITICAL/FATAL — фатальный сбой на старте логируемERRORи завершаем процесс (ненулевой код возврата).
Время
- Поле —
time(ключ по умолчаниюslog). - UTC, RFC 3339 с долями секунды, суффикс
Z:2026-06-28T11:23:45.123456Z. - Логи — в UTC (это явный TZ, не нарушает инвариант проекта): даёт
однозначный порядок событий и лексикографическую сортировку. Бизнес-логика
по-прежнему работает в
Europe/Moscow— UTC только в логах.
Поля: словарь имён
Главное условие — единый словарь: одно поле — одно имя по всему коду
(не mediaType/media/media_type вперемешку).
- Бизнес-/доменные поля — плоский
snake_case. - Системные домены — точечная иерархия (адаптация OpenTelemetry):
http.*,ext.*. - JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
|---|---|
| на входящий HTTP-запрос (middleware) | transport (http/web/telegram), http.method, http.route, http.status_code, duration_ms |
| на загрузку (scoped-логгер, см. ниже) | capability (ingest/recognition/file-layout/review), download_id, infohash, media_type, title |
| на запись об ошибке | error |
| на вызов внешнего сервиса | ext.service, ext.operation, ext.status_code, duration_ms, retry |
Не заводим service.*/host.* — для одного бинаря на одном хосте это шум.
Если когда-нибудь поедем в несколько инстансов, добавим service.version
одной строкой при старте.
Корреляция по id сущности
Отдельный случайный trace_id не заводим — у сущностей уже есть стабильные
осмысленные ключи: ULID-идентификаторы (download_id, recognition_id,
batch_id, см. database.md), они лежат в SQLite.
- Каждая запись, относящаяся к сущности, несёт её id в поле
<entity>_id. Для загрузки — scoped-логгер, протаскиваемый черезcontext.Contextсквозь асинхронные стадии (приём → скачивание → распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
log := log.With("download_id", id, "infohash", ih)
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
- Все записи одной загрузки собираются одним фильтром:
jq 'select(.download_id=="01jz2k7f8q9r3s4t5v6w7x8y9z")' app.jsonl. - ULID глобально уникален across сущностей, поэтому штатно работает и
простой grep по голому id — он находит все упоминания сущности независимо
от имени поля:
grep 01jz2k7f8q9r3s4t5v6w7x8y9z app.jsonl.
Ошибки
Go-ошибки логируем как атрибут, не как текст сообщения:
log.Error("layout failed", "error", err, "download_id", id). Ключ — error
(как по умолчанию в zap/zerolog; единый ключ важнее краткости).
-
Идиома Go — либо лог, либо возврат, не оба. Промежуточные слои только оборачивают и возвращают (
fmt.Errorf("…: %w", err)), не логируя — контекст накапливается в цепочке%w. -
Логируем ошибку один раз — на границе доменного слоя, которая определяет исход операции: полем
error. В Go логирует этот единый чокпоинт, а не каждый транспорт — так транспорты остаются тонкими. Границы в jellybit:- use-case
Ingest(приём); - асинхронные стадии воркера (поллинг, распознавание, авто-раскладка) — исход стадии, вызванной таймером/циклом;
- публичные команды воркера (
Apply/Refine/Cancel/Retry/Undo/Delete/…), вызываемые транспортами. Исход команды логирует ровно один чокпоинт (worker.logCmd, вdeferпри именованном возврате), а не HTTP/web/Telegram — они одну и ту же команду зовут из трёх мест.
- use-case
-
Транспорты (HTTP/web/Telegram) переводят возвращённую ошибку в свой ответ (статус, сообщение пользователю) и не логируют её повторно — иначе один сбой даёт дубли.
-
Уровень доменного отказа — по адресату, а не по месту. У каждой доменной ошибки ровно один логирующий; уровень выбирает он. На границе команды (пользователь инициировал действие и ждёт ответа —
worker.logCmd):Класс отказа Кому Уровень штатный конфликт состояния / некорректный ввод ( ErrConflict,ErrNotReady,ErrInvalidInput,ErrNotFound,layout.ErrCollision)пользователю (уже получил ответ на поверхности) DEBUGнарушенный инвариант хранилища/учёта (не безопасность данных: файлы уже разложены) команде, «может стать проблемой» WARNсбой БД / ФС / недоступность зависимости команде, в разбор ERRORТот же класс отказа в асинхронной стадии (пользователь не ждёт: авто- раскладка, поллинг) адресован уже команде как деградация автоматики — уровень поднимается. Пример:
layout.ErrCollisionв ручномApply—DEBUG(человек видит причину в карточке), а в авто-раскладке —WARN(«auto-apply failed, left for review»): автоматика не довела задачу, это «может стать проблемой». -
Повторяющийся сбой фонового цикла (поллинг/сверка) —
WARN, неERROR. Одиночный промах тика (poll/sweep/list failed, недоступный qBittorrent) транзиентен: следующий тик повторит. Тот же класс сбоя внутри синхронной операции (ingest.Ingest) —ERROR, потому что операция провалилась целиком и повтора нет. То есть уровень задаёт не текст ошибки, а наличие штатного ретрая: тик повторится →WARN, разовая операция упала →ERROR. (Устойчивый сбой N тиков подряд эскалировать вERROR— на будущее, сейчас не реализовано.) -
Телеметрия внешнего вызова (
ext.*, см. ниже) — отдельная запись о поведении зависимости, не дубль доменной ошибки. -
Глушить ошибку без лога — только с однострочным комментарием «почему».
Внешние сервисы (обязательно логируем все вызовы)
Каждый вызов внешнего сервиса (qBittorrent, Jellyfin, LLM, TMDB/TVDB) логируется. Поля:
ext.service—qbittorrent/jellyfin/llm/tmdb/tvdb;ext.operation— логическая операция (torrents/add,chat.completions,search/movie);ext.status_code— HTTP-код ответа (если применимо);duration_ms— длительность вызова;retry— номер попытки (если были ретраи).
Уровни вызова:
INFO— успешный событийный вызов (по реальному действию: добавление торрента, вызов LLM, рефреш Jellyfin, поиск в метабазе);DEBUG— успешный рутинно-частый вызов (поллинг статусаtorrents/info/torrents/files, авто-рефреш) — см. правило «событийное → INFO, рутинно-частое → DEBUG» в разделе «Уровни»;WARN— попытка не удалась, делаем retry;ERROR— ретраи исчерпаны / сервис недоступен (сетевой сбой/таймаут). Завершённый HTTP-ответ с 4xx — это успех на транспортном уровне (Successсext.status_code); решение «это ошибка» принимает доменный вызывающий.
Тело запроса/ответа — только на DEBUG и после вычистки секретов
(см. «Безопасность»).
HTTP и healthcheck
- Входящие HTTP-запросы логируем с полями
http.method,http.route,http.status_code,duration_ms,transport(http/web/telegram). - Для корреляции HTTP-запроса допустим
request_id(напр. chiRequestID) — это отдельный слой от корреляции загрузки поdownload_idи не противоречит отказу отtrace_id. Если запрос порождает загрузку — связь даётdownload_idв её записях. - Эндпоинты healthcheck/liveness/readiness логируем на
DEBUG— их дёргают периодически, наINFOони забивают аудит шумом. В проде (базовый уровеньINFO) они не пишутся.
Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- учётные данные qBittorrent (логин/пароль, cookie сессии);
- API-ключ и токен LLM-провайдера,
Authorization-заголовки; - ключи TMDB/TVDB и прочих метабаз;
- содержимое аутентификационных параметров magnet/трекеров.
Дополнительно:
- Тела запросов/ответов внешних API и сырой вывод LLM (недоверенный, может
быть большим) — только на
DEBUG, с вычисткой секретов и обрезкой по длине. - При сомнении — не логируем значение, логируем факт его наличия
(
"has_api_key", true). - Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.
*url.Error(стандартныйnet/http) встраивает полный URL запроса, а секрет может жить прямо в нём: токен Telegram в пути (…/bot<TOKEN>/…),api_keyметабазы в query. Санитизируем на границе клиента до лога и обёртки —logging.SanitizeErr(err)разворачивает*url.Errorв первопричину (URL отбрасывается,errors.Isна причину сохраняется). Применяется вext.*-обёртке (ExtCall), клиентах metadata и tgbot. Общее правило: секрет не кладём в URL, если у API есть заголовок — тогда его нет и в ошибке транспорта.
Куда пишем и уровень
- Пишем JSON в
stdoutодним потоком; сбор и ротацию делает окружение (docker/journald). Не маршрутизируем по файлам. - Базовый уровень в проде —
INFO;DEBUGвключается через конфиг/env при необходимости. dev —DEBUG.
Анализ
- Повседневно —
jq(jq 'select(.download_id=="a1b2")' app.jsonl). - Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.