Перейти к основному содержимому

ADR-0001 · Контракт логгера — OTel-совместимое структурированное логирование

Status: accepted v1.0 (2026-04-19) · Полный нормативный текст

Зачем единая спецификация логгера

Приложения dagstack используют разрозненное логирование — Python logging плюс structlog, TypeScript pino или winston, Go zap или slog. Итог — непереносимые записи логов, потерянный trace context на границах сервисов, фрагментация OTel-экспортёров, разбросанное маскирование и отсутствие runtime-реконфигурации.

ADR-0001 кодифицирует cross-language контракт логгера: формат передачи на основе OTel Log Data Model, API логгера, roadmap по приёмникам-адаптерам и интеграцию с config-spec для конфигурации и runtime-реконфигурации.

Формат передачи

Внутренний LogRecord структурно идентичен OTel Log Data Model v1.24. Имена полей совпадают с нормативной спецификацией OTel (time_unix_nano, observed_time_unix_nano, severity_number, severity_text, body, attributes, resource, instrumentation_scope, trace_id как 16 байт, span_id как 8 байт, trace_flags).

Тот же внутренний record сериализуется в три формата передачи:

  • OTLP protobuf — нативный wire OTel (OTLPSink в Phase 2).
  • OTel JSON — camelCase-ключи, строки-десятичные наносекунды, hex trace id (OTLPSink HTTP+JSON, FileSink режим OTLP, ConsoleSink режим JSON).
  • dagstack JSON-lines — snake_case-ключи, целые наносекунды, Canonical JSON с сортированными ключами (FileSink режим по умолчанию, ConsoleSink wire-режим).

observed_time_unix_nano заполняет приёмник на ingest, если producer оставил его null — гарантия, что wire-вывод всегда несёт ingest-timestamp.

Модель уровней

Уровни — целое число в диапазоне 1..24 плюс шесть канонических строк для severity_text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL). Границы bucket'ов: 1-4 → TRACE, 5-8 → DEBUG, 9-12 → INFO, 13-16 → WARN, 17-20 → ERROR, 21-24 → FATAL. Биндинги отдают основные имена как методы (trace, debug, info, warn, error, fatal) с severity_number 1, 5, 9, 13, 17, 21; промежуточные значения идут через универсальный log(severity_number, ...).

Запланированный файл _meta/severity.yaml станет источником истины в v1.1 — биндинги будут потреблять его как vendored copy. v1.0-биндинги поставляют значения инлайн; YAML приземляется инкрементально на этапе v1.0 → v1.1.

API логгера

Логгер идентифицируется именем в нотации с точкой (dagstack.rag.retriever); иерархия — родитель/потомок через dot-prefix. Приёмники и нижняя граница уровня наследуются от родителя, если на потомке не переопределены.

Основные методы:

  • Emit'ы по уровню — trace, debug, info, warn, error, fatal.
  • Универсальный emit — log(severity_number, body, attributes).
  • Emit исключения — exception(err, attributes) заполняет exception.type, exception.message, exception.stacktrace по семантическим соглашениям OTel.
  • Дочерние логгеры — with(attrs) возвращает потомка с заранее прикреплёнными атрибутами.
  • Локальные переопределения — with_sinks, append_sinks, without_sinks, scope_sinks (по спеке §6).
  • Жизненный цикл — flush(timeout), close().

Методы неблокирующие. logger.info(...) возвращается сразу; приёмники ставят запись в очередь на доставку. Вызывающий никогда не ждёт сетевого I/O.

Приёмники

Приёмник — получатель записей. Протокол повторяет ConfigSource из config-spec: id, emit(), flush(timeout), close() плюс подсказка для фильтра supports_severity(severity_number).

Phase 1 поставляет ConsoleSink (stdout/stderr, JSON или pretty), FileSink (локальный файл с ротацией), InMemorySink (кольцевой буфер для тестов). Phase 2 добавляет OTLPSink, LokiSink, SentrySink, SyslogSink, FluentBitForwardSink. Phase 3 добавляет облачные приёмники (CloudWatch, GCP Cloud Logging, Kafka, Elasticsearch).

Маршрутизация по нескольким приёмникам применяет per-sink фильтр min_severity, и каждый приёмник изолирован от сбоев других.

Зарезервированный именованный логгер dagstack.logger.internal несёт самодиагностику логгера (сбои приёмников, переполнение буфера, ошибки валидации схемы) с выделенным stderr-приёмником, который не наследует от root — это предотвращает бесконечные циклы, если приёмники root сами сломаны.

Семантические соглашения

