заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/config.md
|
||||
---
|
||||
|
||||
# Конфигурация: реализация на Go
|
||||
|
||||
Как `arch/config.md` выглядит в Go-приложении.
|
||||
|
||||
## Формат и загрузчик
|
||||
|
||||
- TOML. Разбор и валидация — целиком в `internal/config`; наружу отдаётся
|
||||
готовая структура `Config`.
|
||||
- Одна корневая структура `Config` с под-структурами по секциям — имена
|
||||
структур совпадают с именами секций, чтобы конфиг и код читались рядом.
|
||||
- Умолчания — в `Default()`, поверх накладывается разобранный файл.
|
||||
- Флаг `--config=path` переопределяет путь; по умолчанию `config.toml` в
|
||||
рабочей директории, образец — `config.example.toml`.
|
||||
|
||||
## Длительности
|
||||
|
||||
`time.Duration` не разбирается из строки TOML сама по себе — нужен свой тип
|
||||
с `UnmarshalText`, отдающий `time.Duration`:
|
||||
|
||||
```go
|
||||
type Duration time.Duration
|
||||
|
||||
func (d *Duration) UnmarshalText(b []byte) error { … }
|
||||
func (d Duration) Std() time.Duration { … }
|
||||
```
|
||||
|
||||
Так в конфиге видна единица измерения (`poll_interval = "5s"`), а не голое
|
||||
число. Цена: ошибка в длительности всплывает **на разборе TOML**, до общей
|
||||
валидации, поэтому в общий сбор проблем она не попадает — про неё узнаёшь
|
||||
отдельно и первой.
|
||||
|
||||
## Чтение окружения
|
||||
|
||||
Приложение не читает окружение для конфигурации. Механизируется
|
||||
`forbidigo`, и паттерн должен покрывать **все** входы, а не только
|
||||
`os.Getenv`:
|
||||
|
||||
```
|
||||
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
|
||||
```
|
||||
|
||||
Правило про приложение, поэтому за его границей запрет не действует:
|
||||
|
||||
- **тесты** — не приложение: интеграционному тесту нормально брать
|
||||
креды внешнего сервиса из окружения;
|
||||
- **переменные рантайма** — те, что читает не наш код, а Go или ОС
|
||||
(`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`).
|
||||
|
||||
Отдельный случай — переменные, которые читает **стандартная библиотека от
|
||||
имени приложения**: дефолтный `http.Transport` уважает
|
||||
`HTTP_PROXY`/`HTTPS_PROXY`. Формально их читает не наш код, но это
|
||||
конфигурация поведения приложения, поэтому прокси задаётся полем конфига и
|
||||
явным `Transport`, а не окружением.
|
||||
|
||||
## Валидация
|
||||
|
||||
- Проверки собираются `errors.Join`, чтобы за один запуск показать **все**
|
||||
проблемы конфига, а не первую.
|
||||
- IANA-зона валидируется `time.LoadLocation`. База зон встраивается
|
||||
импортом `_ "time/tzdata"` **в `main`**, а не в библиотечном пакете:
|
||||
иначе ~450 КБ zoneinfo навязываются каждому импортёру. Со встроенной
|
||||
базой ошибка `LoadLocation` означает битое имя зоны, а не отсутствие
|
||||
zoneinfo в контейнере.
|
||||
- Невалидный конфиг — `slog` уровня `ERROR` и `os.Exit(1)` из `main`, до
|
||||
старта серверов и воркеров.
|
||||
|
||||
## Секреты
|
||||
|
||||
Go-специфики нет: секреты приходят из деплоя уже в файле, проверка их
|
||||
непустоты идёт вместе с остальной валидацией — см. базу.
|
||||
|
||||
<!-- local:поля -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `lang/go/time.md` — зона отображения и формат времени.
|
||||
- `lang/go/logging.md` — `slog`, которым падает невалидный конфиг.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/db-identifiers.md
|
||||
---
|
||||
|
||||
# Идентификаторы: реализация на Go
|
||||
|
||||
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID.
|
||||
|
||||
## Единая точка — `internal/ident`
|
||||
|
||||
- `ident.NewID()` — генерация. **PK сущности** генерируется в `Create`-методах
|
||||
слоя `store`. Прочие идентификаторы (батч, задание, корреляционный ключ)
|
||||
генерируются там, где начинается операция, — но тоже только через `ident`.
|
||||
- `ident.NewIDAt(t)` — генерация с заданным временем, для бэкфилла в
|
||||
Go-миграциях: сортировка id тогда сохраняет историческую хронологию, а не
|
||||
момент прогона миграции.
|
||||
- `ident.Parse()` — разбор и нормализация; зовётся на **входных границах**
|
||||
(HTTP-роут, форма, callback бота), до обращения к store.
|
||||
- Других генераторов и парсеров id в коде нет. Это то самое «единая точка»
|
||||
из базовой конвенции; без него нормализация регистра неизбежно
|
||||
где-нибудь пропускается.
|
||||
|
||||
## Типы
|
||||
|
||||
В структурах store и домена id — обычный `string`. Отдельный тип `ID`
|
||||
заводим, только если появится вторая семья идентификаторов, которую можно
|
||||
перепутать; до этого он даёт конверсии без выгоды. От перепутывания двух id
|
||||
одной семьи в сигнатуре он всё равно не спасает — там помогают имена
|
||||
параметров.
|
||||
|
||||
## Невалидный id на границе
|
||||
|
||||
Разбор не удался — дальше зависит от того, откуда id пришёл:
|
||||
|
||||
- **из пути или query URL** — сразу 404, без обращения к store и без
|
||||
фабрикации доменной ошибки: снаружи это неотличимо от несуществующей
|
||||
записи, и хорошо;
|
||||
- **из собственной формы или callback-данных кнопки** — 400 либо понятное
|
||||
сообщение («кнопка устарела»): это баг интерфейса или протухший экран, и
|
||||
под «не найдено» его маскировать нельзя.
|
||||
|
||||
Транспорт не создаёт доменные sentinel'ы, чтобы тут же их сматчить, — это
|
||||
инверсия правила «трансляция у источника» из `lang/go/errors.md`.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Схема и миграции (SQLite, Go)
|
||||
|
||||
Область действия — **новые миграции**. Существующая схема не переписывается;
|
||||
линтер проверяет то, что добавляется, а не то, что уже лежит.
|
||||
|
||||
## Миграции
|
||||
|
||||
- Инструмент — goose, файлы миграций лежат рядом со store-слоем.
|
||||
- **SQL-файл** для DDL: создание таблиц, индексы, изменение структуры.
|
||||
- **Go-миграция** (`goose.AddMigrationContext`) — когда нужен код:
|
||||
генерация идентификаторов, backfill, перенос данных между формами.
|
||||
Не пытаемся выразить это SQL-ом ради единообразия.
|
||||
- **В деплое движение только вперёд.** Down-миграция — инструмент
|
||||
разработки, а не отката на сервере.
|
||||
- **Down пишется, когда он честно обращает up**: убрать то, что up добавил.
|
||||
Не пишется, когда up необратимо трансформирует данные, — тогда его
|
||||
отсутствие честнее имитации, которая молча теряет колонку.
|
||||
- При изменении структуры ER-схема в спеках обновляется **в том же
|
||||
изменении**, а не «потом»: разошедшаяся схема хуже отсутствующей.
|
||||
|
||||
## Типы колонок
|
||||
|
||||
- **Enum-поля** (`state`, `kind`, …) — обычный `TEXT` **без `CHECK`**.
|
||||
Допустимые значения держит код. `ALTER TABLE` в SQLite не умеет менять
|
||||
ограничения ни в одной версии, поэтому каждое новое значение в
|
||||
`CHECK(... IN (...))` означает пересоздание таблицы по 12-шаговой
|
||||
процедуре; защита от невалидного значения всё равно нужна на уровне типов
|
||||
Go.
|
||||
- **Метки времени** — `TEXT` в формате из `arch/time.md`. Без
|
||||
`DEFAULT (datetime('now'))`: помимо того, что время ставит приложение,
|
||||
эта функция даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, то есть не
|
||||
тот формат.
|
||||
- **Булевы** — `INTEGER` 0/1. Отдельного типа в SQLite нет, а строка
|
||||
`'true'` в булевом контексте приводится к **0** — то есть тихо
|
||||
инвертирует смысл, а не просто ломает фильтрацию.
|
||||
- **Первичные ключи** — если репозиторий взял `arch/db-identifiers.md`, то
|
||||
по ней (без `AUTOINCREMENT`); иначе автоинкремент допустим.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/time.md` — формат меток времени.
|
||||
- `arch/db-identifiers.md` — выбор первичных ключей.
|
||||
- `lang/go/errors.md` — граничные ошибки `database/sql` транслируются в
|
||||
доменные у источника, в слое store.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Ошибки
|
||||
|
||||
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
|
||||
**логировать** — в `lang/go/logging.md`, раздел «Ошибки» (коротко: лог один
|
||||
раз на доменной границе).
|
||||
|
||||
## Базовая идиома: stdlib
|
||||
|
||||
- Только стандартный `errors` + `fmt.Errorf`. Контекст ошибки несёт `slog`,
|
||||
а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка
|
||||
сообщений локализует место не хуже стека, а стек-трейсы и Sentry
|
||||
избыточны для домашнего сервиса.
|
||||
- Если отладка начнёт упираться в «где именно родилась ошибка» — это
|
||||
сигнал пересмотреть решение, а не дефолт, который можно обойти локально.
|
||||
- Единственное исключение — восстановленная паника: у неё цепочки `%w` нет
|
||||
вовсе (см. «panic»).
|
||||
|
||||
## Обёртка и контекст
|
||||
|
||||
Сервис — **приложение, а не библиотека**: внешнего Go-API нет, весь код
|
||||
наш. Возражение против дефолтного `%w` («обёрнутая ошибка становится частью
|
||||
API») относится к библиотекам, поэтому внутри приложения обёртка `%w` —
|
||||
**дефолт**, чтобы `errors.Is` и `errors.As` работали сквозь слои.
|
||||
|
||||
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
|
||||
- `%w` — когда вызывающий может инспектировать причину (обычный случай).
|
||||
`%v` — когда причину сознательно **не** раскрываем, чтобы не завязывать
|
||||
вызывающего на чужой тип ошибки.
|
||||
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в
|
||||
цепочке, а трансляцией на внешней границе (ниже).
|
||||
|
||||
Стиль сообщения:
|
||||
|
||||
- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка
|
||||
и так читается как «контекст: причина»;
|
||||
- контекст — операция или субъект: `"link target: %w"`, не
|
||||
`"something failed"`;
|
||||
- без заикания: каждый слой добавляет **свой** смысл, не повторяя нижний
|
||||
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
|
||||
|
||||
## Две трансляции
|
||||
|
||||
Ошибка меняет форму дважды, и это разные преобразования.
|
||||
|
||||
**Первая — у источника, инфраструктурная → доменная.** Граничные ошибки
|
||||
зависимостей транслируем там, где они возникли: `sql.ErrNoRows` → доменный
|
||||
`store.ErrNotFound` в слое store, чтобы выше по коду не торчал
|
||||
`database/sql`. То же для HTTP-клиентов, файловой системы, внешних SDK.
|
||||
|
||||
**Вторая — на внешней границе, доменная → пользовательская.** Описана
|
||||
ниже, в разделе про каналы.
|
||||
|
||||
## Sentinel vs типизированные
|
||||
|
||||
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
|
||||
на которые ветвится код: нет записи, дубликат, неподдерживаемый источник.
|
||||
Проверяем `errors.Is`.
|
||||
- **Типизированная ошибка** (тип с полями и методом `Error()`) — когда
|
||||
вызывающему нужны **данные** ошибки: поле валидации, код, лимит. Достаём
|
||||
`errors.As`. Не плодим типы там, где хватает sentinel.
|
||||
- Матчинг по тексту сообщения запрещён — это то же самое, что публичный
|
||||
API из строки лога.
|
||||
|
||||
## Граница: приватный канал vs публичный
|
||||
|
||||
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от
|
||||
того, кто канал видит.
|
||||
|
||||
**Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
|
||||
цепочкой `%w` и контекстом. Пишется один раз на доменной границе.
|
||||
|
||||
**Публичный канал — пользовательские поверхности** (HTTP API, web-UI, бот).
|
||||
Сюда отдаём:
|
||||
|
||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой
|
||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
|
||||
- **корреляционный ключ** для владельца — id сущности либо `request_id`,
|
||||
чтобы по нему найти полную ошибку в логах. «При обработке загрузки
|
||||
произошла ошибка, download_id=…» вместо «произошла ошибка»;
|
||||
- **маппинг доменной ошибки → сообщение и, для HTTP, статус** — в одной
|
||||
точке на все транспорты. У транспортов без статусов (бот) от маппинга
|
||||
берётся только сообщение.
|
||||
|
||||
Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и
|
||||
**сразу добавляем в маппинг** — иначе `default` отдаст 500 «внутренняя
|
||||
ошибка» на нормальный конфликт, а логирующая граница спишет его в `ERROR`
|
||||
вместо `DEBUG`.
|
||||
|
||||
<!-- local:маппинг -->
|
||||
<!-- /local -->
|
||||
|
||||
### Транзиентный ответ vs персистентная диагностика
|
||||
|
||||
У публичной границы две разные поверхности, и правило сырого текста для них
|
||||
разное:
|
||||
|
||||
- **Транзиентный ответ на действие** (тело ответа, `?err=`, реплика бота по
|
||||
результату команды) — строго нейтральный: маппинг выше, `err.Error()`
|
||||
наружу не идёт, полная ошибка живёт в логах по корреляционному ключу.
|
||||
- **Персистентная диагностика состояния** — причина ухода записи в
|
||||
ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь
|
||||
сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и
|
||||
полезен — **но только пока поверхность видит исключительно владелец**.
|
||||
Появился второй зритель или публичный доступ к экрану состояния —
|
||||
поверхность стала публичным каналом, и правило нейтрального текста
|
||||
распространяется на неё. Секреты запрещены абсолютно в обоих случаях;
|
||||
источник вычищается на границе клиента.
|
||||
|
||||
Различие работает, только если поверхности не смешиваются в одном поле.
|
||||
Диагностику кладём в **отдельное поле**, а не в доменное.
|
||||
|
||||
## panic
|
||||
|
||||
- `panic` — только для невосстановимого: нарушенный инвариант (баг
|
||||
программиста), ошибка инициализации, из которой нельзя стартовать.
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
|
||||
ввод) — это значения `error`.
|
||||
- **`recover` — на верхней границе каждой обрабатывающей единицы**, а не
|
||||
только у HTTP:
|
||||
- HTTP middleware — `net/http` сам восстанавливает панику в хендлере и
|
||||
процесс не роняет, поэтому смысл своего `recover` в другом: отдать
|
||||
контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер;
|
||||
- цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в
|
||||
горутине **действительно роняет процесс**, и `recover` обязателен.
|
||||
`recover` работает только в той горутине, где случилась паника.
|
||||
- **Логирующая recover-граница пишет `debug.Stack()`.** Это единственное
|
||||
место, где нужен стек-трейс: у восстановленной паники нет цепочки `%w`, и
|
||||
без стека «index out of range» не диагностируется вообще.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
Сбор независимых ошибок (валидация конфига — все проблемы разом) —
|
||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
# Логирование
|
||||
|
||||
Как и когда писать логи. Это правила оформления кода (How), а не
|
||||
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
|
||||
функциональности, живут в спеках.
|
||||
|
||||
## Принципы
|
||||
|
||||
- Структурированный JSON (`slog.JSONHandler`), **один формат для dev и
|
||||
prod**. Не потому, что текстовый вывод «расходит поля» — смена хендлера
|
||||
структуру атрибутов не меняет; а потому, что с текстовым dev-выводом
|
||||
перестаёшь ежедневно гонять собственные `jq`-пайплайны, и поломки
|
||||
словаря замечаются только в проде.
|
||||
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
|
||||
отдельный ключ с типизированным значением: это даёт фильтрацию и
|
||||
агрегацию через `jq`/DuckDB без регулярок.
|
||||
|
||||
```json
|
||||
{"time":"2026-06-28T11:23:45.123Z","level":"INFO","msg":"download accepted","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","media_type":"movie"}
|
||||
```
|
||||
|
||||
## Время в записи
|
||||
|
||||
Поле `time` ставит `slog`, но **UTC он по умолчанию не даёт**: встроенные
|
||||
хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне
|
||||
процесса. UTC ставится `ReplaceAttr` по `slog.TimeKey` — см.
|
||||
`lang/go/time.md`. Точность `JSONHandler` — миллисекунды, фиксированная
|
||||
ширина; это другая точность, чем в БД, и по `arch/time.md` так и должно
|
||||
быть: ширина фиксируется на носитель.
|
||||
|
||||
## Сообщение
|
||||
|
||||
- `msg` — короткая **константа** в нижнем регистре: `download accepted`,
|
||||
`recognition done`, `layout failed`. Данные — в атрибутах:
|
||||
`log.Info("download accepted", "download_id", id)`.
|
||||
- `msg` — чистая категория **без неймспейс-префикса**: `recognition done`,
|
||||
а не `recognize: done`. Подсистема — отдельное поле, не текст.
|
||||
- **Смена состояния сущности — единая категория** (`state transition`) с
|
||||
полями `from`/`to`/`code`. Какое именно состояние и по какой причине —
|
||||
это данные, а не текст. Тогда весь жизненный цикл собирается одним
|
||||
фильтром. Физический эффект сверх перехода — отдельная запись своей
|
||||
категории, она не подменяет запись перехода.
|
||||
|
||||
## Уровни
|
||||
|
||||
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько
|
||||
громко сломалось».
|
||||
|
||||
| Уровень | Кому и когда |
|
||||
|---|---|
|
||||
| `DEBUG` | разработчику при отладке; в проде выключен |
|
||||
| `INFO` | владельцу, аудит постфактум |
|
||||
| `WARN` | владельцу, «может стать проблемой» |
|
||||
| `ERROR` | владельцу, в разбор |
|
||||
|
||||
Правила:
|
||||
|
||||
- Уровень **не зависит от подсистемы**: `ERROR` везде одинаково серьёзен.
|
||||
- `WARN` ≠ «ничего страшного». `WARN` = «может стать проблемой». Если это
|
||||
не «может» — это `INFO`.
|
||||
- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
|
||||
`DEBUG` (норма, разбирать нечего), а не `ERROR`.
|
||||
- **Событийное → `INFO`, рутинно-частое → `DEBUG`.** Операция по реальному
|
||||
действию или изменению — `INFO`. Повторяющаяся служебная операция,
|
||||
запускаемая таймером или поллингом и сама по себе не несущая события
|
||||
(healthcheck, опрос статуса, авто-рефреш UI), — `DEBUG`: на `INFO` она
|
||||
зашумляет аудит.
|
||||
- `slog` не разделяет CRITICAL/FATAL — фатальный сбой на старте логируем
|
||||
`ERROR` и завершаем процесс с ненулевым кодом.
|
||||
|
||||
## Поля: единый словарь
|
||||
|
||||
Главное условие — **одно поле, одно имя по всему коду** (не
|
||||
`mediaType`/`media`/`media_type` вперемешку).
|
||||
|
||||
- Бизнес-поля — плоский `snake_case`.
|
||||
- Системные домены — точечная иерархия (адаптация OpenTelemetry): `http.*`,
|
||||
`ext.*`.
|
||||
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
||||
|
||||
| Когда добавляем | Поля |
|
||||
|---|---|
|
||||
| входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного |
|
||||
| работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
|
||||
| запись об ошибке | `error` |
|
||||
| вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
|
||||
`service.*` и `host.*` не заводим — для одного бинаря на одном хосте это
|
||||
шум. Если появятся несколько инстансов, добавим `service.version` одной
|
||||
строкой при старте.
|
||||
|
||||
<!-- local:словарь -->
|
||||
<!-- /local -->
|
||||
|
||||
## Корреляция по id сущности
|
||||
|
||||
Отдельный случайный `trace_id` не заводим, **если у сущностей есть
|
||||
стабильные уникальные идентификаторы** — они и служат ключом корреляции.
|
||||
(Как их выбирают — `arch/db-identifiers.md`, если конвенция взята.)
|
||||
|
||||
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
|
||||
Для долгой операции — scoped-логгер, протаскиваемый через
|
||||
`context.Context` сквозь асинхронные стадии, чтобы ключ дописывался сам:
|
||||
|
||||
```go
|
||||
log := log.With("download_id", id)
|
||||
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
|
||||
```
|
||||
|
||||
- Все записи одной операции собираются одним фильтром:
|
||||
`jq 'select(.download_id=="01jz…")' app.jsonl`.
|
||||
- Если id глобально уникален across сущностей, штатно работает и простой
|
||||
`grep` по голому id — он находит все упоминания независимо от имени поля.
|
||||
|
||||
## Ошибки
|
||||
|
||||
Go-ошибки логируем **атрибутом**, не текстом сообщения:
|
||||
`log.Error("layout failed", "error", err, "download_id", id)`. Ключ —
|
||||
`error` (как по умолчанию в zap/zerolog: единый ключ важнее краткости).
|
||||
|
||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||
оборачивают и возвращают (`%w`), не логируя: контекст накапливается в
|
||||
цепочке.
|
||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая
|
||||
определяет исход операции. Логирует этот единый чокпоинт, а не каждый
|
||||
транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей.
|
||||
|
||||
<!-- local:границы -->
|
||||
<!-- /local -->
|
||||
|
||||
- Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение
|
||||
пользователю) и **не логируют** её повторно.
|
||||
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
|
||||
доменной ошибки ровно один логирующий; уровень выбирает он:
|
||||
|
||||
| Класс отказа | Кому | Уровень |
|
||||
|---|---|---|
|
||||
| штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
||||
| расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
||||
| сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
||||
|
||||
- Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт)
|
||||
адресован уже владельцу как деградация автоматики — уровень поднимается.
|
||||
Коллизия в ручном действии — `DEBUG` (человек видит причину на экране), в
|
||||
авто-обработке — `WARN` (автоматика не довела задачу).
|
||||
- **Повторяющийся сбой фонового цикла — `WARN`, не `ERROR`.** Одиночный
|
||||
промах тика транзиентен: следующий тик повторит. Тот же класс сбоя внутри
|
||||
синхронной операции — `ERROR`, потому что операция провалилась целиком и
|
||||
повтора нет. Уровень задаёт не текст ошибки, а **наличие штатного
|
||||
повтора**.
|
||||
|
||||
## Два цикла повтора — не путать
|
||||
|
||||
Слово «ретрай» означает два разных механизма, и уровень считается по
|
||||
каждому отдельно:
|
||||
|
||||
- **Повтор вызова внутри одной операции** (ретраи HTTP-клиента) — по нему
|
||||
выбирается уровень **`ext`-записи**: `WARN` на попытку, `ERROR` когда
|
||||
попытки исчерпаны.
|
||||
- **Повтор тика внешним циклом** (поллинг, сверка) — по нему выбирается
|
||||
уровень **доменной записи** об исходе тика: `WARN`, потому что следующий
|
||||
тик повторит.
|
||||
|
||||
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
|
||||
каждый тик. Это и есть механизм эскалации: доменный слой не паникует, а
|
||||
телеметрия зависимости честно показывает, что она недоступна. Если поток
|
||||
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
|
||||
подавлением повторов в самом клиенте, а не переклассификацией уровня.
|
||||
|
||||
## Внешние сервисы: логируем все вызовы
|
||||
|
||||
**Каждый** вызов внешнего сервиса логируется — это единственный способ
|
||||
отличить «у нас баг» от «зависимость легла». Поля: `ext.service`,
|
||||
`ext.operation` (логическая операция, не URL), `ext.status_code`,
|
||||
`duration_ms`, `retry`.
|
||||
|
||||
Уровни:
|
||||
|
||||
- `INFO` — успешный **событийный** вызов;
|
||||
- `DEBUG` — успешный **рутинно-частый** вызов (поллинг, авто-рефреш);
|
||||
- `WARN` — попытка не удалась, делаем retry;
|
||||
- `ERROR` — ретраи исчерпаны, сервис недоступен.
|
||||
|
||||
Завершённый HTTP-ответ с 4xx — это **успех на транспортном уровне**
|
||||
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
||||
вызывающий. Тело запроса и ответа — только на `DEBUG` и после вычистки
|
||||
секретов.
|
||||
|
||||
## HTTP и healthcheck
|
||||
|
||||
- Входящие запросы логируем с `http.*` и `duration_ms` на **`INFO`**: это
|
||||
аудит обращений, а не отладка. Уровень не понижается из-за кода ответа —
|
||||
4xx остаётся `INFO`-записью доступа; решение «это ошибка» принимает
|
||||
доменный слой и пишет свою запись.
|
||||
- Для корреляции запроса допустим `request_id` — это отдельный слой от
|
||||
корреляции по сущности и не противоречит отказу от `trace_id`.
|
||||
- **Healthcheck, liveness, readiness — `DEBUG`.** Их дёргают периодически,
|
||||
на `INFO` они забивают аудит; в проде с базовым `INFO` они не пишутся.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
Никаких секретов в полях и сообщениях: пароли и cookie сессий, API-ключи и
|
||||
токены, `Authorization`-заголовки, аутентификационные параметры в ссылках.
|
||||
|
||||
- Тела ответов внешних API и сырой вывод LLM (недоверенный, может быть
|
||||
большим) — только на `DEBUG`, с вычисткой и обрезкой по длине.
|
||||
- При сомнении — не логируем значение, логируем факт его наличия
|
||||
(`"has_api_key", true`).
|
||||
- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.**
|
||||
`*url.Error` встраивает полный URL запроса, а секрет может жить прямо в
|
||||
нём: токен в пути, `api_key` в query. Go редактирует только пароль из
|
||||
userinfo, остального не трогает. Санитизируем на границе клиента **до**
|
||||
лога и обёртки: разворачиваем `*url.Error` в первопричину. Цена —
|
||||
теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на причину
|
||||
сохраняется); альтернатива с редактированием URL сохранила бы структуру,
|
||||
но сложнее. Порядок важен: санитизация идёт **раньше** трансляции ошибки
|
||||
в доменную (`lang/go/errors.md`), иначе секрет уедет в обёртку.
|
||||
- Общее правило: **секрет не кладём в URL, если у API есть заголовок** —
|
||||
тогда его нет и в ошибке транспорта.
|
||||
|
||||
<!-- local:секреты -->
|
||||
<!-- /local -->
|
||||
|
||||
## Куда пишем
|
||||
|
||||
- JSON в `stdout` одним потоком; сбор и ротацию делает окружение (docker,
|
||||
journald). По файлам не маршрутизируем.
|
||||
- Базовый уровень в проде — `INFO`, `DEBUG` включается конфигом. dev —
|
||||
`DEBUG`.
|
||||
|
||||
## Анализ
|
||||
|
||||
- Повседневно — `jq`: `jq 'select(.download_id=="a1b2")' app.jsonl`.
|
||||
- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
# Время: реализация на Go
|
||||
|
||||
## Единая точка
|
||||
|
||||
- «Сейчас» берём у слоя хранилища — `store.Now()`, а не `time.Now()` по
|
||||
коду. Ценность точки — **гарантированный UTC и один формат**: `Now()`
|
||||
возвращает `time.Now().UTC()`, и ни одна ветка кода не может об этом
|
||||
забыть. Побочно это единственное место, которое придётся превратить в
|
||||
переменную или поле, если однажды понадобится подменять часы в тестах, —
|
||||
но само по себе оно тестируемости не даёт.
|
||||
- Форматирование и разбор — `store.FormatTime` / `store.ParseTime` поверх
|
||||
`time.RFC3339`.
|
||||
- Запрет прямого `time.Now()` механизируется `forbidigo`. Исключений
|
||||
ровно два, и оба обязаны быть прописаны, иначе конвенция противоречит
|
||||
сама себе: сама точка `Now()` и обёртка измерения длительности (ниже).
|
||||
|
||||
## Точность и разбор
|
||||
|
||||
- В БД — **секундная точность**, ширина 20 символов
|
||||
(`2026-06-28T11:23:45Z`). Она получается сама: layout `time.RFC3339` не
|
||||
содержит долей секунды, поэтому `Format` их не выведет.
|
||||
- `time.RFC3339Nano` не используем: он отбрасывает хвостовые нули и ломает
|
||||
фиксированную ширину.
|
||||
- `time.Parse(time.RFC3339, …)` принимает и доли, и не-`Z` офсеты, то есть
|
||||
канонический вид гарантирует **писатель**, а не читатель. Для одного
|
||||
писателя этого достаточно; чужой вход нормализуем явно.
|
||||
- В драйвер отдаём строку из `FormatTime`, а не `time.Time`: колонка —
|
||||
`TEXT`, и промежуточное преобразование драйвером нам не нужно.
|
||||
|
||||
## Логи
|
||||
|
||||
`slog` по умолчанию **не даёт UTC**: встроенные хендлеры пишут время в зоне
|
||||
самого `time.Time`, то есть в локальной зоне процесса, — на ноутбуке
|
||||
разработчика логи молча поедут в `+03:00`. UTC ставится `ReplaceAttr` по
|
||||
`slog.TimeKey`:
|
||||
|
||||
```go
|
||||
func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
a.Value = slog.TimeValue(a.Value.Time().UTC())
|
||||
}
|
||||
return a
|
||||
}
|
||||
```
|
||||
|
||||
`JSONHandler` пишет миллисекунды — три знака, фиксированная ширина. Это
|
||||
другая точность, чем в БД, и это нормально: ширина фиксируется на носитель
|
||||
(см. базу).
|
||||
|
||||
## Длительность
|
||||
|
||||
Обёртка измерения — **легитимное исключение из запрета `time.Now()`**, и
|
||||
без него не обойтись: `store.Now()` приводит время к UTC через `.UTC()`, а
|
||||
это **срезает монотонную составляющую** `time.Time`. Интервал, посчитанный
|
||||
по таким меткам, зависит от подводки часов. Поэтому обёртка берёт
|
||||
`time.Now()` напрямую и считает `time.Since` — с локальным `//nolint`.
|
||||
|
||||
## Зоны
|
||||
|
||||
`time/tzdata` импортируется в `main`, зона отображения валидируется
|
||||
загрузчиком конфига — см. `lang/go/config.md`. Применяется она только в
|
||||
шаблонах и форматтерах UI; календарные вычисления бизнес-логики берут зону
|
||||
явно, как описано в базе.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
Reference in New Issue
Block a user