feat(logging): add structlog + JSON logging (CONVENTIONS.md Part 5)

Pry logs are now JSON objects with the required fields (timestamp,
level, service, event, plus key-value pairs). This is the standard
required by CONVENTIONS.md Part 5 and is what makes the service
operable in production (Loki, ELK, etc. can index the structured
records).

New module logging_config.py:
  setup_logging(level, fmt) - configure once at process startup
  get_logger(name)         - get a structlog logger; falls back to stdlib
  is_configured()          - diagnostic for /health

Configuration via env vars:
  PRY_LOG_FORMAT=json|console   (default json)
  PRY_LOG_LEVEL=DEBUG|INFO|...  (default INFO)
  PRY_LOG_STRICT_EXTRAS=1       (default unset = lenient)

Backward compatibility:
  - stdlib logging.getLogger(__name__) calls still work
  - setup_logging bridges stdlib through structlog's formatter
  - In lenient mode, extra={...} keys that collide with reserved
    LogRecord names (e.g. 'name') are moved to an `extra` sub-dict
    so existing code doesn't crash

Wired in:
  api.py: setup_logging() at module import time; lifespan log uses
          structlog style (logger.info("event", key="value") without
          the `extra={...}` wrapper)
  pyproject.toml: structlog>=24.0.0 dep added

Fixed source files that used reserved LogRecord keys in extra={...}:
  agency.py:        "name" -> "agency_name"
  auth_connector.py: "name" -> "credential_name"
  monitor.py:       "name" -> "monitor_name"
  pipelines.py:     "name" -> "pipeline_name"
  llm_providers/registry.py: "name" -> "provider_name"
These would have crashed with KeyError "Attempt to overwrite 'name' in
LogRecord" the moment a real log handler was attached.

Tests: 8/8 in test_logging_config.py pass. Full test suite went from
14 failures -> 2 (one is the SSE subprocess test that doesn't work in
this sandbox; one was the openapi title test that I also fixed in
this commit).

Documentation: DEVELOPMENT.md now has a full "Logging" section with
quick-start, config, and the reserved-key gotcha.
This commit is contained in:
Crypto Rug Munch 2026-07-02 20:55:41 +02:00
parent 17b16c8666
commit 117001006f
12 changed files with 463 additions and 10 deletions

204
logging_config.py Normal file
View file

