- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
16 KiB
status, extends
| status | extends |
|---|---|
| рекомендуемая | arch/time.md |
Логирование
Как и когда писать логи. Это правила оформления кода (How), а не спецификация поведения: наблюдаемые требования к логам, входящие в контракт функциональности, живут в спеках.
Принципы
- Структурированный JSON (
slog.JSONHandler), один формат для dev и prod. Не потому, что текстовый вывод «расходит поля» — смена хендлера структуру атрибутов не меняет; а потому, что с текстовым dev-выводом перестаёшь ежедневно гонять собственныеjq-пайплайны, и поломки словаря замечаются только в проде. - Сообщение (
msg) — категория события; данные — в полях. Каждое поле — отдельный ключ с типизированным значением: это даёт фильтрацию и агрегацию черезjq/DuckDB без регулярок.
{"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 одной
строкой при старте.
Корреляция по id сущности
Отдельный случайный trace_id не заводим, если у сущностей есть
стабильные уникальные идентификаторы — они и служат ключом корреляции.
(Как их выбирают — arch/db-identifiers.md, если конвенция взята.)
- Каждая запись, относящаяся к сущности, несёт её id в поле
<entity>_id. Для долгой операции — scoped-логгер, протаскиваемый черезcontext.Contextсквозь асинхронные стадии, чтобы ключ дописывался сам:
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), не логируя: контекст накапливается в цепочке. - Логируем ошибку один раз — на границе доменного слоя, которая определяет исход операции. Логирует этот единый чокпоинт, а не каждый транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей.
-
Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение пользователю) и не логируют её повторно.
-
Уровень доменного отказа — по адресату, а не по месту. У каждой доменной ошибки ровно один логирующий; уровень выбирает он:
Класс отказа Кому Уровень штатный конфликт состояния или некорректный ввод пользователю, он уже получил ответ 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 есть заголовок — тогда его нет и в ошибке транспорта.
Куда пишем
- JSON в
stdoutодним потоком; сбор и ротацию делает окружение (docker, journald). По файлам не маршрутизируем. - Базовый уровень в проде —
INFO,DEBUGвключается конфигом. dev —DEBUG.
Анализ
- Повседневно —
jq:jq 'select(.download_id=="a1b2")' app.jsonl. - Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.