Coverage for src/local_deep_research/security/secure_logging.py: 100%
21 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Shared logging helpers for controlling exception detail.
3``log_exception_type()`` logs a fixed action and exception class at ERROR
4without exception text or tracebacks, including in diagnose mode.
6Provider and search-engine exception handlers must stay observable at
7ERROR level, but plain ``loguru.logger.exception()`` always attaches the
8active exception to the record — every sink (stderr, database, frontend)
9then renders or persists the traceback, including the ``__cause__`` /
10``__context__`` chain, which has leaked Authorization headers and full
11URLs embedded in wrapped exceptions (#4183).
13:data:`logger` exported here is a :class:`SecureLogger` proxy around the
14real loguru logger. Its ``.exception()`` is equivalent to::
16 logger.opt(exception=is_diagnose_mode()).error(...)
18so in normal operation the record carries *no* exception object — no sink
19can render a traceback — while the event itself still lands at ERROR.
20Developers opt into full tracebacks by setting **both** ``LDR_APP_DEBUG``
21and ``LDR_LOGURU_DIAGNOSE`` to a truthy value ("1", "true", "yes").
23Usage — a drop-in replacement for the loguru import::
25 from ..security.secure_logging import logger
27 try:
28 ...
29 except Exception as e:
30 safe_msg = scrub_error(e) # from ..security.log_sanitizer
31 logger.exception(f"Engine request failed: {safe_msg}")
33Caveats:
35* **Messages are production-visible at ERROR.** The wrapper only strips
36 the traceback; the message string itself reaches every sink. Call sites
37 must still pass scrubbed text (``scrub_error`` — or in engine
38 subclasses ``self._scrub_error``), never raw ``str(e)``.
39* **Known bypass routes.** ``logger.opt(exception=True).error(...)`` and
40 ``@logger.catch`` delegate to plain loguru and are NOT gated. No call
41 site under ``llm/providers/``, ``embeddings/providers/`` or
42 ``web_search_engines/`` uses them today; the #4183 step-2 pre-commit
43 hook should detect them.
44* **Env-gated only.** ``config_logger()`` in ``utilities/log_utils.py``
45 gates its per-sink ``diagnose`` flag on its ``debug`` argument, which
46 may come from the ``app.debug`` DB setting — this wrapper reads only
47 the environment, so a DB-enabled debug mode does not enable wrapper
48 tracebacks. See the cross-reference comment in ``config_logger``.
49"""
51import os
53from loguru import logger as _loguru_logger
56def log_exception_type(action: str, exc: Exception) -> None:
57 """Log a fixed action and exception class without exception details.
59 ``action`` must be developer-written text, with no request data or
60 exception text interpolated into it. The exception is never stringified
61 or attached to the record, even in diagnose mode. ``depth=1`` preserves
62 the caller's location and module-based logging filters.
63 """
64 _loguru_logger.opt(exception=False, depth=1).error(
65 "{action} ({error_type})",
66 action=action,
67 error_type=type(exc).__name__,
68 )
71def env_truthy(name: str) -> bool:
72 """Return True if env var *name* is set to a truthy value ("1", "true", "yes")."""
73 return os.environ.get(name, "").strip().lower() in ("1", "true", "yes")
76def is_diagnose_mode() -> bool:
77 """Return True when the operator explicitly opted into traceback output.
79 Requires BOTH ``LDR_APP_DEBUG`` and ``LDR_LOGURU_DIAGNOSE`` to be
80 truthy, mirroring the two-flag opt-in that ``config_logger()`` uses
81 for loguru's frame-locals ``diagnose`` rendering.
82 """
83 return env_truthy("LDR_APP_DEBUG") and env_truthy("LDR_LOGURU_DIAGNOSE")
86class SecureLogger:
87 """Thin proxy over the loguru logger with a diagnose-gated ``exception()``.
89 Everything except ``exception()``, ``bind()`` and ``patch()``
90 delegates straight to loguru. Not a ``loguru._logger.Logger``
91 subclass: its ``__init__`` takes ~10 positional private arguments
92 that shift between releases, so a proxy is the version-stable shape.
93 """
95 __slots__ = ("_logger",)
97 def __init__(self, wrapped=None):
98 self._logger = wrapped if wrapped is not None else _loguru_logger
100 def exception(__self, __message, *args, **kwargs): # noqa: N805
101 """Log at ERROR; attach the active exception only in diagnose mode.
103 Positional-only ``__self`` / ``__message`` mirror loguru's own
104 signature so ``str.format`` keyword fields (e.g. ``msg=``) cannot
105 collide with parameter names. ``depth=1`` attributes the record
106 to the caller's module/function/line, keeping
107 ``logger.enable()`` / ``logger.disable()`` namespace semantics.
108 """
109 __self._logger.opt(exception=is_diagnose_mode(), depth=1).error(
110 __message, *args, **kwargs
111 )
113 def bind(self, *args, **kwargs):
114 """Like loguru's ``bind()`` but the result keeps the gated ``exception()``."""
115 return SecureLogger(self._logger.bind(*args, **kwargs))
117 def patch(self, *args, **kwargs):
118 """Like loguru's ``patch()`` but the result keeps the gated ``exception()``."""
119 return SecureLogger(self._logger.patch(*args, **kwargs))
121 def __getattr__(self, name):
122 return getattr(self._logger, name)
125logger = SecureLogger()