Files
pablan/backend/app/log.py
T
ProfessorNovaandClaude Opus 5 97dbff309c Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 08:36:17 +02:00

104 lines
3.2 KiB
Python

"""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()