feat(stage2-phase2): structlog hot-path, pytest markers, obs package

Phase 2 deliverables not yet committed from plan execution:
- structlog wired to 10 hot-path files (orchestrator, eskf, components)
- bind_contextvars(correlation_id=frame_id) in process_frame
- obs/logging_config.py: configure_logging(env) JSON/console renderer
- pyproject.toml: structlog>=25.1, --strict-markers, 6 markers registered
- tests/conftest.py: ac(id) validator plugin + pytest_collection hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yuzviak
2026-05-11 19:06:47 +03:00
parent 7f76acfe29
commit 7e64ef8d2b
15 changed files with 286 additions and 78 deletions
+67
View File
@@ -0,0 +1,67 @@
"""structlog configuration. Call ``configure_logging`` once at app boot.
Per Phase 2 / OBS-01: hot path uses structlog with ``correlation_id`` (= frame_id)
bound at frame entry. Non-hot-path code keeps stdlib ``logging`` until Phase 6
(api/, db/, scripts/, composition.py at startup, core/models.py engine load).
The stdlib bridge below lets stdlib records flow through the same renderer.
"""
from __future__ import annotations
import logging
from typing import Literal
import orjson
import structlog
_Env = Literal["jetson", "x86_dev", "ci", "sitl"]
_configured = False
def configure_logging(env: _Env, level: int = logging.INFO) -> None:
"""Configure structlog ONCE at app boot. Idempotent — repeat calls no-op.
Args:
env: Deployment environment. Controls renderer:
- ``x86_dev`` -> pretty console renderer
- ``jetson|ci|sitl`` -> JSON renderer (orjson) + bytes logger factory
level: Stdlib log level threshold. ``filtering_bound_logger`` short-circuits
sub-level calls in ~50-100 ns. Keep at INFO in production.
"""
global _configured
if _configured:
return
shared_processors: list = [
# MUST be first — pulls bound frame_id into every record.
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.format_exc_info,
]
if env == "x86_dev":
processors = [*shared_processors, structlog.dev.ConsoleRenderer()]
logger_factory = structlog.PrintLoggerFactory()
else:
# jetson | ci | sitl — JSON to bytes via orjson; fastest path on hot loop.
processors = [
*shared_processors,
structlog.processors.JSONRenderer(serializer=orjson.dumps),
]
logger_factory = structlog.BytesLoggerFactory()
structlog.configure(
processors=processors,
wrapper_class=structlog.make_filtering_bound_logger(level),
logger_factory=logger_factory,
cache_logger_on_first_use=True,
)
# Bridge stdlib logging: api/, db/, scripts/, composition.py, core/models.py
# (Phase 6 ports these to structlog directly). Until then, their records share
# the same level threshold; format passthrough is via "%(message)s" because the
# structlog renderer above is the actual output sink.
logging.basicConfig(level=level, format="%(message)s")
_configured = True