Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 KiB
Логирование
Конвенция: как и когда писать логи в jellybit. Это правила оформления
кода (How), а не спецификация поведения — наблюдаемые требования к логам
(что система ОБЯЗАНА залогировать как часть контракта capability) живут в
OpenSpec-спеках (### Requirement с SHALL).
Краткая выжимка и инварианты — в CLAUDE.md, раздел «Конвенции кода».
Принципы
- Только
log/slog, безfmt.Printlnи прямой записи в stdout. - Структурированный JSON (
slog.JSONHandler), один формат для dev и prod. - Сообщение (
msg) — константный шаблон/категория события; данные — в полях (атрибутахslog), а не в интерполяции текста. - Каждое поле — отдельный ключ с типизированным значением. Это даёт
фильтрацию и агрегацию через
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. Без переменных в тексте.- Данные кладём в атрибуты:
slog.Info("download accepted", "download_id", id, "infohash", ih).
// Правильно: msg — категория, данные — поля
log.Info("download accepted", "download_id", id, "media_type", "movie")
// Неправильно: данные зашиты в текст, агрегация ломается
log.Info(fmt.Sprintf("download %s accepted as movie", id))
msg— чистая категория без неймспейс-префикса:recognition done, а неrecognize: done. Подсистему выносим в полеcapability(ingest/recognition/file-layout/review), не в текст.
Уровни
Принцип: уровень — это адресат («кому сообщение»), а не «насколько
громко сломалось». 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-ошибки логируем как атрибут, не как текст сообщения.
// Правильно: msg — категория, ошибка — поле
log.Error("layout failed", "error", err, "download_id", id)
// Неправильно: ошибка зашита в msg, агрегация по событию ломается
log.Error(err.Error())
Правила:
- Ошибку передаём полем
"error", err— не склеиваем вmsg. Ключ —error(как по умолчанию в zap/zerolog; единый ключ важнее краткости). - Идиома Go — либо лог, либо возврат, не оба. Промежуточные слои только
оборачивают и возвращают (
fmt.Errorf("…: %w", err)), не логируя — контекст накапливается в цепочке%w. - Логируем ошибку один раз — на границе доменного слоя (use-case
Ingest, стадии воркера), которая определяет исход операции: полемerror, уровеньERROR. В Go логирует этот единый чокпоинт, а не каждый транспорт — так транспорты остаются тонкими. - Транспорты (HTTP/web/Telegram) переводят возвращённую ошибку в свой ответ (статус, сообщение пользователю) и не логируют её повторно — иначе один сбой даёт дубли.
- Телеметрия внешнего вызова (
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).
Куда пишем и уровень
- Пишем JSON в
stdoutодним потоком; сбор и ротацию делает окружение (docker/journald). Не маршрутизируем по файлам. - Базовый уровень в проде —
INFO;DEBUGвключается через конфиг/env при необходимости. dev —DEBUG.
Анализ
- Повседневно —
jq(jq 'select(.download_id=="a1b2")' app.jsonl). - Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.