остальные конвенции переведены на формальный язык

- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у
  каждого модальность и обязательный блок «Почему»
- классифицирующие места оформлены таблицами, файловый статус снят
  отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
av
2026-07-25 19:17:32 +03:00
parent 7701a28df1
commit 31d0620f55
11 changed files with 2404 additions and 811 deletions
+178 -43
View File
@@ -1,26 +1,94 @@
---
status: рекомендуемая
extends: arch/config.md
---
# Конфигурация: реализация на Go
Как `arch/config.md` выглядит в Go-приложении.
Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы
запрета на окружение. Форма записи — `common/language.md`.
## Формат и загрузчик
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
проверка их непустоты идёт вместе с остальной валидацией — как описано в
базовой конвенции.
- TOML. Разбор и валидация — целиком в `internal/config`; наружу отдаётся
готовая структура `Config`.
- Одна корневая структура `Config` с под-структурами по секциям — имена
структур совпадают с именами секций, чтобы конфиг и код читались рядом.
- Умолчания — в `Default()`, поверх накладывается разобранный файл.
- Флаг `--config=path` переопределяет путь; по умолчанию `config.toml` в
рабочей директории, образец — `config.example.toml`.
## Правила
## Длительности
### R1. Формат конфигурации — TOML
`time.Duration` не разбирается из строки TOML сама по себе — нужен свой тип
с `UnmarshalText`, отдающий `time.Duration`:
**ДОЛЖЕН.** Конфиг — файл TOML.
**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор
делается один раз на язык, а не в каждом приложении: разные форматы в
соседних сервисах означают разные загрузчики, разные шаблоны рендера
конфига в деплое и разное поведение при синтаксической ошибке. TOML при
этом даёт секции и типизированные скаляры без значимых отступов — конфиг,
поправленный руками на сервере, ломается заметно, а не меняет вложенность
молча.
### R2. Разбор и валидация — целиком в `internal/config`
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
**Почему.** Пока значение не покинуло пакет, оно может быть невалидным;
после — уже нет, и это единственная граница, на которой такое утверждение
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
«проверено ли это поле» только чтением всех вызывающих, часть полей
неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ
посреди работы. Экспортированный разбор вдобавок даёт второй способ
получить конфиг — мимо умолчаний (R5).
### R3. Весь конфиг — одна корневая структура
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
под-структур по секциям.
**Почему.** Один корень даёт одну точку, после которой конфиг проверен
целиком, и дальше передаётся как обычный аргумент. Несколько независимых
структур конфига означают несколько загрузок и вопрос «какая из них уже
провалидирована» на каждом использовании; связанные между собой поля
(включена интеграция — заданы все её поля) при этом перестают быть
проверяемыми в одном месте.
### R4. Под-структуры названы по секциям файла
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит
в код и обратно; при расхождении связь между полем файла и полем структуры
восстанавливается чтением тегов, и проделывать это приходится для каждой
секции заново.
### R5. Умолчания задаёт `Default()`
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
накладывается поверх.
**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание,
подставленное по месту использования (`if x == 0 { x = … }`), поэтому не
видно ни целиком, ни из образца, и два потребителя одного поля со временем
подставляют разное. `Default()` — единственное место, откуда список
умолчаний читается разом и переносится в образец.
### R6. Имя файла фиксировано, путь переопределяется флагом
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
путь переопределяет флаг `--config=path`, образец рядом —
`config.example.toml`.
**Почему.** Фиксированное имя и переопределение из командной строки требует
базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во
всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску
пишутся, не открывая код приложения. Соседство `config.toml` и
`config.example.toml` вдобавок делает расхождение образца с реальным
конфигом видимым обычным `diff`, а не вычиткой.
### R7. Длительности — собственный тип с `UnmarshalText`
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
`time.Duration`:
```go
type Duration time.Duration
@@ -29,50 +97,117 @@ func (d *Duration) UnmarshalText(b []byte) error { … }
func (d Duration) Std() time.Duration { }
```
Так в конфиге видна единица измерения (`poll_interval = "5s"`), а не голое
число. Цена: ошибка в длительности всплывает **на разборе TOML**, до общей
валидации, поэтому в общий сбор проблем она не попадает — про неё узнаёшь
отдельно и первой.
**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое
число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не
знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз
проходит и разбор, и проверку диапазона, а проявляется нагрузкой или
зависшим ожиданием. Запись `poll_interval = "5s"` несёт единицу измерения
в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
## Чтение окружения
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
показываются.
Приложение не читает окружение для конфигурации. Механизируется
`forbidigo`, и паттерн должен покрывать **все** входы, а не только
### R8. Приложение не читает окружение
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
**Почему.** Второй канал конфигурации — то, против чего написана базовая
конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого
пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию.
Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как
чтением всего кода — а узнают о нём обычно на сервере, где переменная не
выставлена.
### R9. Проверка запрета покрывает все входы в окружение
**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только
`os.Getenv`:
```
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
```
Правило про приложение, поэтому за его границей запрет не действует:
**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет
незаметно: правило числится механизированным, и глазами его больше никто не
проверяет.
- **тесты** — не приложение: интеграционному тесту нормально брать
креды внешнего сервиса из окружения;
- **переменные рантайма** — те, что читает не наш код, а Go или ОС
(`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`).
### R10. За границей приложения запрет не действует
Отдельный случай — переменные, которые читает **стандартная библиотека от
имени приложения**: дефолтный `http.Transport` уважает
`HTTP_PROXY`/`HTTPS_PROXY`. Формально их читает не наш код, но это
конфигурация поведения приложения, поэтому прокси задаётся полем конфига и
явным `Transport`, а не окружением.
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
приложение:
## Валидация
| № | Кто читает | Вердикт |
|---|---|---|
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
- Проверки собираются `errors.Join`, чтобы за один запуск показать **все**
проблемы конфига, а не первую.
- IANA-зона валидируется `time.LoadLocation`. База зон встраивается
импортом `_ "time/tzdata"` **в `main`**, а не в библиотечном пакете:
иначе ~450 КБ zoneinfo навязываются каждому импортёру. Со встроенной
базой ошибка `LoadLocation` означает битое имя зоны, а не отсутствие
zoneinfo в контейнере.
- Невалидный конфиг — `slog` уровня `ERROR` и `os.Exit(1)` из `main`, до
старта серверов и воркеров.
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
механизм. Явное разрешение нужно и потому, что нерасписанная граница
лечится `//nolint` наугад: там, где легальные случаи приходится глушить
руками, вместе с ними проходят и нелегальные.
## Секреты
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY`
Go-специфики нет: секреты приходят из деплоя уже в файле, проверка их
непустоты идёт вместе с остальной валидацией — см. базу.
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут
исходящих запросов отличается от машины к машине без единого следа в
конфиге и в образце, а расследование начинается с вопроса «почему на
сервере ходит не так, как локально».
### R12. Проблемы конфига собираются `errors.Join`
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
ошибка, собранная `errors.Join`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию
перезапусков по одному полю за раз, причём каждый следующий запуск
обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
вложенной проблеме.
### R13. Имя зоны проверяется `time.LoadLocation`
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
тогда, когда база зон его знает, и никакая проверка формата не отличит
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
доживает до первого форматирования времени — то есть до рантайма, мимо
fail-fast (R15).
### R14. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
пакете.
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
полагаться на системную» принадлежит собираемой программе. Со встроенной
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без
неё тот же конфиг валиден на машине разработчика и падает в контейнере без
zoneinfo, а сообщение указывает не на ту причину.
### R15. Невалидный конфиг — `ERROR` и выход из `main`
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
старта серверов и воркеров.
**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
оставляет вызывающему возможности ни залогировать причину, ни дописать
контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
во внешний сервис и записать в базу от имени процесса, который потом
объявит, что не стартовал.
<!-- local:поля -->
<!-- /local -->
+110 -30
View File
@@ -1,47 +1,127 @@
---
status: рекомендуемая
extends: arch/db-identifiers.md
---
# Идентификаторы: реализация на Go
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID.
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID
(ветка `arch/db-identifiers.md` R1.1). Форма записи — `common/language.md`.
## Единая точка `internal/ident`
Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
- `ident.NewID()` — генерация. **PK сущности** генерируется в `Create`-методах
слоя `store`. Прочие идентификаторы (батч, задание, корреляционный ключ)
генерируются там, где начинается операция, — но тоже только через `ident`.
- `ident.NewIDAt(t)` — генерация с заданным временем, для бэкфилла в
Go-миграциях: сортировка id тогда сохраняет историческую хронологию, а не
момент прогона миграции.
- `ident.Parse()` — разбор и нормализация; зовётся на **входных границах**
(HTTP-роут, форма, callback бота), до обращения к store.
- Других генераторов и парсеров id в коде нет. Это то самое «единая точка»
из базовой конвенции; без него нормализация регистра неизбежно
где-нибудь пропускается.
## Правила
## Типы
### R1. Генерация и разбор — только через `internal/ident`
В структурах store и домена id — обычный `string`. Отдельный тип `ID`
заводим, только если появится вторая семья идентификаторов, которую можно
перепутать; до этого он даёт конверсии без выгоды. От перепутывания двух id
одной семьи в сигнатуре он всё равно не спасает — там помогают имена
параметров.
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
`internal/ident`; других генераторов и парсеров id в коде нет.
## Невалидный id на границе
**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
выглядит нарушением: значение получается валидное, просто мимо нормализации
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
библиотеки где-либо, кроме `internal/ident`, находится поиском по имени
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
перестанет находить существующую запись.
Разбор не удался — дальше зависит от того, откуда id пришёл:
### R2. Первичный ключ генерируется в `Create`-методах store
- **из пути или query URL** — сразу 404, без обращения к store и без
фабрикации доменной ошибки: снаружи это неотличимо от несуществующей
записи, и хорошо;
- **из собственной формы или callback-данных кнопки** — 400 либо понятное
сообщение («кнопка устарела»): это баг интерфейса или протухший экран, и
под «не найдено» его маскировать нельзя.
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
внутри `Create`-метода слоя store.
Транспорт не создаёт доменные sentinel'ы, чтобы тут же их сматчить, — это
инверсия правила «трансляция у источника» из `lang/go/errors.md`.
**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было
известно до вставки, но не говорит, кто его присваивает. Store — последний
слой, через который проходят все пути создания строки, включая импорт,
фоновые задания и тесты. Генерация выше по стеку делает присвоение
обязанностью каждого нового вызывающего, и первый забывший запишет пустую
строку в колонку ключа: для строкового PK это валидное значение, база его
не отклонит, и дефект обнаружится на второй такой вставке.
### R3. Прочие идентификаторы генерируются в точке начала операции
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
вызовом `ident.NewID()` там, где операция начинается.
**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) —
сшивать записи лога всей операции. Созданный ниже по стеку или в момент
первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
когда операция упала до того, как что-либо записала: без общего ключа эти
записи из лога не собираются вообще.
### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
Go-миграции, порождаются с историческим временем строки, а не с текущим.
**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не
момент прогона миграции. Иначе все затронутые строки получают метку одного
момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id`
начинает врать ровно на том массиве данных, который старше всего.
Исправить это потом нельзя: исходное время в идентификаторе не
восстановить.
### R5. Разбор — на входных границах, до обращения к store
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
callback'а бота — раньше, чем идентификатор попадёт в store.
**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана
транспортная, потому что только на ней известен источник значения, от
которого зависит реакция (R8): store видит одинаковую строку независимо от
того, пришла она из URL или из собственной формы, и ответить по-разному
оттуда уже невозможно.
### R6. Id в структурах — обычный `string`
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
`string`.
**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им
ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и
`authorID`) он не спасает — оба будут одного типа, и различают их имена
параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
json и шаблонами, то есть даёт цену без выгоды.
### R7. Отдельный тип — когда появляется вторая семья идентификаторов
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
можно перепутать, для них заводятся различимые типы.
**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на
типизацию навсегда. Условие названо ровно то, при котором тип начинает
работать: пока все идентификаторы — `string`, подстановка одного вида
вместо другого компилируется и обнаруживается только на данных.
### R8. Реакция на невалидный id зависит от источника
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
значение:
| № | Источник | Ответ |
|---|---|---|
| R8.1 | путь или query URL | 404 без обращения к store |
| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах
HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи —
и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2
значение сформировало само приложение, и невалидность означает баг
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
в логах не оставляет аномалии и тем самым съедает единственный момент,
когда дефект заметен.
### R9. Транспорт не создаёт доменные ошибки
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
**Почему.** Инверсия правила «трансляция у источника» из
`lang/go/errors.md`. Sentinel — сообщение от слоя, который знает факт:
строка не найдена, потому что store её искал. Сфабрикованный транспортом,
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
вообще поход в хранилище — а на этом держится вся диагностика по ошибкам.
<!-- local:механизировано -->
<!-- /local -->
+172 -33
View File
@@ -1,44 +1,183 @@
---
status: рекомендуемая
---
# Схема и миграции (SQLite, Go)
Область действия — **новые миграции**. Существующая схема не переписывается;
линтер проверяет то, что добавляется, а не то, что уже лежит.
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
Go-приложении. Форма записи — `common/language.md`.
## Область действия
Схема меняется тяжело: таблица не переезжает от того, что её потрогали.
Правила распространяются на **новые миграции**; существующая схема не
переписывается, и проверяется граница изменения — то, что миграция
добавляет, а не то, что уже лежит в базе.
## Миграции
- Инструмент — goose, файлы миграций лежат рядом со store-слоем.
- **SQL-файл** для DDL: создание таблиц, индексы, изменение структуры.
- **Go-миграция** (`goose.AddMigrationContext`) — когда нужен код:
генерация идентификаторов, backfill, перенос данных между формами.
Не пытаемся выразить это SQL-ом ради единообразия.
- **В деплое движение только вперёд.** Down-миграция — инструмент
разработки, а не отката на сервере.
- **Down пишется, когда он честно обращает up**: убрать то, что up добавил.
Не пишется, когда up необратимо трансформирует данные, — тогда его
отсутствие честнее имитации, которая молча теряет колонку.
- При изменении структуры ER-схема в спеках обновляется **в том же
изменении**, а не «потом»: разошедшаяся схема хуже отсутствующей.
### R1. Миграции ведёт goose
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
goose.
**Почему.** Журнал применённых версий goose держит в самой базе
(`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй
инструмент заводит второй журнал: миграция, применённая одним, для другого
выглядит неприменённой, и попытка накатить её повторно упирается в уже
существующую таблицу. На сервере это означает ручной разбор состояния
схемы вместо автоматического деплоя.
### R2. Файлы миграций лежат рядом со store-слоем
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
схемой.
**Почему.** Миграция и код, читающий схему, — одно изменение: колонка
появляется вместе с полем структуры и запросом. Лежащие в другом конце
дерева миграции выпадают из поля зрения при правке store, и уезжает либо
код без миграции, либо миграция без кода; расходятся они на сервере, где
схема ещё старая.
### R3. Форма миграции выбирается по тому, нужен ли код
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
| № | Что делает миграция | Форма |
|---|---|---|
| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
ошибиться, не добавляя ничего к результату.
Обратное направление дороже. Перенос данных и генерация идентификаторов
выражаются на SQL либо громоздко, либо неточно: идентификатор по
`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена
завести для него второй генератор — ровно то, что запрещает
`arch/db-identifiers.md` R3. Единообразие формы здесь покупается
дублированием логики, которая уже есть в коде.
### R4. В деплое схема движется только вперёд
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
ошибка исправляется новой миграцией вперёд.
**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует
его: колонка, которую убрал up, восстанавливается пустой, а строки,
записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом
происходит молча — миграция отчитывается об успехе. Исправление, приехавшее
следующей миграцией, оставляет целыми и данные, и журнал применённых
версий.
### R5. Down пишется, когда он честно обращает up
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
| № | Что делает up | Down |
|---|---|---|
| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| R5.2 | необратимо преобразует данные | не пишется |
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
и именно там он обязан действительно обращать up. Имитация опаснее
отсутствия: разработчик применяет её, получает схему прежней формы и
продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
отладка по данным, которых уже нет.
### R6. ER-схема обновляется в том же изменении
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
изменением.
**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл.
Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить
это можно, только сверив её с миграциями, то есть проделав работу, которую
диаграмма экономит. Отложенное обновление не делается: изменение уже
влито, и повода вернуться к схеме больше нет.
## Типы колонок
- **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`); иначе автоинкремент допустим.
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
а не язык приложения.
### R7. Enum-поля — `TEXT`, допустимые значения держит код
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
без `CHECK`-ограничения на список значений.
**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной
версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))`
превращается из строки в коде в пересоздание таблицы по 12-шаговой
процедуре, с копированием данных и восстановлением внешних ключей.
Платить эту цену не за что: невалидное значение отсекается типами Go
раньше, чем дойдёт до вставки, и `CHECK` лишь дублирует защиту, которая
всё равно нужна выше. `TEXT` при этом читается в дампе и в логе без
таблицы соответствия, которую пришлось бы держать в голове для числового
кода.
### R8. Метки времени — `TEXT` в формате из `arch/time.md`
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
пишутся в формате из `arch/time.md`.
**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает
значения сравнимыми, — договорённость о формате. Текст в формате из
`arch/time.md` сортируется лексикографически в том же порядке, что и
хронологически: `ORDER BY` и диапазонные условия работают без функций
преобразования, а значит и без потери индекса. Соседство двух форматов в
одной колонке ломает и сравнение, и разбор на стороне Go.
### R9. Умолчание `DEFAULT (datetime('now'))` не ставится
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
на уровне схемы.
**Почему.** Время ставит приложение, и умолчание в схеме заводит второй
источник этого значения: пропущенное приложением поле не падает, а тихо
получает время сервера базы — расхождение обнаруживается по данным, а не
по ошибке.
Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`,
то есть не тот формат, которого требует R8. В колонке оказываются строки
двух видов, и ломается ровно то, ради чего формат выбран.
### R10. Булевы поля — `INTEGER` со значениями 0 и 1
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя
колонку удерживает только договорённость о представлении. Цена ошибки
здесь несимметрична: строка `'true'` в булевом контексте приводится к
**0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
проверяют, что список не пуст.
### R11. Вид первичного ключа задаёт `arch/db-identifiers.md`
**ДОЛЖЕН.** В репозитории, подписанном на `arch/db-identifiers.md`, вид
ключа выбирается по её R1, и `AUTOINCREMENT` в миграции не пишется.
**Почему.** Вопрос о виде ключа решается один раз на репозиторий
(`arch/db-identifiers.md` R1). Повторив здесь его ветвление, мы завели бы
второй источник правды, и соседние таблицы разъехались бы по разным
ответам на один и тот же вопрос.
`AUTOINCREMENT` не нужен ни в одной из веток R1. Строкового ключа он не
касается вовсе, а целочисленному даёт единственную гарантию — что значение
rowid не будет переиспользовано после удаления строки, — ценой служебной
таблицы `sqlite_sequence` и записи в неё на каждой вставке. Гарантия эта
имеет смысл, только если старые идентификаторы живут где-то вне базы.
### R12. Вне `arch/db-identifiers.md` первичный ключ — автоинкремент
**ДОПУСКАЕТСЯ.** Репозиторий, не подписанный на `arch/db-identifiers.md`,
берёт целочисленный автоинкрементный ключ.
**Почему.** Явное разрешение нужно, чтобы R11 не читался как требование
подписаться на `arch/db-identifiers.md`. Выбор вида ключа — решение уровня
репозитория, и конвенция про типы колонок его за репозиторий не принимает;
приложению, сущности которого не адресуют снаружи, целочисленный ключ
ничего не стоит.
<!-- local:механизировано -->
<!-- /local -->
+283 -101
View File
@@ -1,140 +1,322 @@
---
status: рекомендуемая
---
# Ошибки
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
**логировать** — в `lang/go/logging.md`, раздел «Ошибки» (коротко: лог один
раз на доменной границе).
Как ошибки строятся, оборачиваются и проверяются. Форма записи —
`common/language.md`. Где и когда ошибку **логировать** — в
`lang/go/logging.md` (коротко: лог один раз на доменной границе).
## Базовая идиома: stdlib
## Правила
- Только стандартный `errors` + `fmt.Errorf`. Контекст ошибки несёт `slog`,
а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка
сообщений локализует место не хуже стека, а стек-трейсы и Sentry
избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это
сигнал пересмотреть решение, а не дефолт, который можно обойти локально.
- Единственное исключение — восстановленная паника: у неё цепочки `%w` нет
вовсе (см. «panic»).
### R1. Ошибки строятся средствами стандартной библиотеки
## Обёртка и контекст
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
Сервис — **приложение, а не библиотека**: внешнего Go-API нет, весь код
наш. Возражение против дефолтного `%w` («обёрнутая ошибка становится частью
API») относится к библиотекам, поэтому внутри приложения обёртка `%w`
**дефолт**, чтобы `errors.Is` и `errors.As` работали сквозь слои.
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
это цена без покупателя.
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
- `%w` — когда вызывающий может инспектировать причину (обычный случай).
`%v` — когда причину сознательно **не** раскрываем, чтобы не завязывать
вызывающего на чужой тип ошибки.
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в
цепочке, а трансляцией на внешней границе (ниже).
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
неё цепочки `%w` нет вовсе (R23).
Стиль сообщения:
### R2. Дефолт не обходится точечно
- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка
и так читается как «контекст: причина»;
- контекст — операция или субъект: `"link target: %w"`, не
`"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяя нижний
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
кодовой базы ради конкретной отладки.
**Почему.** В коде появляются два способа устроить ошибку, и вызывающий
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
локально, перестаёт накапливаться — а накопление и есть единственный
сигнал, что решение R1 пора пересматривать целиком.
### R3. Каждый слой добавляет свой контекст
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько,
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
кода.
### R4. Обёртка по умолчанию — `%w`
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
вызывающему:
| № | Ситуация | Глагол |
|---|---|---|
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
| R4.2 | причину сознательно не раскрываем | `%v` |
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
становится частью API» — относится к библиотекам с внешними потребителями.
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v`
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
ошибку наружу.
**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
Настоящее место защиты — R13.
### R6. Текст обёртки — со строчной буквы и без служебных слов
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
на середине. Слова «failed» и «error» не несут информации: то, что перед
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
каждом уровне и вытесняют из строки полезный контекст.
### R7. Контекст обёртки называет операцию или субъект
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
бы занять единственный полезный здесь факт — имя операции.
### R8. Слой не повторяет смысл нижнего
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
же событие названо дважды. Читателю приходится проверять, не два ли это
разных места в коде, — то есть заикание не просто бесполезно, оно стоит
времени при каждом чтении лога.
## Две трансляции
Ошибка меняет форму дважды, и это разные преобразования.
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
доменная у источника (R9) и доменная → пользовательская на внешней границе
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
**Первая — у источника, инфраструктурная → доменная.** Граничные ошибки
зависимостей транслируем там, где они возникли: `sql.ErrNoRows` → доменный
`store.ErrNotFound` в слое store, чтобы выше по коду не торчал
`database/sql`. То же для HTTP-клиентов, файловой системы, внешних SDK.
### R9. Инфраструктурная ошибка транслируется в доменную у источника
**Вторая — на внешней границе, доменная → пользовательская.** Описана
ниже, в разделе про каналы.
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для
HTTP-клиентов, файловой системы, внешних SDK.
## Sentinel vs типизированные
**Почему.** Иначе тип зависимости становится частью контракта всех слоёв
выше: чтобы отличить «нет записи», доменный код импортирует `database/sql`
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
адаптер, а все ветвления в приложении — притом что снаружи адаптера
состояние «нет записи» одно и то же. Трансляция у источника оставляет
знание о зависимости в единственном слое, который её и так знает.
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
на которые ветвится код: нет записи, дубликат, неподдерживаемый источник.
Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями и методом `Error()`) — когда
вызывающему нужны **данные** ошибки: поле валидации, код, лимит. Достаём
`errors.As`. Не плодим типы там, где хватает sentinel.
- Матчинг по тексту сообщения запрещён — это то же самое, что публичный
API из строки лога.
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
## Граница: приватный канал vs публичный
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
| № | Что нужно вызывающему | Форма |
|---|---|---|
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на
каждой проверке. Две формы для одного условия — это два способа его
проверить, и про второй рано или поздно забудут.
### R11. Матчинг по тексту сообщения
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его
свободно. Правка формулировки в нижнем слое молча ломает ветвление
наверху, и компилятор этого не видит. Это то же самое, что публичный API из
строки лога.
## Граница: приватный канал и публичный
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от
того, кто канал видит.
того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
публичный — пользовательские поверхности (HTTP API, web-UI, бот).
**Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
цепочкой `%w` и контекстом. Пишется один раз на доменной границе.
### R12. Полная ошибка идёт в приватный канал
**Публичный канал — пользовательские поверхности** (HTTP API, web-UI, бот).
Сюда отдаём:
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
`lang/go/logging.md`.
- **человекочитаемое сообщение** по доменной ошибке — не сырой
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
- **корреляционный ключ** для владельца — id сущности либо `request_id`,
чтобы по нему найти полную ошибку в логах. «При обработке загрузки
произошла ошибка, download_id=…» вместо «произошла ошибка»;
- **маппинг доменной ошибки → сообщение и, для HTTP, статус** — в одной
точке на все транспорты. У транспортов без статусов (бот) от маппинга
берётся только сообщение.
**Почему.** Цепочка — единственный носитель диагностики (R1), и
единственный канал, где её можно показать целиком, — тот, который видит
владелец. Не записанная там, она не сохранится нигде: наружу идёт
нейтральное сообщение (R13), и восстанавливать причину будет не из чего.
Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и
**сразу добавляем в маппинг** — иначе `default` отдаст 500 «внутренняя
ошибка» на нормальный конфликт, а логирующая граница спишет его в `ERROR`
вместо `DEBUG`.
### R13. Публичная поверхность получает сообщение по доменной ошибке
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
— у него есть лог (R12). Зато они раскрывают устройство системы — имена
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
причём раскрывают именно в момент, когда что-то пошло не так.
### R14. Публичное сообщение несёт корреляционный ключ
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
ошибка».
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
видел.
### R15. Маппинг доменных ошибок — в одной точке на все транспорты
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
задаётся один раз; транспорт без статусов (бот) берёт из него только
сообщение.
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
важнее: единственная точка — это место, куда механически дописывается новая
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде»
ничем не проверяет.
### R16. Новая штатная ветвь отказа сразу попадает в маппинг
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
добавляется в маппинг (R15) тем же изменением.
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают
шуметь в логе ровно там, где по нему ищут настоящие поломки.
<!-- local:маппинг -->
<!-- /local -->
### Транзиентный ответ vs персистентная диагностика
### R17. Форма текста определяется поверхностью
У публичной границы две разные поверхности, и правило сырого текста для них
разное:
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
текста для них разное:
- **Транзиентный ответ на действие** (тело ответа, `?err=`, реплика бота по
результату команды) — строго нейтральный: маппинг выше, `err.Error()`
наружу не идёт, полная ошибка живёт в логах по корреляционному ключу.
- **Персистентная диагностика состояния** — причина ухода записи в
ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь
сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и
полезен — **но только пока поверхность видит исключительно владелец**.
Появился второй зритель или публичный доступ к экрану состояния —
поверхность стала публичным каналом, и правило нейтрального текста
распространяется на неё. Секреты запрещены абсолютно в обоих случаях;
источник вычищается на границе клиента.
| № | Поверхность | Текст ошибки |
|---|---|---|
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт |
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
Различие работает, только если поверхности не смешиваются в одном поле.
Диагностику кладём в **отдельное поле**, а не в доменное.
Появился второй зритель или публичный доступ к экрану состояния —
поверхность стала публичным каналом, и на неё распространяется R17.1.
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот
эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла
ошибка» в таком поле не несёт ничего и делает поле бессмысленным. Условие
про единственного зрителя — ровно то, что делает вторую поверхность
приватным каналом; без него это обычная публичная поверхность.
### R18. Секретов нет ни на одной из поверхностей
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
ни в персистентную диагностику; источник вычищается на границе клиента.
**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в
БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию
самого секрета. Вычистка на границе клиента — единственное место, где ещё
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
отличить в нём токен от идентификатора уже нельзя.
### R19. Диагностика хранится в отдельном поле
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
показывают пользователю.
**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей
разные поля. Одно поле на оба назначения означает, что при первом же показе
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
одно.
## panic
- `panic` — только для невосстановимого: нарушенный инвариант (баг
программиста), ошибка инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
ввод) — это значения `error`.
- **`recover` — на верхней границе каждой обрабатывающей единицы**, а не
только у HTTP:
- HTTP middleware — `net/http` сам восстанавливает панику в хендлере и
процесс не роняет, поэтому смысл своего `recover` в другом: отдать
контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер;
- цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в
горутине **действительно роняет процесс**, и `recover` обязателен.
`recover` работает только в той горутине, где случилась паника.
- **Логирующая recover-граница пишет `debug.Stack()`.** Это единственное
место, где нужен стек-трейс: у восстановленной паники нет цепочки `%w`, и
без стека «index out of range» не диагностируется вообще.
### R20. `panic` — только для невосстановимого
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
ошибка инициализации, из которой нельзя стартовать.
**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте
нельзя, можно только уронить единицу обработки. Это верный ответ, когда
состояние процесса перестало описываться кодом: работа с нарушенным
инвариантом опаснее падения, а сервис, стартовавший без обязательной
зависимости, всё равно откажет позже и непонятнее.
### R21. Ожидаемые ошибки — значения `error`
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
плохой ввод, отсутствующая запись возвращаются как `error`.
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
его обработать. Дальше такая паника долетает до recover-границы (R22), где
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
«мы сломались».
### R22. `recover` — на верхней границе каждой обрабатывающей единицы
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
| № | Единица | Зачем `recover` |
|---|---|---|
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
**Почему.** `recover` работает только в той горутине, где случилась паника,
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
неожиданным полем гасят весь сервис, включая части, к этой ошибке
отношения не имеющие. У HTTP цена бездействия ниже, но не нулевая: паника
без своего `recover` уходит мимо структурированного лога, а клиент получает
оборванное соединение вместо ответа.
### R23. Recover-граница пишет `debug.Stack()`
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной
паники цепочки `%w` нет вовсе. «index out of range» без стека не
диагностируется в принципе — сообщение не называет ни файла, ни операции,
по нему нельзя сказать даже, в каком пакете упало.
## Несколько ошибок
Сбор независимых ошибок (валидация конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
### R24. Независимые ошибки собираются `errors.Join`
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
разом; проверка собранного — по-прежнему через `errors.Is`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
тот же список, но убивает ветвление: `errors.Is` по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
(R11).
## Связано
- `lang/go/logging.md` — где и когда ошибка попадает в лог.
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14.
<!-- local:механизировано -->
<!-- /local -->
+465 -157
View File
@@ -1,5 +1,4 @@
---
status: рекомендуемая
extends: arch/time.md
---
@@ -7,164 +6,404 @@ extends: arch/time.md
Как и когда писать логи. Это правила оформления кода (How), а не
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
функциональности, живут в спеках.
функциональности, живут в спеках. Форма записи — `common/language.md`.
## Принципы
- Структурированный JSON (`slog.JSONHandler`), **один формат для dev и
prod**. Не потому, что текстовый вывод «расходит поля» — смена хендлера
структуру атрибутов не меняет; а потому, что с текстовым dev-выводом
перестаёшь ежедневно гонять собственные `jq`-пайплайны, и поломки
словаря замечаются только в проде.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
отдельный ключ с типизированным значением: это даёт фильтрацию и
агрегацию через `jq`/DuckDB без регулярок.
Лог читают инструментами, а не глазами: повседневно — `jq`
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
DuckDB поверх JSONL прямо из файла. Отсюда почти все правила ниже: запись
существует для запроса к ней.
```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` так и должно
быть: ширина фиксируется на носитель.
### R1. Структурированный JSON, один формат для dev и prod
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
проде.
**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена
хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым
dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и
поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное
значение) обнаруживаются только в проде, где заметить их заранее уже
некому.
### R2. Данные — в типизированных полях, а не в тексте сообщения
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная
в текст, достаётся только регуляркой, а регулярка ломается при первой же
правке формулировки. Тип важен отдельно от ключа: число внутри строки не
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
### R3. Время записи — UTC
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
(см. `lang/go/time.md`).
**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время
в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного
процесса до и после смены TZ (или записи рядом с данными из БД) перестают
складываться в одну хронологию, причём сдвиг на целые часы глазом не виден
— в отличие от явно неверной даты, он выглядит как правдоподобный порядок
событий.
Точность `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`. Какое именно состояние и по какой причине —
это данные, а не текст. Тогда весь жизненный цикл собирается одним
фильтром. Физический эффект сверх перехода — отдельная запись своей
категории, она не подменяет запись перехода.
### R4. `msg` — константа в нижнем регистре
**ДОЛЖЕН.** Текст сообщения не собирается из переменных:
`log.Info("download accepted", "download_id", id)`.
**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция
превращает одну категорию в множество уникальных строк, и вопрос «сколько
раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы
одна категория не двоилась на варианты, различающиеся только заглавной
буквой.
### R5. `msg` не несёт префикса подсистемы
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
отдельное поле.
**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и
фильтр по подсистеме становится сопоставлением с началом строки вместо
сравнения значения поля. Заодно это второй способ записать одно и то же:
категория дробится на варианты с префиксом и без, а совпадать они обязаны
посимвольно.
### R6. Смена состояния сущности — единая категория
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
состояние и по какой причине — данные, а не текст.
**Почему.** С отдельной категорией на каждый переход жизненный цикл
сущности собирается перечислением всех известных `msg` — и переход,
добавленный в код позже, в это перечисление не попадёт: выборка тихо
останется неполной. Единая категория даёт весь цикл одним фильтром и не
требует обновлять запрос вслед за кодом.
### R7. Физический эффект — отдельная запись, а не вместо перехода
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
запись самого перехода.
**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
строки в логе; восстановление пропущенного перехода не стоит ничего, потому
что невозможно.
## Уровни
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько
### R8. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
громко сломалось».
| Уровень | Кому и когда |
|---|---|
| `DEBUG` | разработчику при отладке; в проде выключен |
| `INFO` | владельцу, аудит постфактум |
| `WARN` | владельцу, «может стать проблемой» |
| `ERROR` | владельцу, в разбор |
| № | Уровень | Кому и когда |
|---|---|---|
| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
| R8.2 | `INFO` | владельцу, аудит постфактум |
| R8.3 | `WARN` | владельцу, «может стать проблемой» |
| R8.4 | `ERROR` | владельцу, в разбор |
Правила:
**Почему.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
базовый порог в проде (R40), потому что он отсекает уже не то, что
задумано.
- Уровень **не зависит от подсистемы**: `ERROR` везде одинаково серьёзен.
- `WARN` ≠ «ничего страшного». `WARN` = «может стать проблемой». Если это
не «может» — это `INFO`.
- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
`DEBUG` (норма, разбирать нечего), а не `ERROR`.
- **Событийное → `INFO`, рутинно-частое → `DEBUG`.** Операция по реальному
действию или изменению — `INFO`. Повторяющаяся служебная операция,
запускаемая таймером или поллингом и сама по себе не несущая события
(healthcheck, опрос статуса, авто-рефреш UI), — `DEBUG`: на `INFO` она
зашумляет аудит.
- `slog` не разделяет CRITICAL/FATAL — фатальный сбой на старте логируем
`ERROR` и завершаем процесс с ненулевым кодом.
### R9. Уровень не зависит от подсистемы
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
везде одинаково серьёзен.
**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если
в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить
происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть
уровень перестаёт быть фильтром и становится подсказкой, требующей знания
кода.
### R10. `WARN` — только когда «может стать проблемой»
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится
«ничего страшного», его перестают читать — и вместе с шумом теряется то
единственное, ради чего уровень существует: предупреждение, на которое ещё
есть время отреагировать.
### R11. Событийное — `INFO`, рутинно-частое — `DEBUG`
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
| № | Операция | Уровень |
|---|---|---|
| R11.1 | по реальному действию или изменению | `INFO` |
| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность
определяется долей записей, за которыми что-то стоит. Периодическая
операция даёт ровный поток при нулевой информации, в котором настоящие
события тонут количественно: их не отфильтровать, потому что фильтровать
приходится по содержанию, а не по уровню.
### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую
степень даёт завершение процесса.
**Почему.** Супервизор (docker, journald, systemd) отличает падение от
штатной остановки по коду возврата, а не по уровню последней записи.
Процесс, который написал `ERROR` и продолжил жить с неработающей
конфигурацией, выглядит здоровым и будет получать трафик; изобретать же
уровень выше `ERROR` не нужно — сам факт завершения информативнее.
## Поля: единый словарь
Главное условие — **одно поле, одно имя по всему коду** (не
`mediaType`/`media`/`media_type` вперемешку).
### R13. Одно поле одно имя по всему коду
- Бизнес-поля — плоский `snake_case`.
- Системные домены — точечная иерархия (адаптация OpenTelemetry): `http.*`,
`ext.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
| Когда добавляем | Поля |
|---|---|
| входящий 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` одной
строкой при старте.
### R14. Форма имени зависит от вида поля
**ДОЛЖЕН.** Две формы, третьей нет.
| № | Вид поля | Форма имени |
|---|---|---|
| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
в любом проекте, от доменных, которые в каждом свои: по общему префиксу
запрос «все внешние вызовы» пишется без перечисления имён. Заимствование
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
названо, и спорить о них на каждом ревью.
### R15. Запись плоская
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
имени, а не уровень вложенности.
**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой
записи независимо от её категории. Вложенность требует знать глубину
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
весь лог, распадаясь на запрос под каждую форму записи.
### R16. Набор полей определяется ситуацией
**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком.
| № | Когда добавляем | Поля |
|---|---|---|
| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного |
| R16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
| R16.3 | запись об ошибке | `error` |
| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
баг», запись о сущности без идентификатора не корреллируется (R19). Полный
набор делает записи однородными — один запрос работает по всем вызовам, а
не по тем, где автор вспомнил про поле.
### R17. `service.*` и `host.*` не заводим
**НЕ СЛЕДУЕТ.** Пока это один бинарь на одном хосте.
**Почему.** Поле с одним и тем же значением во всех записях не несёт
информации, но стоит места в каждой строке и внимания при чтении. Условие
названо явно, поэтому правило отпадёт вместе со своей причиной: с
появлением нескольких инстансов различающее поле (`service.version`)
добавляется одной строкой при старте.
<!-- local:словарь -->
<!-- /local -->
## Корреляция по id сущности
## Корреляция
Отдельный случайный `trace_id` не заводим, **если у сущностей есть
стабильные уникальные идентификаторы** — они и служат ключом корреляции.
(Как их выбирают — `arch/db-identifiers.md`, если конвенция взята.)
### R18. Ключ корреляции — идентификатор сущности, а не `trace_id`
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для долгой операции — scoped-логгер, протаскиваемый через
`context.Context` сквозь асинхронные стадии, чтобы ключ дописывался сам:
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
`arch/db-identifiers.md`, если конвенция взята.)
**Почему.** Идентификатор сущности уже существует, стабилен между
процессами и во времени — по нему собираются записи не одного прохода, а
всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое
только внутри одной операции, то есть дублирует ключ и добавляет второй
способ спросить об одном. Условие применимости названо: там, где сущности
со стабильным идентификатором нет, связывать записи больше нечем.
### R19. Запись о сущности несёт её идентификатор
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
**Почему.** Принадлежность записи восстанавливается только в момент
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем,
что идентификатор стоит везде, а не в удобных местах.
Все записи одной операции собираются одним фильтром:
`jq 'select(.download_id=="01jz…")' app.jsonl`. Если идентификатор
глобально уникален across сущностей, штатно работает и простой `grep` по
голому значению — он находит все упоминания независимо от имени поля.
### R20. Долгая операция ведётся 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: единый ключ важнее краткости).
### R21. Ошибка логируется атрибутом `error`
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`%w`), не логируя: контекст накапливается в
цепочке.
- Логируем ошибку **один раз — на границе доменного слоя**, которая
определяет исход операции. Логирует этот единый чокпоинт, а не каждый
транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей.
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
зависеть от того, кто писал конкретный вызов, и ради этого единообразия
краткостью жертвуют.
### R22. Промежуточный слой либо логирует, либо возвращает
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
оборачивает (`%w`).
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
и количество `ERROR` перестаёт соответствовать количеству отказов — а
считают именно его. Контекст при этом не теряется: он накапливается в
цепочке обёрток и попадает в единственную запись на границе (R23).
### R23. Ошибка логируется один раз — на границе доменного слоя
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
этим местом выбрана доменная граница, а не транспорт, потому что там
известен исход операции целиком и, значит, класс отказа (R25) — транспорт
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
транспорты остаются тонкими.
<!-- local:границы -->
<!-- /local -->
- Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение
пользователю) и **не логируют** её повторно.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
доменной ошибки ровно один логирующий; уровень выбирает он:
### R24. Транспорт не логирует ошибку повторно
| Класс отказа | Кому | Уровень |
|---|---|---|
| штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и на этом останавливается.
- Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт)
адресован уже владельцу как деградация автоматики — уровень поднимается.
Коллизия в ручном действии — `DEBUG` (человек видит причину на экране), в
авто-обработке — `WARN` (автоматика не довела задачу).
- **Повторяющийся сбой фонового цикла — `WARN`, не `ERROR`.** Одиночный
промах тика транзиентен: следующий тик повторит. Тот же класс сбоя внутри
синхронной операции — `ERROR`, потому что операция провалилась целиком и
повтора нет. Уровень задаёт не текст ошибки, а **наличие штатного
повтора**.
**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё
только формулировкой и читается как второй сбой. Когда транспортов над
одним доменом несколько, дублирование ещё и множится, а расследование
начинается с вопроса, один это инцидент или два.
### R25. Уровень доменного отказа — по классу отказа
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по
классу, а не по месту в коде.
| № | Класс отказа | Кому | Уровень |
|---|---|---|---|
| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на
экране — владельцу разбирать нечего; целостность первичных данных отделяет
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
уровень для одного и того же отказа в зависимости от того, какой транспорт
его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с
упавшей базой.
### R26. Тот же отказ в асинхронной стадии — уровнем выше
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
она же в авто-обработке — `WARN`.
**Почему.** В ручном действии человек видит причину на экране и сам решает,
что делать дальше; запись нужна только для отладки. В автоматике не увидел
никто, задача осталась недоведённой, и лог — единственное место, где это
вообще проявится.
### R27. Повторяющийся сбой фонового цикла — `WARN`
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
уровень задаёт наличие штатного повтора, а не текст ошибки.
**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и
вмешательство не требуется; `ERROR` на каждый такой промах обесценивает
уровень, на который смотрят в первую очередь. Синхронная операция повтора
не имеет: она провалилась целиком, результат никто не восстановит, и это
ровно тот случай, ради которого `ERROR` держат чистым.
## Внешние сервисы
### R28. Каждый вызов внешнего сервиса логируется
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4.
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость
легла»: на своей стороне видно лишь то, что операция не удалась.
Выборочное логирование ломает и второе применение — доля неуспехов и
распределение `duration_ms` считаются, только если знаменатель полный.
### R29. Уровень `ext`-записи — по исходу вызова
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
| № | Исход | Уровень |
|---|---|---|
| R29.1 | успешный событийный вызов | `INFO` |
| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
| R29.3 | попытка не удалась, делается retry | `WARN` |
| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
уровень непригодным для главного вопроса «зависимость доступна?».
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение
событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет
аудит так же, как любой другой.
## Два цикла повтора — не путать
Слово «ретрай» означает два разных механизма, и уровень считается по
каждому отдельно:
каждому отдельно: повтор вызова внутри одной операции (ретраи HTTP-клиента)
задаёт уровень `ext`-записи, повтор тика внешним циклом (поллинг, сверка) —
уровень доменной записи об исходе тика.
- **Повтор вызова внутри одной операции** (ретраи HTTP-клиента) — по нему
выбирается уровень **`ext`-записи**: `WARN` на попытку, `ERROR` когда
попытки исчерпаны.
- **Повтор тика внешним циклом** (поллинг, сверка) — по нему выбирается
уровень **доменной записи** об исходе тика: `WARN`, потому что следующий
тик повторит.
```
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4)
AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27)
```
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
каждый тик. Это и есть механизм эскалации: доменный слой не паникует, а
@@ -172,71 +411,140 @@ Go-ошибки логируем **атрибутом**, не текстом с
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
подавлением повторов в самом клиенте, а не переклассификацией уровня.
## Внешние сервисы: логируем все вызовы
### R30. Ответ 4xx — успех на транспортном уровне
**Каждый** вызов внешнего сервиса логируется — это единственный способ
отличить «у нас баг» от «зависимость легла». Поля: `ext.service`,
`ext.operation` (логическая операция, не URL), `ext.status_code`,
`duration_ms`, `retry`.
Уровни:
- `INFO` — успешный **событийный** вызов;
- `DEBUG` — успешный **рутинно-частый** вызов (поллинг, авто-рефреш);
- `WARN` — попытка не удалась, делаем retry;
- `ERROR` — ретраи исчерпаны, сервис недоступен.
Завершённый HTTP-ответ с 4xx — это **успех на транспортном уровне**
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
(`ext.status_code` записан); решение «это ошибка» принимает доменный
вызывающий. Тело запроса и ответа — только на `DEBUG` и после вычистки
секретов.
вызывающий.
**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и
разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис
недоступен» с «сервис ответил нам нет» — это разные инциденты с разной
реакцией, и различает их как раз `ext`-уровень. Что 404 значит для
операции, знает только вызывающий: для одной это отказ, для другой —
штатный ответ.
## HTTP и healthcheck
- Входящие запросы логируем с `http.*` и `duration_ms` на **`INFO`**: это
аудит обращений, а не отладка. Уровень не понижается из-за кода ответа —
4xx остаётся `INFO`-записью доступа; решение «это ошибка» принимает
доменный слой и пишет свою запись.
- Для корреляции запроса допустим `request_id` — это отдельный слой от
корреляции по сущности и не противоречит отказу от `trace_id`.
- **Healthcheck, liveness, readiness — `DEBUG`.** Их дёргают периодически,
на `INFO` они забивают аудит; в проде с базовым `INFO` они не пишутся.
### R31. Входящий запрос — `INFO` независимо от кода ответа
**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа.
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и
когда приходил», и ценность у неё одинаковая при любом коде ответа.
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
(R25) — она и адресована по-другому.
### R32. Для корреляции запроса допустим `request_id`
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
правило R18. Не запрещает: R18 отказывается от случайного ключа там, где
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
сущности нет — связать его записи между собой больше нечем.
### R33. Healthcheck, liveness, readiness — `DEBUG`
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
доступной при отладке.
## Безопасность: что не логируем
Никаких секретов в полях и сообщениях: пароли и cookie сессий, API-ключи и
токены, `Authorization`-заголовки, аутентификационные параметры в ссылках.
### R34. Секреты не логируются
- Тела ответов внешних 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 есть заголовок**
тогда его нет и в ошибке транспорта.
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
в ссылках.
**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код,
и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован
с момента записи, а не с момента, когда это заметили, и вычистить его задним
числом из уже собранных копий нельзя.
### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
`DEBUG`, с вычисткой секретов и обрезкой по длине.
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией;
обрезка не даёт одной записи вытеснить весь остальной лог за период.
### R36. При сомнении логируется факт, а не значение
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
**Почему.** Для отладки почти всегда достаточно ответа «значение было или
не было» — потеря полезности близка к нулю, а риск снимается целиком.
Правило нужно потому, что решение принимается в момент написания строки,
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
никто не придёт.
### R37. `*url.Error` санитизируется на границе клиента
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
обёртки — раньше трансляции в доменную (`lang/go/errors.md`).
**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт
прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль
из userinfo, остального не трогает, поэтому ошибка уносит секрет и в
обёртку, и в лог целиком. Порядок — часть нормы: санитизация после
трансляции уже опоздала, секрет к этому моменту скопирован в текст обёртки.
Цена — теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на
причину сохраняется); альтернатива с редактированием URL сохранила бы
структуру, но сложнее.
### R38. Секрет не кладётся в URL, если у API есть заголовок
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
способа нет.
**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и
в любую запись, куда URL попал целиком, — то есть обязывает помнить про
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
<!-- local:секреты -->
<!-- /local -->
## Куда пишем
- JSON в `stdout` одним потоком; сбор и ротацию делает окружение (docker,
journald). По файлам не маршрутизируем.
- Базовый уровень в проде — `INFO`, `DEBUG` включается конфигом. dev —
`DEBUG`.
### R39. Логи идут в `stdout` одним потоком
## Анализ
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
не маршрутизируем.
- Повседневно — `jq`: `jq 'select(.download_id=="a1b2")' app.jsonl`.
- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.
**Почему.** Приложение, которое само решает, что куда писать, дублирует
работу супервизора и расходится с ней при первой же смене окружения: срок
хранения, сжатие и ротация оказываются настроены в двух местах и по-разному.
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
теряет его ровно там, где важен ход событий.
### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
**Почему.** Уровень — единственный регулятор объёма, доступный без
пересборки; если `DEBUG` в проде включается только правкой кода, его не
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2).
## Связано
- `arch/time.md` — точность и зона меток времени фиксируются на носитель.
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3).
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно
санитизации (R37).
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы,
на которых держится корреляция (R18).
<!-- local:механизировано -->
<!-- /local -->
+143 -43
View File
@@ -1,43 +1,105 @@
---
status: рекомендуемая
extends: arch/time.md
---
# Время: реализация на Go
## Единая точка
Как требования `arch/time.md` выполняются в Go-коде: откуда берётся
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами.
Форма записи — `common/language.md`.
- «Сейчас» берём у слоя хранилища — `store.Now()`, а не `time.Now()` по
коду. Ценность точки — **гарантированный UTC и один формат**: `Now()`
возвращает `time.Now().UTC()`, и ни одна ветка кода не может об этом
забыть. Побочно это единственное место, которое придётся превратить в
переменную или поле, если однажды понадобится подменять часы в тестах, —
но само по себе оно тестируемости не даёт.
- Форматирование и разбор — `store.FormatTime` / `store.ParseTime` поверх
`time.RFC3339`.
- Запрет прямого `time.Now()` механизируется `forbidigo`. Исключений
ровно два, и оба обязаны быть прописаны, иначе конвенция противоречит
сама себе: сама точка `Now()` и обёртка измерения длительности (ниже).
## Правила
## Точность и разбор
### R1. «Сейчас» берётся у слоя хранилища
- В БД — **секундная точность**, ширина 20 символов
(`2026-06-28T11:23:45Z`). Она получается сама: layout `time.RFC3339` не
содержит долей секунды, поэтому `Format` их не выведет.
- `time.RFC3339Nano` не используем: он отбрасывает хвостовые нули и ломает
фиксированную ширину.
- `time.Parse(time.RFC3339, …)` принимает и доли, и не-`Z` офсеты, то есть
канонический вид гарантирует **писатель**, а не читатель. Для одного
писателя этого достаточно; чужой вход нормализуем явно.
- В драйвер отдаём строку из `FormatTime`, а не `time.Time`: колонка —
`TEXT`, и промежуточное преобразование драйвером нам не нужно.
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
`time.Now().UTC()`, а не из `time.Now()` по коду.
## Логи
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
когда-то локальной, и восстановить смещение задним числом не по чему.
`slog` по умолчанию **не даёт UTC**: встроенные хендлеры пишут время в зоне
самого `time.Time`, то есть в локальной зоне процесса, — на ноутбуке
разработчика логи молча поедут в `+03:00`. UTC ставится `ReplaceAttr` по
`slog.TimeKey`:
Тестируемость мотивом **не является**: точка — единственное место, которое
придётся превратить в переменную или поле, если однажды понадобится
подменять часы, но само по себе оно подмены не даёт.
### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
получить строку времени и прочитать её обратно.
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и
взаимная обратимость записи и чтения держатся ровно до первого второго
layout — а расхождение проявится не на записи, а при сравнении значений,
записанных разными местами.
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
**ДОЛЖЕН.** Запрет механизируется `forbidigo`; исключений ровно два, и оба
прописаны явно:
| № | Исключение | Почему оно не покрывается R1 |
|---|---|---|
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) |
**Почему.** R1 без механической проверки держится на внимании, а
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
себе нарушение запрета: непрописанные, они либо роняют линтер, либо будут
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
сама себе.
### R4. В БД время хранится с секундной точностью, ширина 20 символов
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
лексикографический порядок совпадает с хронологическим только при
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
`ORDER BY`, и диапазонные условия — на конкретных данных, а не на всех
сразу.
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
поэтому `Format` их не выведет.
### R5. `time.RFC3339Nano` не используется
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
от значения: соседние записи получают разную ширину, и свойство, на котором
держится R4, исчезает незаметно. Проверка «формат корректен» при этом
проходит — отказывает только порядок.
### R6. Чужой вход нормализуется явно
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
к каноническому виду явно, а не считается каноническим по факту успешного
разбора.
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
**писатель**, а не читатель; пока писатель один, этого достаточно, но
значение из чужой системы, положенное в базу как пришло, нарушает R4 и
обнаруживается не на записи, а на первой сортировке.
### R7. В драйвер передаётся строка, а не `time.Time`
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с
собственным layout, который меняется вместе с версией драйвера, а не вместе
с конвенцией.
### R8. Время в логах приводится к UTC через `ReplaceAttr`
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
```go
func utcTime(_ []string, a slog.Attr) slog.Attr {
@@ -48,24 +110,62 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
}
```
`JSONHandler` пишет миллисекунды — три знака, фиксированная ширина. Это
другая точность, чем в БД, и это нормально: ширина фиксируется на носитель
(см. базу).
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
неверная зона выглядит как совершенно валидное время, а записи из разных
мест перестают складываться в одну хронологию с метками хранилища.
## Длительность
### R9. Точность времени в логах отличается от точности в БД
Обёртка измерения — **легитимное исключение из запрета `time.Now()`**, и
без него не обойтись: `store.Now()` приводит время к UTC через `.UTC()`, а
это **срезает монотонную составляющую** `time.Time`. Интервал, посчитанный
по таким меткам, зависит от подводки часов. Поэтому обёртка берёт
`time.Now()` напрямую и считает `time.Since`с локальным `//nolint`.
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
приводится к секундной точности R4.
## Зоны
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
такая же фиксированная ширина, и свойство, ради которого R4 существует, не
нарушено. Общее у лога и базы одно — зона (R8).
`time/tzdata` импортируется в `main`, зона отображения валидируется
загрузчиком конфига — см. `lang/go/config.md`. Применяется она только в
шаблонах и форматтерах UI; календарные вычисления бизнес-логики берут зону
явно, как описано в базе.
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с
локальным `//nolint`.
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
меткам, зависит от подводки часов: перевод назад даёт отрицательную
длительность, скачок вперёд — выброс в измерениях, и оба случая
невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается
как недосмотр и его «чинят».
### R11. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** База зон вшивается в бинарь.
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
`main` держит это решение в одном видимом месте, а не в случайном пакете,
откуда его удаляют при чистке зависимостей.
### R12. Зона отображения применяется только в UI
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях.
**Почему.** Зона отображения — настройка, и её меняют. Протекая в
вычисления и хранение, она делает уже записанные данные зависимыми от
текущего значения настройки: смена зоны задним числом сдвигает границы
суток у того, что давно посчитано и сохранено.
Календарные вычисления бизнес-логики берут зону явно — как описано в
`arch/time.md`.
<!-- local:механизировано -->
<!-- /local -->
## Связано
- `arch/time.md` — базовая конвенция: UTC как формат хранения, явная зона в
календарных вычислениях.
- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига.