"""Marchwarden observability — structured application logging. Operational logs for administrators. NOT the same as the JSONL traces in `~/.marchwarden/traces/` — those are per-research-call audit logs for verifying provenance. These logs cover system events: startup, shutdown, errors, MCP transport activity, cost ledger writes. Format is structured-by-default (structlog) so logs can be ingested into OpenSearch or similar without per-call formatter ceremony. Usage: from obs import configure_logging, get_logger configure_logging() # call once at process startup log = get_logger("marchwarden.cli") log.info("ask_started", question=question, depth=depth) # Bind context that flows to every downstream log call: log = log.bind(trace_id="abc-123", researcher="web") log.info("research_started") # automatically includes trace_id + researcher """ import logging import os import sys from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Optional import structlog _CONFIGURED = False def _resolve_format() -> str: """Pick the renderer: explicit env override, else auto-detect TTY.""" explicit = os.environ.get("MARCHWARDEN_LOG_FORMAT") if explicit in {"json", "console"}: return explicit return "console" if sys.stderr.isatty() else "json" def _resolve_level() -> int: name = os.environ.get("MARCHWARDEN_LOG_LEVEL", "INFO").upper() return getattr(logging, name, logging.INFO) def configure_logging(force: bool = False) -> None: """Configure structlog + stdlib logging once per process. Idempotent — subsequent calls are no-ops unless ``force=True``. Honors: - MARCHWARDEN_LOG_LEVEL (default INFO) - MARCHWARDEN_LOG_FORMAT (json|console; auto-detected from TTY if unset) - MARCHWARDEN_LOG_FILE (truthy → also log to ~/.marchwarden/logs/marchwarden.log) """ global _CONFIGURED if _CONFIGURED and not force: return level = _resolve_level() fmt = _resolve_format() timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True) shared_processors = [ structlog.contextvars.merge_contextvars, structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, timestamper, structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, ] if fmt == "json": renderer: structlog.types.Processor = structlog.processors.JSONRenderer() else: renderer = structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()) structlog.configure( processors=shared_processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter], logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.make_filtering_bound_logger(level), cache_logger_on_first_use=True, ) formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, renderer, ], ) # Always log to stderr so MCP stdio stdout stays clean. handler: logging.Handler = logging.StreamHandler(sys.stderr) handler.setFormatter(formatter) root = logging.getLogger() # Replace handlers so re-configuration in tests works cleanly. root.handlers = [handler] root.setLevel(level) if os.environ.get("MARCHWARDEN_LOG_FILE"): log_dir = Path(os.path.expanduser("~/.marchwarden/logs")) log_dir.mkdir(parents=True, exist_ok=True) file_handler = RotatingFileHandler( log_dir / "marchwarden.log", maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", ) file_handler.setFormatter(formatter) root.addHandler(file_handler) # Quiet a few noisy third-party loggers unless DEBUG is requested. if level > logging.DEBUG: for noisy in ("httpx", "httpcore", "anthropic"): logging.getLogger(noisy).setLevel(logging.WARNING) _CONFIGURED = True def get_logger(name: str) -> structlog.stdlib.BoundLogger: """Return a bound structlog logger. Configures logging on first call.""" if not _CONFIGURED: configure_logging() return structlog.get_logger(name)