- перечень «Механизировано» и то, что осталось прозой, снято из конвенций: они про то, как писать код, а не про инструменты, которые его читают - новый документ — дом темы ревью autotests, с границами: семантика гейта остаётся в CLAUDE.md, журнал дефектов и вопросы по темам — в review.md - вопрос ревью о суждении по готовому ответу сужен до того, что машина не проверяет: до ответа мимо recorder
20 KiB
Логирование
Конвенция: как и когда писать логи в transcriber. Это правила оформления кода (How), а не спецификация поведения — наблюдаемые требования к логам (что система обязана залогировать как часть контракта capability) живут в спеках OpenSpec.
Взято из проекта jellybit. Расхождения с сегодняшним кодом названы по месту.
Главные: обработчик текстовый, а не JSON; уровень зашит INFO и не
настраивается; msg — предложение с заглавной буквы, а не константная
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.
Механизировано: ничего из перечисленного ниже. forbidigo в .golangci.yml
включён, но правило у него одно и о другом — чем судят ответ в проверках
(../autotests.md, «Механизировано»); sloglint не заведён, и ни один
пункт этой записи правилом не выражен.
Принципы
- Структурированный JSON (
slog.JSONHandler), один формат для разработки и для продакшена. - Сообщение (
msg) — категория события; данные — в полях. Каждое поле — отдельный ключ с типизированным значением: это даёт отбор и сведение черезjqбез регулярных выражений.
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
Расхождение: main.go ставит slog.NewTextHandler(os.Stdout, …).
Сообщение
msg— короткая константа в нижнем регистре:job accepted,recognition done,conversion failed. Данные — в атрибутах:log.Info("job accepted", "job_id", id, "source", "telegram").msg— чистая категория без префикса подсистемы:recognition done, а неrecognize: done. Подсистему выносим в полеcapability, не в текст.- Смена состояния задачи — единая категория
state transitionс полямиfrom,toи причиной. Любой переход пишет этотmsg, чтобы весь жизненный цикл собирался одним отбором:jq 'select(.msg=="state transition" and .job_id=="…")'. Физический эффект сверх перехода — отдельная запись своей категории (file converted,text delivered), она запись перехода не подменяет.
Расхождение: сегодня msg — предложение вида Starting conversion job,
поля capability нет, отдельной категории перехода нет.
Уровни
Принцип: уровень — это адресат («кому сообщение»), а не «насколько громко
сломалось». slog даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в transcriber |
|---|---|---|
DEBUG |
разработчику при отладке; в продакшене выключен | GET /health, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
INFO |
владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, событийный вызов внешнего сервиса |
WARN |
владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
ERROR |
владельцу, в разбор | внешний сервис недоступен, задача ушла в failed, необработанная ошибка |
Правила:
- Уровень не зависит от capability:
ERRORв приёме и в распознавании одинаково серьёзны. WARNне значит «ничего страшного».WARNзначит «может стать проблемой». Если это не «может» — этоINFO.- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
DEBUG(норма, владельцу разбирать нечего), а неERROR. - Событийное —
INFO, рутинно-частое —DEBUG. Операция по реальному действию (приём записи, запуск распознавания, отправка текста) идёт наINFO. Повторяющаяся служебная операция, которую запускает таймер или опрос и которая сама по себе события не несёт (проверка здоровья, пустой прогон воркера, опрос готовности операции), — наDEBUG: наINFOона зашумляет разбор. slogне разделяет CRITICAL и FATAL — сбой на старте логируемERRORи завершаем процесс с ненулевым кодом.
Расхождение: уровень зашит константой в main.go, DEBUG включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
Время
- Поле —
time(ключslogпо умолчанию). - UTC, RFC 3339 с долями секунды, суффикс
Z. - Логи — в UTC, как и хранение в БД: это даёт однозначный порядок событий и лексикографическую сортировку. Часовой пояс есть только у отображения.
Поля: словарь имён
Главное условие — единый словарь: одно поле, одно имя по всему коду.
- Доменные поля — плоский
snake_case. - Системные домены — точечная иерархия (по образцу OpenTelemetry):
http.*,ext.*. - JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
|---|---|
| на входящий HTTP-запрос | transport (http, telegram), http.method, http.route, http.status_code, duration_ms |
| на задачу | capability (значения — по именам заведённых capability в openspec/specs/), job_id, file_id, source |
| на запись об ошибке | error |
| на вызов внешнего сервиса | ext.service, ext.operation, ext.status_code, duration_ms, retry |
Не заводим service.* и host.* — для одного бинарника на одном хосте это шум.
Расхождение: в коде встречаются job_id, file_id, operation_id,
worker, path, src_path, dest_path — то есть словарь сложился сам и
пересечён с этим лишь частично.
Корреляция по id сущности
Отдельный случайный trace_id не заводим — у сущностей уже есть стабильные
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.
- Каждая запись, относящаяся к сущности, несёт её id в поле
<entity>_id. Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии, чтобы ключ дописывался на каждую запись сам:
log := log.With("job_id", job.Id, "capability", "conversion")
- Все записи одной задачи собираются одним отбором:
jq 'select(.job_id=="…")' app.jsonl.
Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
log.Error("conversion failed", "error", err, "job_id", id). Ключ — error.
-
Идиома Go — либо лог, либо возврат, не оба. Промежуточные слои только оборачивают и возвращают (
fmt.Errorf("…: %w", err)), не логируя — контекст накапливается в цепочке%w. -
Логируем ошибку один раз — на границе доменного слоя, которая определяет исход операции. Логирует эта единая точка, а не каждый транспорт — так транспорты остаются тонкими. Границы в transcriber:
- приём записи (
CreateJobFromTelegram,CreateJobFromApi); - шаг конвейера (
FindAndRunConversionJob,FindAndRunTranscribeJob,FindAndRunTranscribeCheckJob) — исход шага, вызванного циклом воркера; - завершение и отказ задачи (
completeJob,failJob).
- приём записи (
-
Транспорты переводят возвращённую ошибку в свой ответ и не логируют её повторно — иначе один сбой даёт дубли.
-
Уровень доменного отказа — по адресату, а не по месту. У каждой доменной ошибки ровно один логирующий; уровень выбирает он.
Класс отказа Кому Уровень негодный ввод, задача не найдена, действие сейчас недопустимо пользователю (он уже получил ответ на поверхности) DEBUGзапись распознана пустой, задача досталась повторно владельцу, «может стать проблемой» WARNсбой БД, диска, недоступность внешнего сервиса владельцу, в разбор ERROR -
Повторяющийся сбой фонового цикла —
WARN, а неERROR. Одиночный промах шага временный: задача останется в своём состоянии, и следующий тик повторит. Тот же класс сбоя в синхронной операции приёма —ERROR, потому что операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а наличие штатного повтора. -
Телеметрия внешнего вызова (
ext.*, см. ниже) — отдельная запись о поведении зависимости, а не дубль доменной ошибки. -
Глушить ошибку без лога — только с однострочным комментарием «почему».
Расхождение, и оно системное: сегодня шаг конвейера логирует ошибку Error и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи. Плюс internal/controller/http/transcribe.go пишет через log.Printf
мимо slog целиком.
Внешние сервисы: логируем все вызовы
Каждый вызов внешнего сервиса логируется. Поля:
ext.service—telegram,speechkit,object-storage,ffmpeg;ext.operation— логическая операция (getFile,sendMessage,RecognizeFile,GetOperation,PutObject,convert);ext.status_code— код ответа, если применим;duration_ms— длительность вызова;retry— номер попытки, если повторы были.
Уровни вызова:
INFO— успешный событийный вызов (заливка объекта, запуск распознавания, отправка сообщения, конвертация);DEBUG— успешный рутинно-частый вызов (опрос готовности операции, длинный опрос обновлений);WARN— попытка не удалась, делаем повтор;ERROR— повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx — это успех на транспортном уровне; решение «это ошибка» принимает доменный вызывающий.
Тело запроса и ответа — только на DEBUG и после вычистки секретов.
Расхождение: обёртки ext.* нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются
никак.
HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями
http.method,http.route,http.status_code,duration_ms,transport. - Поле, которое уже даёт логгер с подставленным ключом, руками не доклеиваем. Иначе в JSON получается дублирующийся ключ, и строгий потребитель молча оставит одно из значений. Правило проверяется чтением, линтером не выражается.
GET /healthиGET /metricsлогируем наDEBUG— их дёргают периодически, наINFOони забивают разбор шумом. В продакшене при базовомINFOони не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой,
main.go, хук OnServe — вместе с gin ушёл и sloggin. /health и /metrics
идут на DEBUG, то есть при боевом INFO не пишутся вовсе.
Хранилище ведёт свой журнал запросов в собственной таблице, и он виден владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера, по которому разбирают отказы, эта таблица не попадает.
Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок
Authorization; - пара ключей Object Storage;
- сам текст расшифровки и имена файлов пользователя — это содержимое личной
переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем:
ни укороченным именем, ни отпечатком от него — отпечаток та же приватная
величина, а корреляцию держат идентификаторы сущностей. Чем при этом
прослеживается приём, нормирует спека
intake, а не эта запись.
Дополнительно:
- Тела запросов и ответов внешних сервисов — только на
DEBUG, с вычисткой секретов и обрезкой по длине. - При сомнении не логируем значение, логируем факт его наличия
(
"has_api_key", true). - Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.
*url.Errorизnet/httpвстраивает полный URL запроса, а токен Telegram живёт прямо в пути (…/bot<TOKEN>/…). Такую ошибку разворачивают в первопричину на границе клиента до лога и до обёртки: URL отбрасывается, проверкаerrors.Isна причину сохраняется. Общее правило: секрет не кладём в URL, если у сервиса есть заголовок — тогда его нет и в ошибке транспорта.
Расхождение: вычистки нет. Скачивание файла из Telegram идёт обычным
http.Get(file.Link(token)), и ошибка этого вызова содержит токен бота. Сегодня
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
строки лога.
Изъятие, а не расхождение: расширение берётся из имени отправителя дословно
(filepath.Ext), поэтому имя запись.тайное-слово отдаёт приватный хвост
расширением. В журнал оно идёт собственным полем строки приёма — это
объявленное изъятие инварианта приватности (CLAUDE.md,
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
(норма — openspec/specs/intake). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
../security.md.
Куда пишем и уровень
- Пишем JSON в
stdoutодним потоком; сбор и ротацию делает окружение. Не раскладываем по файлам. - Базовый уровень в продакшене —
INFO;DEBUGвключается конфигом при необходимости. При разработке —DEBUG.
Расхождение: поля конфигурации под уровень лога нет.
Анализ
- Повседневно —
jq:jq 'select(.job_id=="…")' app.jsonl. - Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.