Files
jellybit/docs/conventions/logging.md
T
avandClaude Fable 5 37f2f6481a Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с 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>
2026-07-02 21:25:00 +03:00

15 KiB
Raw Blame History

Логирование

Конвенция: как и когда писать логи в 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 команде, в техдолг / разбор внешний сервис недоступен после ретраев, операция загрузки не выполнена, необработанная ошибка

Правила:

  • Уровень не зависит от capabilityERROR в 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.serviceqbittorrent / 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 (напр. chi RequestID) — это отдельный слой от корреляции загрузки по 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 прямо из файла.