Добавил конвенции для логирования
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Памятка для работы над jellybit. Перед задачей прочитай также
|
Памятка для работы над jellybit. Перед задачей прочитай также
|
||||||
[README.md](README.md), [BRIEF.md](BRIEF.md) и
|
[README.md](README.md), [BRIEF.md](BRIEF.md) и
|
||||||
[docs/specs/architecture.md](docs/specs/architecture.md).
|
[docs/specs/architecture.md](docs/specs/architecture.md). Разработка идёт
|
||||||
|
по **Spec Driven Development** через OpenSpec — см. раздел ниже.
|
||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
@@ -34,14 +35,57 @@
|
|||||||
перезаписываем.
|
перезаписываем.
|
||||||
- **Выход LLM недоверенный** — безопасность на валидации пути, не на
|
- **Выход LLM недоверенный** — безопасность на валидации пути, не на
|
||||||
промпте. Авто-раскладка только при подтверждённом матче в базе.
|
промпте. Авто-раскладка только при подтверждённом матче в базе.
|
||||||
|
- **Секреты не попадают в логи** — пароли qBittorrent, API-ключи LLM/метабаз,
|
||||||
|
auth-заголовки. Подробнее — [docs/conventions/logging.md](docs/conventions/logging.md).
|
||||||
- **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация
|
- **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация
|
||||||
по именам), mount `/srv/media` (единая песочница) + data-том для
|
по именам), mount `/srv/media` (единая песочница) + data-том для
|
||||||
SQLite/конфига.
|
SQLite/конфига.
|
||||||
|
|
||||||
## Документация: три раздела
|
## Spec Driven Development (OpenSpec)
|
||||||
|
|
||||||
- `docs/specs/` — **живые** спецификации целевого состояния. Меняем по
|
Изменения ведём через [OpenSpec](https://github.com/Fission-AI/OpenSpec)
|
||||||
мере развития, держим в соответствии с кодом.
|
(CLI `openspec`, v1.x). Сначала спецификация — потом код.
|
||||||
|
|
||||||
|
- `openspec/specs/<capability>/spec.md` — **актуальные** capability-спеки:
|
||||||
|
что система делает сейчас. Capability — это поведение/домен (`ingest`,
|
||||||
|
`recognition`, `file-layout`, `review`, `notifications`), а не пакет кода.
|
||||||
|
- `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md` (зачем и
|
||||||
|
что), `design.md` (как, для нетривиальных), дельта-спеки (`ADDED`/
|
||||||
|
`MODIFIED`/`REMOVED Requirements`), `tasks.md` (шаги). После реализации
|
||||||
|
change архивируется в `openspec/changes/archive/`, дельты вливаются в
|
||||||
|
`openspec/specs/`.
|
||||||
|
- `openspec/config.yaml` — язык и правила оформления спек (читай перед
|
||||||
|
написанием).
|
||||||
|
|
||||||
|
Поток работы — через слэш-команды `opsx:*` (канонический набор, его
|
||||||
|
поддерживает `openspec update`): `opsx:explore` (продумать), `opsx:propose`
|
||||||
|
(завести change), `opsx:apply` (реализовать tasks), `opsx:sync`/`opsx:archive`
|
||||||
|
(влить и архивировать). Skills `openspec-*` — то же, но предыдущего
|
||||||
|
поколения; для новой работы используем `opsx:*`.
|
||||||
|
|
||||||
|
Правила спек:
|
||||||
|
|
||||||
|
- Каждое `### Requirement` ОБЯЗАНО содержать литерал `SHALL` или `MUST` —
|
||||||
|
иначе `openspec validate` падает.
|
||||||
|
- Структурные заголовки и ключевые слова — английские (`### Requirement:`,
|
||||||
|
`#### Scenario:`, `GIVEN/WHEN/THEN`, RFC 2119), остальной текст — русский.
|
||||||
|
- Сценарии — в формате `GIVEN/WHEN/THEN`.
|
||||||
|
- `openspec validate --strict` перед коммитом change.
|
||||||
|
|
||||||
|
Ревью (процесс, не артефакт): нетривиальная задача — два чекпоинта (ревью
|
||||||
|
дизайна после design/specs, ДО кода; ревью кода после apply, до archive);
|
||||||
|
тривиальная — одного прохода по коду достаточно.
|
||||||
|
|
||||||
|
**Миграция:** capabilities постепенно переносятся из `docs/specs/` в
|
||||||
|
OpenSpec (пилот — `ingest`). До переноса источник истины по теме —
|
||||||
|
соответствующий файл в `docs/specs/`; перенесённое живёт в
|
||||||
|
`openspec/specs/`.
|
||||||
|
|
||||||
|
## Прочая документация
|
||||||
|
|
||||||
|
- `docs/specs/` — **живые** спецификации целевого состояния (архитектурный
|
||||||
|
обзор + ещё не перенесённые в OpenSpec темы). Меняем по мере развития,
|
||||||
|
держим в соответствии с кодом.
|
||||||
- `docs/adr/` — **неизменяемый** журнал решений, пишется постфактум,
|
- `docs/adr/` — **неизменяемый** журнал решений, пишется постфактум,
|
||||||
хранит *почему*. Правила — [docs/adr/README.md](docs/adr/README.md).
|
хранит *почему*. Правила — [docs/adr/README.md](docs/adr/README.md).
|
||||||
- `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не
|
- `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не
|
||||||
@@ -72,5 +116,9 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
|
|||||||
- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по
|
- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по
|
||||||
компонентам из [architecture.md](docs/specs/architecture.md).
|
компонентам из [architecture.md](docs/specs/architecture.md).
|
||||||
- Ошибки оборачиваем с контекстом (`fmt.Errorf("...: %w", err)`).
|
- Ошибки оборачиваем с контекстом (`fmt.Errorf("...: %w", err)`).
|
||||||
- Логирование только через `slog`, без `fmt.Println`.
|
- Логирование только через `slog`, без `fmt.Println` — уровни, обязательные
|
||||||
|
поля и что не логировать см. [docs/conventions/logging.md](docs/conventions/logging.md).
|
||||||
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
- Время — всегда с явным TZ (сервер в `Europe/Moscow`).
|
||||||
|
|
||||||
|
Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в
|
||||||
|
[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec.
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Конвенции кода
|
||||||
|
|
||||||
|
Кросс-каттинг правила того, **как** мы пишем код (логирование, ошибки,
|
||||||
|
именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые
|
||||||
|
описывают, **что** система делает.
|
||||||
|
|
||||||
|
Конвенции **не** переносятся в OpenSpec: это не capability. Короткие
|
||||||
|
инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его
|
||||||
|
всегда) и кратко в `openspec/config.yaml` → `context` (подмешивается в
|
||||||
|
генерацию артефактов); детали — здесь. Обоснование «почему» — в `docs/adr/`.
|
||||||
|
|
||||||
|
## Записи
|
||||||
|
|
||||||
|
- [logging.md](logging.md) — логирование: уровни, поля, что не логируем.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Логирование
|
||||||
|
|
||||||
|
Конвенция: *как* и *когда* писать логи в jellybit. Это правила оформления
|
||||||
|
кода (How), а не спецификация поведения — наблюдаемые требования к логам
|
||||||
|
(что система ОБЯЗАНА залогировать как часть контракта capability) живут в
|
||||||
|
OpenSpec-спеках (`### Requirement` с `SHALL`).
|
||||||
|
|
||||||
|
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
|
||||||
|
«Конвенции кода».
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- Только `log/slog`, без `fmt.Println` и прямой записи в stdout.
|
||||||
|
- Структурированный JSON (`slog.JSONHandler`), один формат для dev и prod.
|
||||||
|
- Сообщение (`msg`) — константный шаблон/категория события; данные — в
|
||||||
|
полях (атрибутах `slog`), а не в интерполяции текста.
|
||||||
|
- Каждое поле — отдельный ключ с типизированным значением. Это даёт
|
||||||
|
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"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)`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Правильно: 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` сквозь асинхронные стадии (приём → скачивание →
|
||||||
|
распознавание → раскладка), чтобы ключ дописывался на каждую запись сам:
|
||||||
|
|
||||||
|
```go
|
||||||
|
log := log.With("download_id", id, "infohash", ih)
|
||||||
|
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
|
||||||
|
```
|
||||||
|
|
||||||
|
- Все записи одной загрузки собираются одним фильтром:
|
||||||
|
`jq 'select(.download_id=="a1b2")' app.jsonl`.
|
||||||
|
|
||||||
|
## Ошибки
|
||||||
|
|
||||||
|
Go-ошибки логируем как атрибут, не как текст сообщения.
|
||||||
|
|
||||||
|
```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 прямо из файла.
|
||||||
@@ -28,6 +28,15 @@ context: |
|
|||||||
apply, до archive).
|
apply, до archive).
|
||||||
- Тривиальная задача — достаточно одного прохода (код).
|
- Тривиальная задача — достаточно одного прохода (код).
|
||||||
|
|
||||||
|
Конвенции кода (соблюдать при apply):
|
||||||
|
- Логирование — только log/slog (структурированный JSON), без fmt.Println.
|
||||||
|
Логируем все вызовы внешних сервисов; healthcheck-эндпоинты — на DEBUG.
|
||||||
|
Детали: уровни, обязательные поля — docs/conventions/logging.md.
|
||||||
|
- Безопасность: никаких секретов в полях логов (пароли qBittorrent,
|
||||||
|
API-ключи LLM/метабаз, auth-заголовки).
|
||||||
|
- Ошибки оборачиваем с контекстом (fmt.Errorf("...: %w", err)).
|
||||||
|
- Время — всегда с явным TZ (сервер в Europe/Moscow; логи — в UTC).
|
||||||
|
|
||||||
# Project context (optional)
|
# Project context (optional)
|
||||||
# This is shown to AI when creating artifacts.
|
# This is shown to AI when creating artifacts.
|
||||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||||
|
|||||||
Reference in New Issue
Block a user