Skip to content

ADR-022 — Распространение ошибок и политика логирования

Поле Значение
Статус Принято
Версия v.3
Дата 2026-07-31
Владелец Руслан Габитов
Уточняет ADR-002 v.2 (расширения наблюдаемости и позиция «видимо по умолчанию»), SAD-001 v.1.1 (library-first: процессом владеет встраивающее приложение, а движок обязан обеспечить ему диагностируемость)
Смежные ADR-013 v.2 (поток наблюдения для host — отдельный канал от операторских логов)

EN-оригинал — канонический: ADR-022-error-propagation-and-logging-policy.md. Этот файл — его перевод (twin). При расхождении приоритет у английского текста.

Пользователи gobpm должны получать исчерпывающую информацию о каждой ошибке и каждом важном событии в движке. Этот ADR — единый контракт того, как ошибка путешествует (распространение), где ей позволено остановиться (обработка), что именно логируется, на каком уровне, с какими атрибутами — и что запрещено: молчаливые сбросы и дублирующее логирование. Это continuously-current политика: код-ревью и style sweep судят по ней, а сопровождающий remediation FIX доводит существующий код до неё.


1. Контекст и проблема

gobpm — это библиотека (SAD-001 v.1): приложение встраивающей стороны и есть процесс, поэтому ошибки и логи движка — единственные окна встраивающей стороны в неполадки движка. Политику мотивируют три проблемы:

  1. Молчаливые сбросы существуют. Ревью владельца обнаружило вызовы, возвращающие ошибку, чьи результаты сбрасываются через _ = на продакшн-путях (прецедент — отчёт о срабатывании waiter'а сообщения в hub). Проглоченная ошибка превращает дефект движка в недиагностируемый симптом где-то ещё — тот же класс отказа, ради которого существует правило validate-all-parameters, но на исходящей стороне.
  2. Конвенции — это фольклор. Кодовая база уже склоняется в правильную сторону — видимое-по-умолчанию логирование (ADR-002 v.2), Warn для best-effort деградации, Debug для потока по событиям — но ничего из этого не записано, поэтому каждая новая подсистема заново принимает решение, и накапливается дрейф (одна и та же сущность логируется как instance в одном файле и instance_id в другом).
  3. Наивное исправление хуже дефекта. Механическое добавление лога рядом с каждой ошибкой порождает log flood: один отказ, отрапортованный на пяти уровнях стека, каждый с частичным контекстом. Объём без дисциплины разрушает читаемость, ради которой логи и существуют.

BPMN 2.0 молчит о наблюдаемости движка — логирование целиком является выбором движка, поэтому эта политика заземлена на инженерной практике, а не на стандарте (§3).

2. Решение

2.1 Каждая ошибка обрабатывается ровно один раз

У ошибки есть ровно две легальные судьбы, и каждое её появление выбирает одну:

  • Propagate (распространить) — обернуть контекстом и вернуть вызывающему. Распространённая ошибка НЕ логируется на месте распространения; теперь ею владеет вызывающий.
  • Handle (обработать) — на границе, где ни один вызывающий выше не может ничего предпринять (§2.3): залогировать с полным контекстом и решить последствие (перевести instance в fault, деградировать, отбросить с указанием причины).

Log-and-return запрещён. Это единственная причина log flood'ов: один и тот же отказ, отрапортованный на каждом уровне стека вызовов, ни один из которых не имеет полного контекста. Один отказ ⇒ максимум одна запись в логе.

2.2 Распространение несёт контекст; параллельные ошибки объединяются

Три паттерна распространения, выбираемые по месту:

Ситуация Паттерн
Одиночный вызов, возвращающий ошибку, завершает функцию return f(...) — не захватывать-и-сбрасывать
Ошибка уже в полёте, и происходит вторая (teardown, follow-up отчёт) errors.Join(err, err2) — обе доходят до обработчика
Добавление контекста движка на пути вверх wrap: errs.New(errs.M(...), errs.E(err), errs.D(...)) или fmt.Errorf("...: %w", err) по преобладающему стилю пакета

Обёртка именует операцию, которая упала, и несёт идентифицирующие атрибуты (§2.6), чтобы итоговая единственная запись в логе была полной.

2.3 Границы обработки — единственные места, которые логируют ошибки

Ошибка логируется ровно там, где ничто выше не может на неё повлиять:

  1. Вершины горутин — instance loop, терминальный fault-путь трека, сервисная горутина waiter'а, run loop hub'а, worker диспетчера. Над ними нет ничего, куда можно было бы вернуться.
  2. Best-effort операции — вызовы, чей отказ не должен ронять поток (раздача/отзыв задач, уведомление observer'а, продление подписки receiver'а). Логируются на Warn/Debug (§2.5), а поток продолжается; лог и есть обработка.
  3. Умышленные игнорирования — место, где ошибка доказуемо несущественна, сохраняет ОБА: и лог (на Debug), и комментарий, объясняющий, почему игнорирование безопасно. Голый _ = f() на вызове, возвращающем ошибку, запрещён в продакшн-коде.

Best-effort vs fail-fast — суди по поверхности отказа, а не по месту вызова. Операция является best-effort (класс границы 2), только если её режимы отказа действительно несущественны — транзиентная или внешняя заминка, которую поток может пережать (таймаут distributor'а, ошибка observer'а, идемпотентная операция, которая no-op'ит при промахе). Если единственный способ, которым операция может упасть, — это нарушение инварианта — состояние, которое не может случиться, если что-то выше по потоку уже не сломано (waiter отсутствует в реестре, в который он сам зарегистрировался), — её отказ не best-effort: он сигнализирует о повреждённом окружении, и продолжать работу в этом окружении — худший исход. Такой отказ распространяется (fail-fast), чтобы граница выше остановилась и залогировала его. Прочитай, за что вызываемая функция реально возвращает ошибку, прежде чем классифицировать, — форма места вызова (вершина горутины, комментарий «best-effort») намекает, но решает поверхность ошибки.

Граница публичного API — НЕ граница логирования: ошибка, возвращённая встраивающей стороне, сама по себе является исчерпывающим отчётом (само-идентифицирующим по правилу validate-all-parameters); её дополнительное логирование дважды отрапортовало бы отказ, которым встраивающая сторона уже владеет.

Компоненты без logger'а в области видимости. Некоторые единицы законно не держат observability.Logger — чистые конструкторы pkg/model (Clone, AddFlow) и драйвер pkg/interactor/console, который сам является каналом вывода. Там log-половина §2.3(3) нереализуема, поэтому политика удовлетворяется на уровень выше: propagate (конструктор модели возвращает ошибку; без изменения поведения там, где инвариант, который он утверждает, действительно держится), а там, где недоступны ни распространение, ни logger (собственный best-effort вывод консольного writer'а), документированным исключением служит один why-комментарий без лога. Голый _ = f() по-прежнему запрещён — минимум — это комментарий; намерение должно быть явным.

2.4 Дисциплина уровней — пиши для читателя

Уровень Значение Читатель Примеры
Error Actionable-отказ, обработанный здесь: затронуто состояние движка оператор, поднятый по пейджеру ночью instance перешёл в fault, waiter терминально упал
Warn Деградировано, но продолжает работу; кому-то стоит когда-нибудь взглянуть оператор у дашборда таймаут distributor'а, запланирован retry, деривация correlation не удалась
Info Веха жизненного цикла, которую пользователь ожидает увидеть встраивающая сторона во время bring-up старт/стоп движка, процесс зарегистрирован, стартовая конфигурация (ADR-002 v.2)
Debug Трассировка потока для диагностики разработчик с репро диспетчеризация per-event loop, отбрасывания доставки с причиной, добавление/удаление waiter'а

Два следствия: hot path (per-event, per-token, per-message) никогда не логирует выше Debug; и ожидаемый no-op (проигравшая ветвь отложенного выбора, сигнал без catcher'а) — это Debug с причиной отбрасывания — ожидаемое поведение не является предупреждением.

2.5 Один словарь атрибутов

Три носителя идентифицируют свой субъект каноническими snake_case ключами, одними и теми же везде: атрибуты операторской записи в логе, Details наблюдаемого Fact'а и Details классифицированной ошибки (errs.D). Один словарь обслуживает все три: константы Attr* из pkg/observability И ЕСТЬ эта таблица, так что ключ берётся через константу, а не перенабирается в каждом месте вызова.

Словарь делится по тому, что ключ делает, и именно это деление решает, нужна ли регистрация:

  • канонический ключ называет, о каком объекте событие — id, имя, адрес или вид, которым движок к нему адресуется;
  • описательный атрибут характеризует само событие — сколько, в каком порядке, по какой причине, с каким исходом.

Канонические ключи — у каждого есть константа Attr*:

Домен Канонические ключи
Instance / поток instance_id, track_id, node_id, node_name, process_id, process_name, start_node_id, scope_path, data_path, flow_id
Происхождение определения version, parent_instance_id, child_instance_id, call_activity_node_id, called_key, called_namespace, called_version
Человеческие / worker-задачи task_id, job_id, worker_id, topic, user_id, from_user_id, to_user_id
События / waiter'ы event_definition_id, event_definition_type, event_processor_id, waiter_id, signal, message_name, escalation, link_name, arm_id, requester_id
Correlation correlation_key (имя ключа), correlation_value (его выведенное значение)
Данные data_name, data_store, item_id, association_id, association_source_id, expression_id
Decision / script decision_ref, decision_name, implementation, result_variable, operation_id, operation_name, renderer_id
Наблюдение observer_type
Компенсация activity_ref (активность, которую компенсируют, — отлична от node_id, называющего место возникновения факта)
Ошибка error

Описательные атрибуты — свободны по форме by design, регистрация не нужна: attempts, backoff, candidates, chosen_flows, loop_counter, ordinal, output_count, row_count, rule_count, script_format, selected_by, stage, stop_reason, transaction_method, плюс разовые счётчики и длительности (deadline, duration, счётчик processors/catchers).

Канонический ключ ОТСУТСТВУЕТ, когда его значение неизвестно, и никогда не приближается. Пара, называющая callable, делает это наглядным. called_key несёт тот ключ, который решил resolver host'а, — регистрацию, которая действительно выполнялась, а не ссылку, записанную в документе; а called_namespace присутствует, только если эта ссылка была квалифицированной. Поэтому отсутствующий called_namespace читается как «неквалифицированная» — факт о файле; а отсутствующий called_key читается как «на этой фазе неизвестен», и это честный ответ, пока переприкреплённый ребёнок ещё не резидентен. Подставить туда неразрешённую ссылку было бы хуже молчания: она называет ДРУГУЮ регистрацию, чем та, что выполнялась, — и один и тот же вызов сообщал бы об одном callable на одной фазе и о другом на следующей. Правило обобщается: канонический ключ называет, о каком объекте событие, а почти-правильный объект — это неправильный объект.

Ключ вида *_type, сообщающий, что валидация ОЖИДАЛА или ОБНАРУЖИЛА, является описательным, несмотря на entity-подобную форму: option_type, expected_type, expr_type и time_type называют Go- или BPMN-тип в сообщении об ошибке, а не объект модели. Они перечислены здесь, чтобы это различие не пересматривали каждый раз, когда кто-то грепает entity-подобные ключи.

Два размещения, которые выглядят неожиданно и сделаны намеренно: агрегат из id — описательный, а не канонический (candidates и chosen_flows перечисляют, а не ссылаются, поэтому не идентифицируют ни один объект); и script_format описательный, тогда как topic каноничен, потому что формат — это категория, общая для многих скриптов, а topic называет одну очередь.

Правила:

  • Одна сущность — один ключ — никаких пофайловых синонимов (instance vs instance_id, track/node/message vs их формы _id/_name).
  • Ошибка путешествует под error как строка своего сообщения (err.Error()), никогда не как сырой объект err и никогда под ключом-заглушкой (report_error, fault) — если только одна запись действительно не несёт две различные ошибки, что является единственным случаем, когда допускается второй error-ключ, и он должен быть назван по тому, чем является.
  • correlation_key — это имя ключа; correlation_value — выведенное значение — эти два были смешаны (атрибут key держал значения, а correlation_key держал имена); теперь они различны.
  • Описательные атрибуты свободны по форме — канон управляет ссылками на сущности, а не каждым атрибутом.
  • Новые канонические ключи присоединяются через version bump этого ADR, а не ad hoc.
  • Оба направления гарантируются тестами, а не доверием. Правило-проза, которое держится только на ревью, деградирует: к v.2 в код вошли 28 констант, не дошедших до этой таблицы, а два ключа, которые таблица уже несла (event_definition_type, event_processor_id), существовали в коде только как голые строковые литералы. Соответствие теперь держат два теста — один утверждает, что каждая константа Attr* присутствует выше, другой — что ни один строковый литерал не дублирует значение Attr* вне его собственного объявления. Ключ, который есть в коде и отсутствует в этой таблице, либо есть в таблице и набран руками в месте вызова, роняет сборку.

2.6 Тишина — это opt-out, никогда не случайность

Наблюдаемость движка по умолчанию видима (slog.Default(), ADR-002 v.2); встраивающая сторона, желающая тишины, настраивает её явно. Случайная тишина — сброшенная ошибка, nil-logger, стирающий дефолт, отсутствующий лог на границе обработки — трактуется как дефект, причём худший, чем случайный шум: шум раздражает, тишина недиагностируема.

2.7 Логи и поток наблюдения остаются отдельными каналами

Операторские логи (эта политика) и host-поток наблюдения (ADR-013 v.2) служат разным читателям и остаются раздельными: Fact'ы — это программная лента встраивающей стороны (lossy-by-design, per-observer), логи — операторский нарратив. Событие может законно появиться в обоих — как данные в потоке, как запись в логе — но ни одно не заменяет другое, и решения об объёме логов никогда не предполагают «observer это видел».

3. Заземление на практику

  • Handle-errors-once — устоявшийся принцип Go-сообщества (сформулирован в D. Cheney, Don't just check errors, handle them gracefully, 2016 — ошибку следует обрабатывать только один раз, а логирование ошибки — это её обработка; повторён в Go-блоге, Errors are values): отсюда log-or-return, никогда не оба.
  • Структурированное key-value логирование с leveled-logger'ом следует модели stdlib log/slog, на которой проект уже построен (observability.Logger, ADR-002 v.2 §4.3) — атрибуты вместо интерполированных строк, чтобы записи были машинно-фильтруемыми.
  • Wrap-with-%w / errors.Join — stdlib-идиомы распространения (Go 1.13 wrapping; Go 1.20 multi-errors); пакет проекта errs — домашняя обёртка, несущая классы и детали (reflection-free by design).
  • BPMN 2.0 молчит о логировании и наблюдаемости движка — никаких ограничений standard-conformance не применяется; это выбор движка, задокументированный как политика.

4. Рассмотренные альтернативы

Альтернатива Оценка
Без политики — судить case by case на ревью ❌ Отклонено: дрейф эмпиричен (молчаливые сбросы уже уехали в прод; синонимы атрибутов существуют). Фольклор не переживает смену подсистем.
Логировать везде — добавить лог рядом с каждой ошибкой, продолжать возвращать ❌ Отклонено: гарантирует дублирующие отчёты и log flood; читатель теряет ту единственную запись, что имеет полный контекст.
Enforcement только линтером (errcheck и др.) без doc-политики ❌ Отклонено как единственная мера: _ = — это ровно та идиома, которая заглушает errcheck, и ни один линтер не судит осмысленность, выбор уровня или именование атрибутов. Линтеры обеспечивают механический пол (§5); политика владеет суждением.
Policy ADR + remediation sweep + review discipline ✅ Выбрано: короткий continuously-current контракт, один выделенный проход для доведения существующего кода до него, затем enforcement на ревью и в style sweep.

5. Последствия

  • Поведенческие изменения на просвеченных местах. Удаление сброса меняет контракт: ранее проглоченный отказ теперь распространяется (или переводит в fault, или становится видимым). Каждое remediated-место — это небольшое изменение поведения, требующее своего теста — причина, по которой remediation является выделенным, отревьюенным проходом, а не bulk-правкой.
  • Style sweep получает house rules: никаких голых _ = на вызовах, возвращающих ошибку; log-and-return помечается; ключи атрибутов сверяются с §2.5.
  • Механический пол можно ужесточить: с исправленными сбросами настройки линтера, запрещающие новые (errcheck check-blank), становятся принимаемыми без красной стены.
  • Пути ошибок становятся протестированными путями. Стандарт покрытия по затронутым функциям применяется к каждой заново достижимой ветви, которую открывает sweep.
  • Политика версионируется: определения уровней и словарь атрибутов эволюционируют через bump этого ADR, сохраняя один источник истины.

6. Рекомендации по enterprise-готовности

  • Environment-профили: Debug в development, Info в staging, Warn+ для продакшн-дашбордов — семантика уровней §2.4 выбрана так, чтобы эти отсечки были осмысленными.
  • Машиночитаемый вывод: seam observability.Logger принимает любой slog-handler; продакшн-встраивателям следует использовать JSON-handler, чтобы канонические ключи (§2.5) напрямую управляли алертингом и корреляцией.
  • Кросс-канальная корреляция: instance_id — это join-ключ через логи, поток Fact (ADR-013 v.2) и любой будущий tracing-экспорт — дашбордам следует трактовать его как первичный индекс.
  • Будущее: когда приземлится концепция Incident (отложена из работы над service-task), инциденты станут якорными записями уровня Error, а сегодняшние fault-логи будут к ним прикрепляться; метрика dropped/deduplicated-ошибок на границах обработки сделала бы «ровно один раз» из §2.1 наблюдаемой саму по себе.

7. План раскатки

  1. Эта политика приземляется первой (Принято) — контракт, по которому проводить sweep.
  2. Remediation сброшенных ошибок. Сопровождающий remediation FIX инвентаризирует каждый молчаливый сброс по всему репозиторию и классифицирует каждый по §2.1–§2.3 (return / join / log-at-boundary / deliberate-ignore-with-comment), добавляя тесты для заново достижимых error-путей.
  3. Аудит логов — существующие записи против политики. Каждый текущий log-оператор оценивается, в том же FIX:
  4. удаляется там, где нарушает §2.1 (лог рядом с возвращённой ошибкой — дублирующий отчёт) или volume-следствия §2.4 (выше-Debug на hot path, предупреждение об ожидаемом поведении);
  5. добавляется там, где граница обработки (§2.3) сегодня молчит — вершина горутины или best-effort операция, чей отказ сейчас не оставляет записи;
  6. переуровневывается и переключевывается там, где уровень противоречит §2.4 или атрибуты отклоняются от словаря §2.5 (класс instanceinstance_id), чтобы один отказ читался как одна полная запись.
  7. Review discipline и style sweep обеспечивают политику вперёд; ужесточение линтера (§5) следует, как только кодовая база станет чистой.

8. Ссылки

  • ADR-002 v.2 — extension architecture: seam observability.Logger и видимая-по-умолчанию стартовая позиция, которую эта политика обобщает.
  • ADR-013 v.2 — instance observability: host-поток наблюдения, который §2.7 держит отдельно от операторских логов.
  • SAD-001 v.1 — library-first vision: посылка embedder-owns-the-process за «граница публичного API возвращает, никогда не логирует».
  • D. Cheney, Don't just check errors, handle them gracefully (GoCon 2016); Go-блог, Errors are values — принцип handle-once.
  • Go release notes 1.13 (error wrapping), 1.20 (errors.Join).

Открытые вопросы

Нет.

История документа

Версия Дата Автор Изменение
v.3 2026-08-28 Руслан Габитов called_namespace присоединяется к каноническим ключам происхождения определения. Ссылка Call Activity — это ключ, опционально квалифицированный пространством имён документа, который объявил callable (ADR-023 v.5 §2.7), поэтому именно пара называет callable — и факт, несущий только половину, называет что-то другое. Атрибут присутствует на факте вызова, только если ссылка была квалифицированной: отсутствующий атрибут тогда читается как «неквалифицированная» — факт о файле, а не потерянное значение. Вместе с этим §2.5 фиксирует общее правило: канонический ключ отсутствует, когда неизвестен, и никогда не приближается — почти-правильный объект есть неправильный объект.
v.2 2026-08-01 Руслан Габитов Принято. Словарь сверен с кодом и загейчен в обоих направлениях. Правило регистрации из §2.5 («новые entity-ключи присоединяются через version bump») не поддерживалось ничем и не выполнялось: 28 из 47 констант Attr* вошли в код, не дойдя до таблицы, а два ключа, которые таблица уже несла — event_definition_type, event_processor_id, — существовали в коде только как голые строковые литералы, то есть словарь был не обеспечен В ОБЕ СТОРОНЫ. §2.5 теперь перечисляет все 50 ключей с явным критерием деления: канонический ключ называет, о каком объекте событие (id, имя, адрес или вид, которым движок к нему адресуется), и требует регистрации; описательный атрибут характеризует само событие (счёт, порядок, причина, исход) и свободен по форме. Два судейских решения зафиксированы явно: агрегат из id (candidates, chosen_flows) описателен, потому что перечисляет, а не ссылается; script_format описателен там, где topic каноничен, потому что формат — общая категория, а не одна очередь. Добавлен observer_type (конкретный Go-тип observer'а host'а, чей OnFact паниковал, — единственная зацепка движка за значение, которому он не назначает id). Добавлено правило, что оба направления ПРОВЕРЯЮТСЯ тестами, а не доверием. Исходящий pin обновлён при bump'е: SAD-001 v.1 → v.1.1 (устарел); ADR-002 v.2 и ADR-013 v.2 проверены как актуальные. §2.1–§2.4 и §2.6 не менялись.
v.1 2026-07-11 Руслан Габитов Принято (авторизовано 2026-07-10). Контракт распространения ошибок и логирования: handle-exactly-once (log XOR return); три паттерна распространения (return одиночного вызова, errors.Join, контекстная обёртка); перечисленные границы обработки (вершины горутин, best-effort операции, умышленные игнорирования с log+комментарием), где граница публичного API явно НЕ является границей логирования, дискриминатор fail-fast-vs-best-effort (суди по поверхности отказа — отказ только-по-инварианту распространяется, а не логируется — добавлен в ходе реализации из находки WaiterFired), и carve-out для logger-less компонентов (конструкторы модели, консольный драйвер), которые распространяют или комментируют; дисциплина уровней (Error/Warn/Info/Debug со следствиями для hot-path и expected-no-op); канонический словарь атрибутов (заземлён по коду: добавляет event_definition_type/event_processor_id/worker_id/topic/start_node_id, разделяет correlation_key/correlation_value, освобождает count-атрибуты); silence-is-opt-out; разделение логов и потока ObsEvent. Заземлено на Go-практику (BPMN молчит о наблюдаемости). Приземлено сопровождающим FIX (sweep сбросов + аудит существующих логов); Принято после того, как landing-gate /check-srd этого FIX прошёл.