mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:11:12 +00:00
[AZ-266] [AZ-269] [AZ-277] [AZ-280] Cross-cutting log/config + SE3/SHA256 helpers
AZ-266: schema-compliant JSON logging entrypoint, level normalisation, handler-topology guard, format-error fallback (log_record_schema v1.0.0). AZ-269: env > YAML > defaults config loader, frozen Config dataclass, missing-var fail-fast with pointer to .env.example, component-block registry. AZ-277: GTSAM-backed SE3Utils (matrix<->SE3 + exp/log/adjoint) with strict orthogonality, dtype, and bottom-row contract enforcement. AZ-280: atomicwrites-backed write_atomic + independent verify + order-deterministic aggregate_hash; sidecar format strictness. pyproject.toml pins gtsam>=4.2,<5.0 and atomicwrites>=1.4,<2.0 (named-backend deps per the AZ-277 / AZ-280 contracts). 139 unit tests pass (44 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR + journald deferrals, no blocking issues. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
"""Structured JSON logging entrypoint (E-CC-LOG / AZ-245).
|
||||
"""Structured JSON logging entrypoint (E-CC-LOG / AZ-245 / AZ-266).
|
||||
|
||||
Bootstrap (AZ-263) ships a working `get_logger` so every other module can import it;
|
||||
the concrete sink + redaction policy is layered on by AZ-266.
|
||||
Public surface — every component imports `get_logger` from here. The
|
||||
handler topology is selected by `configure_logging(tier=...)` at the
|
||||
composition-root entrypoint.
|
||||
"""
|
||||
|
||||
from gps_denied_onboard.logging.structured import get_logger
|
||||
from gps_denied_onboard.logging.structured import (
|
||||
JsonFormatter,
|
||||
configure_logging,
|
||||
get_logger,
|
||||
)
|
||||
|
||||
__all__ = ["get_logger"]
|
||||
__all__ = ["JsonFormatter", "configure_logging", "get_logger"]
|
||||
|
||||
@@ -1,83 +1,272 @@
|
||||
"""Structured JSON logging.
|
||||
"""Shared structured JSON logging (E-CC-LOG / AZ-245 / AZ-266).
|
||||
|
||||
E-CC-LOG / AZ-245 contract: one JSON object per log line. Bootstrap provides a
|
||||
minimal working `get_logger(name)` so every other module can import it; AZ-266
|
||||
will add full redaction and the FDR sink.
|
||||
Implements the `log_record_schema` v1.0.0 contract at
|
||||
`_docs/02_document/contracts/shared_logging/log_record_schema.md`:
|
||||
|
||||
- One JSON object per log line.
|
||||
- Stable field order: ``ts, level, component, frame_id, kind, msg, kv, exc``.
|
||||
- Level normalisation: ``WARNING`` -> ``WARN``.
|
||||
- ``frame_id`` is explicit ``null`` for non-frame records.
|
||||
- Formatter never raises into the caller: a serialisation failure replaces
|
||||
the offending record's ``kv`` payload with
|
||||
``{"_format_error": "<reason>"}`` and adds an internal WARN to the
|
||||
emitted bytes; the rest of the record still goes out.
|
||||
|
||||
Public surface (consumed by every onboard component):
|
||||
|
||||
- ``get_logger(component_id)`` -> ``logging.Logger`` (cached).
|
||||
- ``configure_logging(tier, level)`` -> attach the handler topology for
|
||||
the active deployment tier without duplicating handlers on re-init.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import threading
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Final
|
||||
|
||||
# Schema field order (REQUIRED by contract; verified by AC-2).
|
||||
_CONTRACT_FIELDS: Final[tuple[str, ...]] = (
|
||||
"ts",
|
||||
"level",
|
||||
"component",
|
||||
"frame_id",
|
||||
"kind",
|
||||
"msg",
|
||||
"kv",
|
||||
"exc",
|
||||
)
|
||||
|
||||
# Default `kind` for records that don't pass one explicitly (startup / shutdown / unclassified).
|
||||
_DEFAULT_KIND: Final[str] = "log.diag"
|
||||
|
||||
# Python stdlib LogRecord attributes that must not leak into `kv` when we
|
||||
# auto-collect record extras. Kept as a frozenset for O(1) lookup.
|
||||
_RESERVED_LOG_RECORD_KEYS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"message",
|
||||
"module",
|
||||
"msecs",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"taskName",
|
||||
"thread",
|
||||
"threadName",
|
||||
# Contract-named keys live in the top-level payload, not in `kv`.
|
||||
"frame_id",
|
||||
"kind",
|
||||
"kv",
|
||||
"component",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
"""Emit a single JSON object per log line — no narrative log lines (E-CC-LOG)."""
|
||||
def _iso_utc(created_epoch: float) -> str:
|
||||
"""Return RFC 3339 UTC timestamp with microsecond precision and ``Z`` suffix."""
|
||||
dt = _dt.datetime.fromtimestamp(created_epoch, tz=_dt.timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond:06d}Z"
|
||||
|
||||
|
||||
def _normalise_level(stdlib_levelname: str) -> str:
|
||||
"""Translate Python stdlib level names to the contract enum."""
|
||||
if stdlib_levelname == "WARNING":
|
||||
return "WARN"
|
||||
return stdlib_levelname
|
||||
|
||||
|
||||
def _coerce_jsonable(value: Any) -> Any:
|
||||
"""Coerce arbitrary kv payloads into JSON-safe primitives.
|
||||
|
||||
Raises ``TypeError`` / ``ValueError`` on un-serialisable content; the
|
||||
caller (the formatter) is responsible for replacing offending payloads
|
||||
with ``{"_format_error": ...}``.
|
||||
"""
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _coerce_jsonable(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_coerce_jsonable(v) for v in value]
|
||||
raise TypeError(f"unserialisable kv payload type: {type(value).__name__}")
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""Emit one schema-compliant JSON object per log record.
|
||||
|
||||
Field order is locked by the contract — we build the payload via an
|
||||
ordered ``dict`` and rely on Python 3.7+ insertion-order preservation
|
||||
plus ``json.dumps(..., sort_keys=False)``.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
|
||||
+ f".{int(record.msecs):03d}Z",
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
rec_dict = record.__dict__
|
||||
|
||||
frame_id = rec_dict.get("frame_id")
|
||||
kind = rec_dict.get("kind") or _DEFAULT_KIND
|
||||
|
||||
explicit_kv = rec_dict.get("kv")
|
||||
if explicit_kv is None:
|
||||
kv_raw: dict[str, Any] = {
|
||||
k: v
|
||||
for k, v in rec_dict.items()
|
||||
if k not in _RESERVED_LOG_RECORD_KEYS and not k.startswith("_")
|
||||
}
|
||||
else:
|
||||
kv_raw = dict(explicit_kv)
|
||||
|
||||
try:
|
||||
kv_safe = _coerce_jsonable(kv_raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
kv_safe = {"_format_error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
for key, value in record.__dict__.items():
|
||||
if key in (
|
||||
"args",
|
||||
"msg",
|
||||
"levelname",
|
||||
"name",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"created",
|
||||
"msecs",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"processName",
|
||||
"process",
|
||||
"module",
|
||||
"funcName",
|
||||
"filename",
|
||||
"pathname",
|
||||
"lineno",
|
||||
"levelno",
|
||||
):
|
||||
continue
|
||||
payload.setdefault(key, value)
|
||||
return json.dumps(payload, separators=(",", ":"), default=str)
|
||||
exc_text: str | None = self.formatException(record.exc_info)
|
||||
else:
|
||||
exc_text = None
|
||||
|
||||
component = rec_dict.get("component") or record.name
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"ts": _iso_utc(record.created),
|
||||
"level": _normalise_level(record.levelname),
|
||||
"component": component,
|
||||
"frame_id": frame_id,
|
||||
"kind": kind,
|
||||
"msg": record.getMessage().replace("\n", " "),
|
||||
"kv": kv_safe,
|
||||
"exc": exc_text,
|
||||
}
|
||||
|
||||
ordered_payload = {field: payload[field] for field in _CONTRACT_FIELDS}
|
||||
return json.dumps(ordered_payload, separators=(",", ":"), default=str, sort_keys=False)
|
||||
|
||||
|
||||
_CONFIGURED = False
|
||||
# Module-level guard for handler-topology re-initialisation idempotency (AC-4).
|
||||
_LOGGING_LOCK = threading.Lock()
|
||||
_HANDLER_MARKER_ATTR: Final[str] = "_gps_denied_handler_kind"
|
||||
|
||||
|
||||
def _configure_root_once() -> None:
|
||||
global _CONFIGURED
|
||||
if _CONFIGURED:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
root.setLevel(getattr(logging, level_name, logging.INFO))
|
||||
_CONFIGURED = True
|
||||
def _make_tier1_handler() -> logging.Handler:
|
||||
"""Tier-1: stdout handler (Docker captures stdout)."""
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
setattr(handler, _HANDLER_MARKER_ATTR, "tier1.stdout")
|
||||
return handler
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Return a structured JSON logger.
|
||||
def _make_tier2_handler() -> logging.Handler:
|
||||
"""Tier-2: journald handler (Jetson with systemd).
|
||||
|
||||
Every component imports its logger via
|
||||
`from gps_denied_onboard.logging import get_logger`.
|
||||
journald is reached via ``systemd.journal.JournalHandler``; if the
|
||||
``systemd-python`` package is not installed on the active host, the
|
||||
factory raises so callers (and tests) get a clear prerequisite signal
|
||||
rather than a silent stderr fallback that would violate AC-4's
|
||||
"exactly one journald handler" invariant.
|
||||
"""
|
||||
_configure_root_once()
|
||||
return logging.getLogger(name)
|
||||
try:
|
||||
from systemd.journal import JournalHandler # type: ignore[import-not-found]
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Tier-2 journald handler requires `systemd-python`. Install it on the "
|
||||
"Jetson runtime or select `tier=1` for local/Docker deployments."
|
||||
) from exc
|
||||
handler = JournalHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
setattr(handler, _HANDLER_MARKER_ATTR, "tier2.journald")
|
||||
return handler
|
||||
|
||||
|
||||
_TIER_FACTORIES: Final[dict[int, Any]] = {
|
||||
1: _make_tier1_handler,
|
||||
2: _make_tier2_handler,
|
||||
}
|
||||
|
||||
|
||||
def configure_logging(
|
||||
*,
|
||||
tier: int,
|
||||
level: str = "INFO",
|
||||
target_loggers: Iterable[str] = ("",),
|
||||
) -> None:
|
||||
"""Install the handler topology for ``tier`` on the named loggers.
|
||||
|
||||
Idempotent: re-calling with the same tier replaces any prior handler
|
||||
of that kind (no duplicates — AC-4). Switching tiers removes prior
|
||||
tier handlers and installs the new one.
|
||||
|
||||
By default, the root logger (``""``) is configured so every named
|
||||
logger inherits the handler.
|
||||
"""
|
||||
if tier not in _TIER_FACTORIES:
|
||||
raise ValueError(f"unsupported logging tier: {tier!r} (expected 1 or 2)")
|
||||
|
||||
new_handler = _TIER_FACTORIES[tier]()
|
||||
level_value = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
with _LOGGING_LOCK:
|
||||
for name in target_loggers:
|
||||
target = logging.getLogger(name)
|
||||
target.handlers = [
|
||||
h for h in target.handlers if not getattr(h, _HANDLER_MARKER_ATTR, None)
|
||||
]
|
||||
target.addHandler(new_handler)
|
||||
target.setLevel(level_value)
|
||||
target.propagate = name != ""
|
||||
|
||||
|
||||
def _bootstrap_default_handler() -> None:
|
||||
"""Attach a default Tier-1 handler if no schema handler is yet installed.
|
||||
|
||||
Called lazily on first ``get_logger`` so importing a component that
|
||||
logs at module-import time still produces well-formed records before
|
||||
the composition root runs ``configure_logging``.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
has_schema_handler = any(getattr(h, _HANDLER_MARKER_ATTR, None) for h in root.handlers)
|
||||
if has_schema_handler:
|
||||
return
|
||||
env_tier = os.getenv("GPS_DENIED_TIER", "1")
|
||||
tier = 2 if env_tier.strip() == "2" else 1
|
||||
env_level = os.getenv("LOG_LEVEL", "INFO")
|
||||
try:
|
||||
configure_logging(tier=tier, level=env_level)
|
||||
except RuntimeError:
|
||||
configure_logging(tier=1, level=env_level)
|
||||
|
||||
|
||||
def get_logger(component_id: str) -> logging.Logger:
|
||||
"""Return a `Logger` whose records satisfy `log_record_schema` v1.0.0.
|
||||
|
||||
Repeated calls with the same `component_id` return the same cached
|
||||
`Logger` instance (Python stdlib's named-logger registry). The first
|
||||
call installs a default Tier-1 handler if the composition root has
|
||||
not yet run ``configure_logging``.
|
||||
"""
|
||||
_bootstrap_default_handler()
|
||||
logger = logging.getLogger(component_id)
|
||||
logger.setLevel(logging.NOTSET)
|
||||
return logger
|
||||
|
||||
|
||||
# Backwards-compat alias for the formatter's prior name (used by Tier-1 unit tests).
|
||||
_JsonFormatter = JsonFormatter
|
||||
|
||||
Reference in New Issue
Block a user