From c739a207497399528f7bb41b508b004ef8e0b59d Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 28 Jun 2026 19:19:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86=D0=B8=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 58 ++++++++++- docs/conventions/README.md | 14 +++ docs/conventions/logging.md | 185 ++++++++++++++++++++++++++++++++++++ openspec/config.yaml | 9 ++ 4 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 docs/conventions/README.md create mode 100644 docs/conventions/logging.md diff --git a/CLAUDE.md b/CLAUDE.md index 79255e1..0f15bb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,8 @@ Памятка для работы над jellybit. Перед задачей прочитай также [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 недоверенный** — безопасность на валидации пути, не на промпте. Авто-раскладка только при подтверждённом матче в базе. +- **Секреты не попадают в логи** — пароли qBittorrent, API-ключи LLM/метабаз, + auth-заголовки. Подробнее — [docs/conventions/logging.md](docs/conventions/logging.md). - **Запуск:** контейнер под `1000:1000`, в общей docker-сети (адресация по именам), mount `/srv/media` (единая песочница) + data-том для SQLite/конфига. -## Документация: три раздела +## Spec Driven Development (OpenSpec) -- `docs/specs/` — **живые** спецификации целевого состояния. Меняем по - мере развития, держим в соответствии с кодом. +Изменения ведём через [OpenSpec](https://github.com/Fission-AI/OpenSpec) +(CLI `openspec`, v1.x). Сначала спецификация — потом код. + +- `openspec/specs//spec.md` — **актуальные** capability-спеки: + что система делает сейчас. Capability — это поведение/домен (`ingest`, + `recognition`, `file-layout`, `review`, `notifications`), а не пакет кода. +- `openspec/changes//` — предлагаемое изменение: `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/README.md](docs/adr/README.md). - `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не @@ -72,5 +116,9 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`. - Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по компонентам из [architecture.md](docs/specs/architecture.md). - Ошибки оборачиваем с контекстом (`fmt.Errorf("...: %w", err)`). -- Логирование только через `slog`, без `fmt.Println`. +- Логирование только через `slog`, без `fmt.Println` — уровни, обязательные + поля и что не логировать см. [docs/conventions/logging.md](docs/conventions/logging.md). - Время — всегда с явным TZ (сервер в `Europe/Moscow`). + +Кросс-каттинг конвенции (как пишем код, а не что система делает) живут в +[docs/conventions/](docs/conventions/README.md) и не переносятся в OpenSpec. diff --git a/docs/conventions/README.md b/docs/conventions/README.md new file mode 100644 index 0000000..8b7bc47 --- /dev/null +++ b/docs/conventions/README.md @@ -0,0 +1,14 @@ +# Конвенции кода + +Кросс-каттинг правила того, **как** мы пишем код (логирование, ошибки, +именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые +описывают, **что** система делает. + +Конвенции **не** переносятся в OpenSpec: это не capability. Короткие +инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его +всегда) и кратко в `openspec/config.yaml` → `context` (подмешивается в +генерацию артефактов); детали — здесь. Обоснование «почему» — в `docs/adr/`. + +## Записи + +- [logging.md](logging.md) — логирование: уровни, поля, что не логируем. diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md new file mode 100644 index 0000000..2971bb4 --- /dev/null +++ b/docs/conventions/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 прямо из файла. diff --git a/openspec/config.yaml b/openspec/config.yaml index d344134..c473fa3 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -28,6 +28,15 @@ context: | 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) # This is shown to AI when creating artifacts. # Add your tech stack, conventions, style guides, domain knowledge, etc.