import json import logging import os import socket import traceback from datetime import datetime, timezone from typing import Any from urllib.parse import urlsplit _STANDARD_RECORD_ATTRS = { "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 _json_default(value: Any) -> str: return repr(value) def _utc_timestamp(created: float) -> str: return datetime.fromtimestamp(created, tz=timezone.utc).isoformat(timespec="milliseconds") def _split_client_addr(client_addr: Any) -> tuple[str | None, int | None]: if not isinstance(client_addr, str): return None, None host, sep, port = client_addr.rpartition(":") if not sep: return client_addr, None try: return host, int(port) except ValueError: return client_addr, None class JsonFormatter(logging.Formatter): """ Emit one JSON object per log line for container log collectors. Uvicorn access logs carry structured fields in record.args; keep using uvicorn's own access logger and only unpack those fields at format time. """ def __init__(self, *args, service_name: str | None = None, environment: str | None = None, **kwargs): super().__init__(*args, **kwargs) self.service_name = service_name or os.getenv("SERVICE_NAME", "flower") self.environment = environment or os.getenv("ENVIRONMENT") or os.getenv("DJANGO_ENV") or "" self.hostname = socket.gethostname() def format(self, record: logging.LogRecord) -> str: event = { "timestamp": _utc_timestamp(record.created), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "service": self.service_name, "environment": self.environment, "hostname": self.hostname, "module": record.module, "function": record.funcName, "line": record.lineno, "process": record.process, "process_name": record.processName, "thread": record.threadName, } if record.name == "uvicorn.access": event.update(self._format_uvicorn_access(record)) if record.exc_info: exc_type, exc_value, _tb = record.exc_info event["exception"] = { "type": exc_type.__name__ if exc_type else None, "message": str(exc_value) if exc_value else None, "stacktrace": "".join(traceback.format_exception(*record.exc_info)), } elif record.exc_text: event["exception"] = {"stacktrace": record.exc_text} if record.stack_info: event["stack_info"] = record.stack_info extra = self._collect_extra(record) if extra: event["extra"] = extra return json.dumps(event, ensure_ascii=False, default=_json_default, separators=(",", ":")) def _format_uvicorn_access(self, record: logging.LogRecord) -> dict[str, Any]: if not isinstance(record.args, tuple) or len(record.args) != 5: return {} client_addr, method, full_path, http_version, status_code = record.args split_result = urlsplit(str(full_path)) client_host, client_port = _split_client_addr(client_addr) return { "client_addr": client_addr, "client_host": client_host, "client_port": client_port, "method": method, "full_path": full_path, "path": split_result.path or str(full_path), "query_string": split_result.query, "http_version": http_version, "status_code": int(status_code), } def _collect_extra(self, record: logging.LogRecord) -> dict[str, Any]: extra = {} for key, value in record.__dict__.items(): if key.startswith("_") or key in _STANDARD_RECORD_ATTRS: continue extra[key] = value return extra class MaxLevelFilter(logging.Filter): """Allow records below the configured level.""" def __init__(self, max_level: str | int): super().__init__() self.max_level = logging._checkLevel(max_level) def filter(self, record: logging.LogRecord) -> bool: return record.levelno < self.max_level