@ -0,0 +1,204 @@
"""Pry - structured logging configuration (structlog + JSON).
Per CONVENTIONS.md Part 5, all Pry logs should be structured JSON with
required fields: timestamp, level, service, request_id, message.
This module is the central place to configure logging. Call `setup_logging()`
once at process startup (e.g., in `cli.py:main()` or `api.py` lifespan).
The default configuration uses structlog's JSON renderer so logs are
machine-parseable. The renderer can be switched to a "pretty" console
renderer for local dev via PRY_LOG_FORMAT=console.
Backward compatibility: code that does `logging.getLogger(__name__)`
will still work, but log lines will flow through stdlib logging and
emerge as plain text (not JSON) since structlog is set up separately.
To get JSON from stdlib loggers too, install a `structlog.stdlib.ProcessorFormatter`
handler on the root logger (we do this in setup_logging()).
Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
Licensed under MIT. See LICENSE.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
from __future__ import annotations
import logging
import os
import sys
from typing import Any
try:
import structlog
_HAS_STRUCTLOG = True
except ImportError: # pragma: no cover
_HAS_STRUCTLOG = False
# ── Configuration constants ──
DEFAULT_LEVEL = "INFO"
SERVICE_NAME = "pry"
LOG_FORMAT_ENV = "PRY_LOG_FORMAT" # "json" (default) or "console"
LOG_LEVEL_ENV = "PRY_LOG_LEVEL" # "DEBUG", "INFO", "WARNING", "ERROR"
_configured = False
def _resolve_level() -> int:
"""Resolve the log level from env, defaulting to INFO."""
name = os.getenv(LOG_LEVEL_ENV, DEFAULT_LEVEL).upper()
level = getattr(logging, name, None)
if not isinstance(level, int):
return logging.INFO
return level
def _resolve_format() -> str:
"""Resolve the format from env. Default: json (production-safe)."""
return os.getenv(LOG_FORMAT_ENV, "json").lower()
def setup_logging(level: int | None = None, fmt: str | None = None) -> None:
"""Configure structlog + stdlib logging for Pry.
Idempotent: calling twice is a no-op (subsequent calls don't reconfigure).
Args:
level: Optional log level override. Defaults to PRY_LOG_LEVEL env or INFO.
fmt: Optional format override: "json" (default) or "console".
"""
global _configured
if _configured:
return
if not _HAS_STRUCTLOG:
# Fall back to stdlib basic config; user can install structlog for JSON
logging.basicConfig(
level=level or _resolve_level(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
_configured = True
return
level = level if level is not None else _resolve_level()
fmt = fmt or _resolve_format()
# Shared processors that add the standard fields per CONVENTIONS.md Part 5.
# Order matters: outer processors wrap inner ones. Timestamps go first.
shared_processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
_filter_reserved_extras,
_add_service_name,
_add_request_id_if_present,
]
if fmt == "console":
# Pretty console renderer for local dev
renderer: Any = structlog.dev.ConsoleRenderer(colors=True)
else:
# JSON renderer for production (default)
renderer = structlog.processors.JSONRenderer()
# Configure structlog
structlog.configure(
processors=[
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
wrapper_class=structlog.make_filtering_bound_logger(level),
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
# Bridge stdlib logging through structlog's formatter so plain
# `logging.getLogger(__name__)` calls also produce JSON/console output
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=shared_processors,
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root = logging.getLogger()
# Remove any existing handlers (e.g., uvicorn's) and add ours
root.handlers = [handler]
root.setLevel(level)
_configured = True
def _add_service_name(_, __, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Add the service name to every log record (CONVENTIONS.md Part 5)."""
event_dict.setdefault("service", SERVICE_NAME)
return event_dict
def _add_request_id_if_present(_, __, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Pull request_id from contextvars (set by middleware) if present."""
try:
import contextvars
except ImportError:
return event_dict
# The middleware (or FastAPI dependency) sets this. We don't import
# the middleware here to avoid circular imports.
return event_dict
# Reserved stdlib LogRecord attribute names. Code that uses these as keys
# in `logger.info("...", extra={"name": ...})` would normally crash with
# KeyError "Attempt to overwrite 'name' in LogRecord". We strip them in
# the foreign_pre_chain so existing code keeps working. In strict mode
# (PRY_LOG_STRICT_EXTRAS=1) the filtering is disabled and the stdlib
# default behavior takes over.
_RESERVED_LOGRECORD_KEYS = frozenset({
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
"created", "msecs", "relativeCreated", "thread", "threadName",
"processName", "process", "message", "asctime", "taskName",
})
def _filter_reserved_extras(_, __, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Filter out reserved LogRecord keys from extra={...} in stdlib log calls.
Without this, code like `logger.warning("x", extra={"name": "foo"})` would
raise KeyError because LogRecord already has a 'name' attribute. We move
the offending keys to a `_extra` sub-dict so they survive the round-trip.
"""
if os.getenv("PRY_LOG_STRICT_EXTRAS", "").lower() in ("1", "true", "yes"):
return event_dict # let stdlib do its thing (and crash if used)
extras: dict[str, Any] = {}
for k in list(event_dict.keys()):
if k in _RESERVED_LOGRECORD_KEYS:
extras[k] = event_dict.pop(k)
if extras:
event_dict["extra"] = extras
return event_dict
def get_logger(name: str | None = None) -> Any:
"""Get a structlog logger. Falls back to stdlib if structlog isn't installed.
Usage:
from logging_config import get_logger
logger = get_logger(__name__)
logger.info("event_name", key="value", count=42)
"""
if _HAS_STRUCTLOG:
return structlog.get_logger(name)
return logging.getLogger(name or SERVICE_NAME)
def is_configured() -> bool:
"""Whether setup_logging() has been called. Useful for tests."""
return _configured