Files
cista-storage/cista/sanic_logging.py
T
LeoVasanko bd96b2c7ba Cleaner error logging: sanic loggers at INFO, 499/503 for cancelled requests, shortened preview error reasons
- Force sanic.root/error/server back to INFO from before_server_start, after
  dev mode's runtime setLevel(DEBUG) — kills the useless 'Error Page:' noise
- Silence mediapreview.pool WARNINGs (timeouts already in access log extra);
  they previously fell to logging.lastResort with no level prefix
- Handle CancelledError with 499 (client disconnect / RequestCancelled) or
  503 (server shutdown) instead of Sanic's default 500 error page
- 422 preview failures now log 'backend: reason' in the access log, with
  upstream error text shortened (first line, no [Errno] prefix, cut at ': ');
  dev mode prints the full original error to console
2026-08-12 03:45:39 +00:00

317 lines
10 KiB
Python

"""Custom access logging middleware for Sanic."""
import logging
import os
import sys
from ipaddress import IPv6Address
from sanic.log import LOGGING_CONFIG_DEFAULTS
from cista.util.logformat import EmojiFormatter as _EmojiFormatter
from cista.util.logformat import display_width as _display_width
logger = logging.getLogger("cista.access")
class ReentrantSafeStreamHandler(logging.StreamHandler):
"""Stream handler that degrades gracefully on signal-time reentrant writes.
Python's buffered text streams are not reentrant. If a signal handler logs
while another log write is in progress, StreamHandler.emit can raise:
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
Instead of letting logging emit a long "--- Logging error ---" traceback,
we fall back to a best-effort os.write to the same file descriptor.
"""
def emit(self, record: logging.LogRecord) -> None:
msg = ""
try:
msg = self.format(record)
stream = self.stream
stream.write(msg + self.terminator)
self.flush()
except RuntimeError as exc:
if "reentrant call inside" not in str(exc):
self.handleError(record)
return
stream = self.stream
fd = stream.fileno()
encoding = getattr(stream, "encoding", None) or "utf-8"
data = (msg + self.terminator).encode(encoding, errors="replace")
os.write(fd, data)
except RecursionError:
raise
except Exception:
self.handleError(record)
_RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (light grey)
_TIMING = "\033[38;5;242m" # timing (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
def format_ipv6_network(ip: str) -> str:
"""Format IPv6 address to show only network part (first 64 bits)."""
try:
ip = ip.strip("[]")
if "%" in ip:
ip = ip.split("%")[0]
addr = IPv6Address(ip)
if addr.is_loopback:
return "::1"
if addr.is_unspecified:
return "::"
if addr.ipv4_mapped:
return str(addr.ipv4_mapped)
if addr.is_link_local:
return str(addr)
network_int = int(addr) >> 64
groups = []
for _ in range(4):
groups.insert(0, format(network_int & 0xFFFF, "x"))
network_int >>= 16
result = ":".join(groups) + "::"
return str(IPv6Address(result + "0")).removesuffix("::")
except Exception:
return ip
def format_client_ip(ip: str) -> str:
"""Format client IP, compressing IPv6 to network part only."""
if not ip or ip == "-":
return "-"
stripped = ip.strip("[]")
if ":" in stripped:
return format_ipv6_network(stripped)
return stripped
def status_color(status: int) -> str:
if status < 200:
return _STATUS_INFO
if status < 300:
return _STATUS_OK
if status < 400:
return _STATUS_REDIRECT
if status < 500:
return _STATUS_CLIENT_ERR
return _STATUS_SERVER_ERR
def method_color(method: str) -> str:
if method in ("GET", "HEAD", "OPTIONS"):
return _METHOD_READ
return _METHOD_WRITE
def format_duration_ms(duration_ms: float) -> str:
rounded_ms = round(duration_ms)
if rounded_ms < 2000:
return f"{rounded_ms}ms"
total_s = round(duration_ms / 1000)
if total_s < 60:
return f"{total_s}s"
if total_s <= 3600:
minutes, seconds = divmod(total_s, 60)
return f"{minutes}m{seconds}s"
hours, remainder = divmod(total_s, 3600)
minutes = round(remainder / 60)
if minutes == 60:
hours += 1
minutes = 0
return f"{hours}h{minutes}m"
def _format_left(label: str) -> str:
return label[:19].ljust(19)
def _format_method_label(label: str, *, color: str | None = None) -> str:
color_value = _METHOD_WRITE if color is None else color
padding = max(0, 7 - _display_width(label))
return f"{color_value}{label}{' ' * padding}{_RESET}"
def format_access_log(
client: str,
status: int,
method: str,
host: str,
path: str,
*,
duration_ms: float,
extra: str | None = None,
) -> str:
ip = _format_left(format_client_ip(client))
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
method_str = _format_method_label(method, color=method_color(method))
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return (
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
)
_ws_counter = 1
def _next_ws_id() -> int:
global _ws_counter
ws_id = _ws_counter
_ws_counter += 1
return ws_id
def _format_ws_id(ws_id: int, *, bright: bool = False) -> str:
value = str(ws_id) if ws_id >= 100 else f"{ws_id:02d}"
color = _WS_OPEN if bright else _WS_CLOSE
return f"{color}{value.rjust(3)}{_RESET}"
def log_ws_open(request, extra: str | None = None) -> int:
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
ws_id = _next_ws_id()
client = request.client_ip or "-"
host = request.host or "-"
path = request.path
origin = request.headers.get("origin")
ip = _format_left(format_client_ip(client))
id_str = _format_ws_id(ws_id, bright=True)
origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host
method_str = _format_method_label("🔌", color=_WS_OPEN)
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info(
"%s %s %s %s%s%s",
ip,
id_str,
method_str,
host_str,
path_str,
origin_str + extra_str,
)
return ws_id
WS_CLOSE_CODES = {
1000: "ok",
1001: "going away",
1002: "protocol error",
1003: "unsupported",
1005: "no status",
1006: "abnormal",
1007: "invalid data",
1008: "policy violation",
1009: "too large",
1010: "extension required",
1011: "server error",
1012: "restarting",
1013: "try again",
1014: "bad gateway",
1015: "tls error",
}
def log_ws_close(
ws_id: int, close_code: int | None, duration: float, extra: str | None = None
) -> None:
"""Log WebSocket connection close with duration and status."""
id_str = _format_ws_id(ws_id)
timing = format_duration_ms(duration * 1000)
if close_code is None:
code = "----"
status = "unknown"
else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
method_str = _format_method_label("closed", color=_TIMING)
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
logger.info(
"%s %s %s %s %s%s",
" " * 19,
id_str,
method_str,
status_str,
timing_str,
extra_str,
)
def configure_access_logging() -> None:
"""Configure the cista.access logger to output to stderr."""
handler = ReentrantSafeStreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
def configure_main_logging() -> None:
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
call Sanic makes during serve_single() / serve().
"""
for handler_name in ("console", "error_console", "access_console"):
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
"cista.sanic_logging.ReentrantSafeStreamHandler"
)
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
"class": "cista.sanic_logging._EmojiFormatter",
}
# Sanic passes its "sanic.websockets" logger to websockets' ServerProtocol,
# so "connection closed" (websockets >= 17, INFO) and Sanic's own
# "Websocket timed out waiting for pong" (WARNING) both emit via
# sanic.websockets, not websockets.server. Raise it to ERROR so these
# routine disconnect messages are dropped while real errors still show.
# Patch the config defaults too, so the level survives Sanic's dictConfig.
LOGGING_CONFIG_DEFAULTS["loggers"]["sanic.websockets"]["level"] = "ERROR"
logging.getLogger("sanic.websockets").setLevel(logging.ERROR)
# Preview worker timeouts are already annotated in the access log extra;
# the pool's WARNING would otherwise fall to logging.lastResort, printing
# a bare message with no level prefix.
logging.getLogger("mediapreview.pool").setLevel(logging.ERROR)
# Also reformat any handlers already attached (covers the initial Sanic() call)
for name in ("sanic.root", "sanic.error", "sanic.server", "sanic.websockets"):
for handler in logging.getLogger(name).handlers:
handler.setFormatter(_EmojiFormatter())
def reset_sanic_log_levels() -> None:
"""Force Sanic's loggers back to INFO in debug/dev mode.
Debug mode enables DEBUG on sanic.root at runtime
(ApplicationState.set_mode calls logger.setLevel(DEBUG)), which unleashes
useless noise like the 'Error Page:' content-negotiation messages. Call
from before_server_start so the override lands after Sanic's own setup.
"""
for name in ("sanic.root", "sanic.error", "sanic.server"):
logging.getLogger(name).setLevel(logging.INFO)