Поверх формата передачи спека публикует набор соглашений:

  • Операцииoperation.name, operation.id, operation.kind, operation.parent.id, operation.status, operation.duration_ms для любой длительной единицы работы.
  • Типизированные событияevent.domain, event.name, event.schema_version плюс per-domain обязательные атрибуты; схемы будут жить в _meta/events/<domain>.yaml (запланировано в v1.1; v1.0-биндинги поставляют per-domain списки атрибутов инлайн).
  • События прогресса — соглашение поверх LogRecord с event.domain = "progress" (tick, started, completed, failed); поглощает Progress sink из plugin-system-spec.
  • Метаданные с подсказками типов — суффиксные подсказки атрибутов для рендеринга в UI (*.url, *.path, *.markdown, *.duration_ms, ...).
  • Extension pack для AI-агентов — опциональный pack с conformance к OTel GenAI (gen_ai.*, mcp.*) плюс dagstack-специфичные пространства имён (rag.*, agent.*, prompt.*); см. отдельную страницу концепции.

Зарезервированные домены делятся на core-резервированные (всегда применяются) и резервированные расширениями (применяются, только когда pack загружен).

Локальные переопределения логгера

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

  • with_sinks([...]), append_sinks([...]), without_sinks() — возвращают дочерний логгер.
  • scope_sinks([...]) — контекст-менеджер / callback / ctx + defer (по идиоме языка), подменяющий приёмники на исходном логгере на время блока.

Сценарии: тесты с InMemorySink, audit на отдельный запуск, маскирование на отдельный hook, debug-сессия с захватом body. Антипаттерны: долгоживущие scoped-логгеры, утечки scope через async-границы.

Конфигурация через config-spec

Логгер — потребитель dagstack/config-spec, а не самостоятельный загрузчик конфигурации. Секция logging: в YAML (по спеке §9.1) несёт level, resource, loggers (переопределения по логгерам), sinks (per-sink конфигурация) и processors (цепочка Phase 2).

Logger.configure(...) (или configure(...) в Python-биндинге) — точка входа bootstrap'а. Runtime-реконфигурация идёт через config.onSectionChange("logging", ...) плюс atomic-swap Logger.reconfigure(new); если новые приёмники не инициализируются, реконфигурация отвергается и старая конфигурация остаётся активной (параллель с rollback валидации в config-spec).

Маскирование

Список суффиксов по умолчанию: *_key, *_secret, *_token, *_password, *_passphrase, *_credentials. Совпадение case-insensitive по ключу; значение заменяется на литеральную строку "***". Рекурсия применяется через вложенные maps. Body не маскируется — разработчики форматируют body без секретов.

Список шаблонов общий с config-spec через config-spec/_meta/secret_patterns.yaml.

Сэмплирование

Phase 1: фильтр по уровню (per-logger и per-sink min_severity). Phase 2 вводит сэмплеры на основе процессоров (sampler_rate, sampler_trace_ratio). Tail-based сэмплирование делегируется OTel-коллектору и не входит в контракт.

Самонаблюдаемость

Phase 2 предписывает self-метрики — records_emitted_total, records_dropped_total, sink_flush_duration_seconds, sink_errors_total, reconfigure_total, active_loggers_gauge, buffer_depth. В Phase 1 они опциональны. Имена метрик следуют семантическим соглашениям OTel (префикс otel.logger.*).

Async и shutdown

Неблокирующий emit — это контракт: logger.info(...) возвращается сразу, приёмники пакуют записи, вызывающий никогда не ждёт I/O. Стратегия переполнения настраивается на каждом приёмнике (drop_oldest по умолчанию, drop_newest, block).

Протокол shutdown: flush(timeout) -> FlushResult { success, partial, failed_sinks: [{sink_id, error}] } и close(). Приложение должно вызывать close() в shutdown-хуке (atexit / SIGTERM / lifespan FastAPI).

Conformance

Биндинг conformant с v1.0, когда проходит четыре категории тестов: roundtrip формата передачи (каждый объявленный формат), проброс контекста (trace_id / span_id из OTel-контекста), семантические соглашения (операции / типизированные события / progress / extension pack для AI-агентов), Phase 1-приёмники (неблокирующий emit, учёт drop'ов, flush + close).

Биндинг может публиковаться под тегом phase1-partial, если покрывает только dagstack JSON-lines (без OTLP-форматов), проброс контекста, подмножество §5 по операциям и Phase 1-приёмники.

Что вне scope

  • SDK для трассировки и метрик — используй OTel напрямую.
  • Tail-based sampling — забота коллектора / бэкенда.
  • Сканирование body по паттернам (regex по body для catch-all детектирования секретов) — дорого, ненадёжно.
  • Правила алертинга на логах — забота бэкенда.
  • Multi-tenant изоляция логов — забота инфраструктуры.

См. также