11 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":"a1b2","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))
Уровни
Принцип: уровень — это адресат («кому сообщение»), а не «насколько
громко сломалось». slog даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в jellybit |
|---|---|---|
DEBUG |
разработчику при отладке; в проде выключен | healthcheck-эндпоинты, тела запросов/ответов внешних API, промежуточные шаги распознавания |
INFO |
команде, аудит постфактум | приём загрузки, распознан фильм/сериал, раскладка выполнена, старт процессов, каждый вызов внешнего сервиса (старт/успех) |
WARN |
команде, «может стать проблемой» | retry внешнего вызова, низкая уверенность распознавания (ушло в ревью), приближение к лимиту |
ERROR |
команде, в техдолг / разбор | внешний сервис недоступен после ретраев, операция загрузки не выполнена, необработанная ошибка |
Правила:
- Уровень не зависит от capability —
ERRORвingestи вfile-layoutодинаково серьёзны. WARN≠ «ничего страшного».WARN= «может стать проблемой». Если это не «может» — этоINFO.- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
это
DEBUG(норма, команде разбирать нечего), а неERROR. 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
одной строкой при старте.
Корреляция по download_id
Отдельный случайный trace_id не заводим — у загрузки уже есть стабильный
осмысленный ключ: download_id (и infohash), он лежит в SQLite.
- Заводим scoped-логгер на загрузку и протаскиваем его через
context.Contextсквозь асинхронные стадии (приём → скачивание → распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
log := log.With("download_id", id, "infohash", ih)
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
- Все записи одной загрузки собираются одним фильтром:
jq 'select(.download_id=="a1b2")' app.jsonl.
Ошибки
Go-ошибки логируем как атрибут, не как текст сообщения.
// Правильно: msg — категория, ошибка — поле
log.Error("layout failed", "error", err, "download_id", id)
// Неправильно: ошибка зашита в msg, агрегация по событию ломается
log.Error(err.Error())
Правила:
- Ошибку передаём полем
"error", err— не склеиваем вmsg. - В коде оборачиваем с контекстом (
fmt.Errorf("…: %w", err)); логируем развёрнутую ошибку один раз — в точке, где решено «дальше не пробрасываем». - Не логировать одну ошибку дважды по цепочке: либо логируешь и гасишь, либо оборачиваешь и пробрасываешь — не оба сразу.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
Внешние сервисы (обязательно логируем все вызовы)
Каждый вызов внешнего сервиса (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— старт и успешный результат (трафик низкий, шум допустим);WARN— попытка не удалась, делаем retry;ERROR— ретраи исчерпаны / сервис недоступен.
Тело запроса/ответа — только на DEBUG и после вычистки секретов
(см. «Безопасность»).
HTTP и healthcheck
- Входящие HTTP-запросы логируем с полями
http.method,http.route,http.status_code,duration_ms. - Эндпоинты 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 прямо из файла.