1"""Request-exception logging.
2
3`log_exception` is the single logging entry point for exceptions raised
4during request handling. Called from `View.get_response` for exceptions
5that reach `handle_exception`, and from the framework's
6`response_for_exception` for pre-view failures (URL resolution,
7middleware). The sentinel attribute makes it idempotent, so an exception
8caught at multiple layers is logged once.
9"""
10
11from __future__ import annotations
12
13import logging
14from typing import TYPE_CHECKING
15
16from .logger import get_framework_logger
17
18if TYPE_CHECKING:
19 from plain.http import Request
20
21
22request_logger = get_framework_logger("plain.request")
23
24_LOGGED_SENTINEL = "_plain_logged"
25
26
27def log_exception(request: Request, exc: Exception) -> None:
28 """Log an exception raised during request handling.
29
30 Idempotent — setting a sentinel on the exception means multiple call
31 sites won't double-log. 404s are skipped unconditionally since
32 crawler/probe noise drowns real signal.
33 """
34 # Deferred to avoid a circular import: plain.logs is loaded during
35 # plain.runtime bootstrap, which happens before plain.http is ready.
36 from plain.http.exceptions import (
37 HTTPException,
38 NotFoundError404,
39 SuspiciousOperationError400,
40 )
41
42 if getattr(exc, _LOGGED_SENTINEL, False):
43 return
44 setattr(exc, _LOGGED_SENTINEL, True)
45
46 if isinstance(exc, NotFoundError404):
47 return
48
49 base = {"path": request.path}
50
51 if isinstance(exc, SuspiciousOperationError400):
52 # Logged on plain.security.* so operators can target an alert at
53 # security events specifically. Warning (no exc_info) because the
54 # rejection is the working-as-designed response — same noise
55 # category as 404s once a scanner is probing nonexistent paths.
56 security_logger = logging.getLogger(f"plain.security.{type(exc).__name__}")
57 security_logger.warning(str(exc), extra=base)
58 return
59
60 if isinstance(exc, HTTPException):
61 request_logger.warning(
62 "HTTP error",
63 extra={**base, "error": str(exc), "status_code": exc.status_code},
64 )
65 return
66
67 request_logger.error("Server error", extra=base, exc_info=exc)