"""Logging filter that redacts API keys in log output.
Attaches to the root logger so all loggers (including httpx/httpcore)
have keys partially censored: first 4 chars + ``...`` + last 4 chars.
"""
from __future__ import annotations
import logging
import re
_KEY_RE = re.compile(r"(key=)([A-Za-z0-9_-]{12,})")
def _redact_match(m: re.Match) -> str:
"""Censor a single ``key=<value>`` regex match captured by ``_KEY_RE``.
Rebuilds the matched substring keeping the literal ``key=`` prefix
(capture group 1) but replacing the secret value (capture group 2) with
its first four characters, a ``...`` ellipsis, and its last four
characters, so the value is recognizable for correlation while never being
fully exposed in a log line. Pure string transformation with no side
effects, network, or I/O.
This is passed as the replacement callable to ``_KEY_RE.sub`` inside
:func:`redact_api_keys`; it has no other internal callers and is not part
of the public API.
Args:
m: A regex match produced by ``_KEY_RE`` where group 1 is the ``key=``
prefix and group 2 is the 12+ character secret value.
Returns:
str: The reconstructed ``key=`` text with the value partially masked
(e.g. ``key=abcd...wxyz``).
"""
prefix, value = m.group(1), m.group(2)
return f"{prefix}{value[:4]}...{value[-4:]}"
[docs]
def redact_api_keys(text: str) -> str:
"""Replace API key values in *text* with a partially-masked form.
Scans the input for ``key=<secret>`` patterns via ``_KEY_RE`` and rewrites
each one through :func:`_redact_match` so the secret survives in logs only
as its first four and last four characters joined by an ellipsis. This keeps
log lines correlatable without ever exposing a full credential. Pure string
transformation with no I/O or side effects.
Called by :meth:`ApiKeyRedactionFilter.filter` for every log record routed
through the handler the filter is attached to, and exercised directly by
``tests/core/test_enhanced_logging.py``.
Args:
text: The fully-formatted log message that may embed one or more
``key=`` secrets.
Returns:
str: The same text with every matched key value partially masked; the
original string is returned unchanged when no key pattern is present.
"""
return _KEY_RE.sub(_redact_match, text)
[docs]
class ApiKeyRedactionFilter(logging.Filter):
"""Logging filter that censors API keys before they reach the handler.
A :class:`logging.Filter` subclass that sanitizes rather than suppresses:
every record passing through is allowed onward, but any embedded ``key=``
secret is partially masked first via :func:`redact_api_keys`. Installing it
on the root/console handler means downstream-library noise (httpx, httpcore)
is scrubbed alongside the bot's own log lines.
Instantiated and attached to a handler by ``core/log_config.py`` (which
calls ``addFilter`` on the console handler), and asserted to be present by
``tests/core/test_enhanced_logging.py``. The actual per-record work lives in
:meth:`filter`.
"""
[docs]
def filter(self, record: logging.LogRecord) -> bool:
"""Redact API keys in a log record in place before it is emitted.
Implements the :class:`logging.Filter` contract. Renders the record's
fully-formatted message via ``record.getMessage()`` (which interpolates
``record.args``), passes it through :func:`redact_api_keys`, and — only
when redaction actually changed the text — overwrites ``record.msg``
with the censored string and clears ``record.args`` to ``None`` so the
handler does not attempt a second ``%``-format pass against the
already-substituted message. The record is always allowed through
(returns ``True``); this filter sanitizes rather than suppresses.
Registered on a handler (typically the root/console handler) by
``core/log_config.py``, which constructs an :class:`ApiKeyRedactionFilter`
and calls ``ch.addFilter(...)``; the standard logging machinery invokes
this method for every record routed through that handler. It is also
asserted to be present by ``tests/core/test_enhanced_logging.py``.
Args:
record: The log record about to be handled; ``record.msg`` and
``record.args`` may be mutated in place when a key is found.
Returns:
bool: Always ``True`` so the (possibly redacted) record continues
to the handler.
"""
original = record.getMessage()
redacted = redact_api_keys(original)
if redacted != original:
record.msg = redacted
record.args = None
return True