"""Structured JSON logging (stdlib only). Logging policy: log lines carry metadata only — never prompts, LLM responses, user messages or document text. Exceptions are reduced to their type plus a sanitized message; SQLAlchemy statement/param dumps are stripped because parameters can contain user content. """ import json import logging import sys from contextvars import ContextVar from datetime import UTC, datetime from typing import Any from app.config import get_settings correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None) conversation_id: ContextVar[str | None] = ContextVar("conversation_id", default=None) # LogRecord attributes that are not user-supplied extras. _STANDARD_ATTRS = 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", } ) def safe_error(exc: BaseException, limit: int = 300) -> str: """Exception text that is safe to log or persist (no content leaks). SQLAlchemy appends "[SQL: ...] [parameters: (...)]" to its messages; parameters can contain user content, so everything from "[SQL" on is cut. """ text = str(exc).split("[SQL", 1)[0].strip() return f"{type(exc).__name__}: {text[:limit]}" class JsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: payload: dict[str, Any] = { "ts": datetime.fromtimestamp(record.created, tz=UTC).isoformat( timespec="milliseconds" ), "level": record.levelname, "logger": record.name, "message": record.getMessage(), } cid = correlation_id.get() if cid: payload["correlation_id"] = cid conv = conversation_id.get() if conv: payload["conversation_id"] = conv for key, value in record.__dict__.items(): if key not in _STANDARD_ATTRS and not key.startswith("_"): payload[key] = value if record.exc_info and record.exc_info[1] is not None: payload["error"] = safe_error(record.exc_info[1]) return json.dumps(payload, default=str) # These third-party loggers dump request/response bodies at DEBUG — with # prompts and user content in them, so they stay capped at INFO unless # content debug logging is explicitly enabled. _CONTENT_DEBUG_LOGGERS = ("openai", "httpx", "httpcore") def apply_content_log_guard() -> None: level = logging.DEBUG if get_settings().debug_log_prompts else logging.INFO for name in _CONTENT_DEBUG_LOGGERS: logging.getLogger(name).setLevel(level) def setup_logging() -> None: handler = logging.StreamHandler(sys.stdout) handler.setFormatter(JsonFormatter()) root = logging.getLogger() root.handlers = [handler] root.setLevel(get_settings().log_level.upper()) apply_content_log_guard()