- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
22 KiB
Логирование
Конвенция: как и когда писать логи в transcriber. Это правила оформления кода (How), а не спецификация поведения — наблюдаемые требования к логам (что система обязана залогировать как часть контракта capability) живут в спеках OpenSpec.
Взято из проекта jellybit. Расхождения с сегодняшним кодом названы по месту.
Главные: обработчик текстовый, а не JSON; уровень зашит INFO и не
настраивается; msg — предложение с заглавной буквы, а не константная
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.
Механизировано: форма вызова — sloglint: только пары
«ключ-значение», msg константой, атрибуты (slog.String и прочие) не
употребляются вовсе. Запрет fmt.Print* и встроенных print/println —
forbidigo; вывод в stdout через fmt.Fprintln(os.Stdout, …) правилом не
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
go-linters.md, «Механизировано».
Принципы
- Структурированный JSON (
slog.JSONHandler), один формат для разработки и для продакшена. - Сообщение (
msg) — категория события; данные — в полях. Каждое поле — отдельный ключ с типизированным значением: это даёт отбор и сведение черезjqбез регулярных выражений.
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
Расхождение: текстовый обработчик ставит cmd/transcriber —
slog.NewTextHandler(os.Stdout, …).
Изъятие: оснастка разработчика cmd/devtools печатает не через slog, а
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
терминале, в сбор он не едет, а текст подсказки по командам slog-ом
выглядел бы хуже, чем есть.
Сообщение
msg— короткая константа в нижнем регистре:record accepted,recognition done,conversion failed. Данные — в атрибутах:log.Info("record accepted", "record_id", id, "source", "api").msg— чистая категория без префикса подсистемы:recognition done, а неrecognize: done. Подсистему выносим в полеcapability, не в текст.- Смена состояния задачи — единая категория
state transitionс полямиfrom,toи причиной. Любой переход пишет этотmsg, чтобы весь жизненный цикл собирался одним отбором:jq 'select(.msg=="state transition" and .record_id=="…")'. Физический эффект сверх перехода — отдельная запись своей категории (file converted,text delivered), она запись перехода не подменяет.
Расхождение: сегодня msg — предложение вида Starting conversion job,
поля capability нет, отдельной категории перехода нет.
Уровни
Принцип: уровень — это адресат («кому сообщение»), а не «насколько громко
сломалось». slog даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в transcriber |
|---|---|---|
DEBUG |
разработчику при отладке; в продакшене выключен | GET /health, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
INFO |
владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, событийный вызов внешнего сервиса |
WARN |
владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
ERROR |
владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
Правила:
- Уровень не зависит от capability:
ERRORв приёме и в распознавании одинаково серьёзны. WARNне значит «ничего страшного».WARNзначит «может стать проблемой». Если это не «может» — этоINFO.- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
DEBUG(норма, владельцу разбирать нечего), а неERROR. - Событийное —
INFO, рутинно-частое —DEBUG. Операция по реальному действию (приём записи, запуск распознавания, отправка текста) идёт наINFO. Повторяющаяся служебная операция, которую запускает таймер или опрос и которая сама по себе события не несёт (проверка здоровья, пустой прогон воркера, опрос готовности операции), — наDEBUG: наINFOона зашумляет разбор. slogне разделяет CRITICAL и FATAL — сбой на старте логируемERRORи завершаем процесс с ненулевым кодом.
Расхождение: уровень зашит константой в cmd/transcriber, DEBUG включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
Изъятие: строка о подставленных заголовках входа
(internal/controller/http/substitute.go) адресована разработчику, а идёт на
INFO: DEBUG включить нечем, а в бою она не пишется вовсе — подстановку
держит выключенный умолчанием предохранитель [server] debug.
Время
- Поле —
time(ключslogпо умолчанию). - UTC, RFC 3339 с долями секунды, суффикс
Z. - Логи — в UTC, как и хранение в БД: это даёт однозначный порядок событий и лексикографическую сортировку. Часовой пояс есть только у отображения.
Поля: словарь имён
Главное условие — единый словарь: одно поле, одно имя по всему коду.
- Доменные поля — плоский
snake_case. - Системные домены — точечная иерархия (по образцу OpenTelemetry):
http.*,ext.*,webapp.*. - JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
|---|---|
| на входящий HTTP-запрос | transport (http), http.method, http.route, http.status_code, duration_ms, http.path_length. Запрошенного пути в строке нет ни под каким корнем: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В http.route идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на узнавание пришедшего | http.peer_addr — адрес того, кто открыл соединение; плюс account_id на заведении учётной записи. Значения заголовка в строке нет: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | capability (значения — по именам заведённых capability в openspec/specs/), record_id, file_id, source |
| на запись об ошибке | error |
| на вызов внешнего сервиса | ext.service, ext.operation, ext.status_code, duration_ms, retry |
| на запрос, отданный приложению | webapp.outcome (markup, asset, failure — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в http.route стоит <приложение> |
| на подъёме сервиса | webapp.build — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
Не заводим service.* и host.* — для одного бинарника на одном хосте это шум.
Расхождение: в коде встречаются record_id, file_id, operation_id,
worker, path, src_path, dest_path — то есть словарь сложился сам и
пересечён с этим лишь частично.
Корреляция по id сущности
Отдельный случайный trace_id не заводим — у сущностей уже есть стабильные
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.
- Каждая запись, относящаяся к сущности, несёт её id в поле
<entity>_id. Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии, чтобы ключ дописывался на каждую запись сам:
log := log.With("record_id", record.Id, "capability", "conversion")
- Все записи одной задачи собираются одним отбором:
jq 'select(.record_id=="…")' app.jsonl.
Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
log.Error("conversion failed", "error", err, "record_id", id). Ключ — error.
-
Идиома Go — либо лог, либо возврат, не оба. Промежуточные слои только оборачивают и возвращают (
fmt.Errorf("…: %w", err)), не логируя — контекст накапливается в цепочке%w. -
Логируем ошибку один раз — на границе доменного слоя, которая определяет исход операции. Логирует эта единая точка, а не каждый транспорт — так транспорты остаются тонкими. Границы в transcriber:
- приём записи (
CreateJobFromApi); - шаг конвейера (
FindAndRunConversionJob,FindAndRunTranscribeJob,FindAndRunTranscribeCheckJob) — исход шага, вызванного циклом воркера; - завершение и отказ задачи (
completeJob,failJob).
- приём записи (
-
Транспорты переводят возвращённую ошибку в свой ответ и не логируют её повторно — иначе один сбой даёт дубли.
-
Уровень доменного отказа — по адресату, а не по месту. У каждой доменной ошибки ровно один логирующий; уровень выбирает он.
Класс отказа Кому Уровень негодный ввод, задача не найдена, действие сейчас недопустимо пользователю (он уже получил ответ на поверхности) DEBUGзапись распознана пустой, задача досталась повторно владельцу, «может стать проблемой» WARNсбой БД, диска, недоступность внешнего сервиса владельцу, в разбор ERROR -
Повторяющийся сбой фонового цикла —
WARN, а неERROR. Одиночный промах шага временный: задача останется в своём состоянии, и следующий тик повторит. Тот же класс сбоя в синхронной операции приёма —ERROR, потому что операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а наличие штатного повтора. -
Телеметрия внешнего вызова (
ext.*, см. ниже) — отдельная запись о поведении зависимости, а не дубль доменной ошибки. -
Глушить ошибку без лога — только с однострочным комментарием «почему».
Расхождение, и оно системное: сегодня шаг конвейера логирует ошибку Error и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи.
Внешние сервисы: логируем все вызовы
Каждый вызов внешнего сервиса логируется. Поля:
ext.service—speechkit,object-storage,ffmpeg;ext.operation— логическая операция (RecognizeFile,GetOperation,PutObject,convert);ext.status_code— код ответа, если применим;duration_ms— длительность вызова;retry— номер попытки, если повторы были.
Уровни вызова:
INFO— успешный событийный вызов (заливка объекта, запуск распознавания, отправка сообщения, конвертация);DEBUG— успешный рутинно-частый вызов (опрос готовности операции, длинный опрос обновлений);WARN— попытка не удалась, делаем повтор;ERROR— повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx — это успех на транспортном уровне; решение «это ошибка» принимает доменный вызывающий.
Тело запроса и ответа — только на DEBUG и после вычистки секретов.
Расхождение: обёртки ext.* нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage и опрос операции не логируются никак.
HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями
http.method,http.route,http.status_code,duration_ms,http.path_length,transport. - Поле, которое уже даёт логгер с подставленным ключом, руками не доклеиваем. Иначе в JSON получается дублирующийся ключ, и строгий потребитель молча оставит одно из значений. Правило проверяется чтением, линтером не выражается.
GET /healthиGET /metricsлогируем наDEBUG— их дёргают периодически, наINFOони забивают разбор шумом. В продакшене при базовомINFOони не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой —
internal/controller/http, journal.go. /health и /metrics идут на DEBUG,
то есть при боевом INFO не пишутся вовсе.
Журнал у сервиса один. Второй, куда встроенное хранилище клало путь целиком вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- ключ SpeechKit и заголовок
Authorization; - пара ключей Object Storage;
- сам текст расшифровки и имена файлов пользователя — это содержимое личной
переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем:
ни укороченным именем, ни отпечатком от него — отпечаток та же приватная
величина, а корреляцию держат идентификаторы сущностей. Чем при этом
прослеживается приём, нормирует спека
intake, а не эта запись.
Дополнительно:
- Тела запросов и ответов внешних сервисов — только на
DEBUG, с вычисткой секретов и обрезкой по длине. - При сомнении не логируем значение, логируем факт его наличия
(
"has_api_key", true). - Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.
*url.Errorизnet/httpвстраивает полный URL запроса, а секрет иногда живёт прямо в пути. Такую ошибку разворачивают в первопричину на границе клиента до лога и до обёртки: URL отбрасывается, проверкаerrors.Isна причину сохраняется. Общее правило: секрет не кладём в URL, если у сервиса есть заголовок — тогда его нет и в ошибке транспорта.
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор случая и цена промаха записаны в ../review.md, 2026-08-13: конвенция числила утечку расхождением с оценкой «не логируется», и оценка была неверной.
Изъятие, а не расхождение: расширение берётся из имени отправителя дословно
(filepath.Ext), поэтому имя запись.тайное-слово отдаёт приватный хвост
расширением. В журнал оно идёт собственным полем строки приёма — это
объявленное изъятие инварианта приватности (CLAUDE.md,
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — openspec/specs/intake). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
../security.md.
Куда пишем и уровень
- Пишем JSON в
stdoutодним потоком; сбор и ротацию делает окружение. Не раскладываем по файлам. - Базовый уровень в продакшене —
INFO;DEBUGвключается конфигом при необходимости. При разработке —DEBUG.
Расхождение: поля конфигурации под уровень лога нет.
Анализ
- Повседневно —
jq:jq 'select(.record_id=="…")' app.jsonl. - Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.