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
104 lines
3.2 KiB
Python
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()
|