Edit on GitHub

agent_search_gateway.observability

Secret-safe structured observability primitives.

  1"""Secret-safe structured observability primitives."""
  2
  3import json
  4import logging
  5import re
  6from collections.abc import Callable, Iterable
  7from contextlib import suppress
  8from logging.handlers import RotatingFileHandler
  9from pathlib import Path
 10from typing import TextIO
 11from urllib.parse import urlsplit, urlunsplit
 12
 13from .errors import ConfigFailure, ErrorCode
 14from .request_ids import current_request_id
 15
 16LOG_MAX_BYTES = 5 * 1024 * 1024
 17LOG_BACKUP_COUNT = 3
 18
 19FileHandlerFactory = Callable[..., logging.Handler]
 20
 21_FIELD_NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
 22_PREFERRED_FIELD_ORDER = ("provider", "stage", "url", "event")
 23_EMERGENCY_MESSAGE = "agent-search-gateway debug logging sink failure\n"
 24
 25
 26class SecretValue:
 27    """A secret whose implicit string representations are always redacted."""
 28
 29    __slots__ = ("_value",)
 30
 31    def __init__(self, value: str) -> None:
 32        self._value = value
 33
 34    def reveal(self) -> str:
 35        return self._value
 36
 37    def __repr__(self) -> str:
 38        return "<redacted>"
 39
 40    def __str__(self) -> str:
 41        return "<redacted>"
 42
 43
 44class SecretRedactor:
 45    """Redact configured secrets from fully rendered logging output."""
 46
 47    def __init__(self, secrets: Iterable[SecretValue] = ()) -> None:
 48        self._secrets: set[str] = set()
 49        self._ordered_secrets: tuple[str, ...] = ()
 50        self.add_secrets(secrets)
 51
 52    def add_secrets(self, secrets: Iterable[SecretValue]) -> None:
 53        for secret in secrets:
 54            value = secret.reveal()
 55            if not value:
 56                continue
 57            self._secrets.add(value)
 58            escaped = json.dumps(value, ensure_ascii=False)[1:-1]
 59            if escaped:
 60                self._secrets.add(escaped)
 61        self._ordered_secrets = tuple(sorted(self._secrets, key=len, reverse=True))
 62
 63    def redact(self, rendered: str) -> str:
 64        for secret in self._ordered_secrets:
 65            rendered = rendered.replace(secret, "<redacted>")
 66        return rendered
 67
 68
 69class SecretRedactingFilter(logging.Filter):
 70    """Compatibility filter for existing provider tests and ad-hoc handlers."""
 71
 72    def __init__(self, secrets: Iterable[SecretValue]) -> None:
 73        super().__init__()
 74        self._redactor = SecretRedactor(secrets)
 75
 76    def filter(self, record: logging.LogRecord) -> bool:
 77        record.msg = self._redactor.redact(record.getMessage())
 78        record.args = ()
 79        return True
 80
 81
 82def normalize_log_reason(value: str, max_chars: int = 160) -> str:
 83    if max_chars <= 0:
 84        raise ValueError("max_chars must be positive")
 85    normalized = " ".join(value.split())
 86    if len(normalized) <= max_chars:
 87        return normalized
 88    if max_chars == 1:
 89        return "…"
 90    return f"{normalized[: max_chars - 1]}…"
 91
 92
 93def elapsed_ms(monotonic: Callable[[], float], started: float) -> int:
 94    """Convert a monotonic interval to a non-negative whole-millisecond duration."""
 95
 96    return max(0, int((monotonic() - started) * 1000))
 97
 98
 99def target_url_for_log(value: str) -> str:
100    """Strip URI userinfo while retaining target path/query/fragment diagnostics."""
101
102    return _url_for_log(value, keep_query=True, keep_fragment=True)
103
104
105def http_endpoint_for_log(value: str) -> str:
106    """Render an HTTP endpoint without userinfo or request-specific query data."""
107
108    return _url_for_log(value, keep_query=False, keep_fragment=False)
109
110
111def _url_for_log(value: str, *, keep_query: bool, keep_fragment: bool) -> str:
112    try:
113        parsed = urlsplit(value)
114    except (ValueError, UnicodeError):
115        return "<invalid-url>"
116    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
117        return "<invalid-url>"
118    _, separator, hostport = parsed.netloc.rpartition("@")
119    netloc = hostport if separator else parsed.netloc
120    return urlunsplit(
121        (
122            parsed.scheme,
123            netloc,
124            parsed.path,
125            parsed.query if keep_query else "",
126            parsed.fragment if keep_fragment else "",
127        )
128    )
129
130
131def _render_string(value: str) -> str:
132    if (
133        value
134        and value.isprintable()
135        and not any(character.isspace() or character in {'"', "\\"} for character in value)
136    ):
137        return value
138    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
139
140
141def _render_scalar(value: object) -> str:
142    if value is None:
143        return "null"
144    if isinstance(value, bool):
145        return "true" if value else "false"
146    if isinstance(value, str):
147        return _render_string(value)
148    if isinstance(value, (int, float)):
149        return repr(value)
150    return _render_string(f"<{type(value).__name__}>")
151
152
153def _validated_field_name(name: str) -> str:
154    if _FIELD_NAME.fullmatch(name) is None:
155        raise ValueError(f"invalid log field name: {name}")
156    return name
157
158
159class KeyValueFormatter(logging.Formatter):
160    """Render structured events as deterministic, single physical lines."""
161
162    def __init__(self, redactor: SecretRedactor) -> None:
163        super().__init__()
164        self._redactor = redactor
165
166    def format(self, record: logging.LogRecord) -> str:
167        event = getattr(record, "gateway_event", None)
168        raw_fields = getattr(record, "gateway_fields", None)
169        if isinstance(event, str) and isinstance(raw_fields, dict):
170            fields = dict(raw_fields)
171        else:
172            event = "log"
173            fields = {"message": record.getMessage()}
174
175        values: dict[str, object] = {
176            _validated_field_name(str(key)): value for key, value in fields.items()
177        }
178        values["event"] = event
179        request_id = current_request_id() or "-"
180        parts = [record.levelname, f"request={request_id}"]
181        emitted: set[str] = set()
182        for name in _PREFERRED_FIELD_ORDER:
183            if name not in values:
184                continue
185            parts.append(f"{name}={_render_scalar(values[name])}")
186            emitted.add(name)
187        for name in sorted(values.keys() - emitted):
188            parts.append(f"{name}={_render_scalar(values[name])}")
189
190        if record.exc_info:
191            traceback_text = self.formatException(record.exc_info)
192            parts.append(f"traceback={_render_string(traceback_text)}")
193        return self._redactor.redact(" ".join(parts))
194
195
196def log_event(
197    logger: logging.Logger,
198    level: int,
199    event: str,
200    *,
201    exc_info: bool | BaseException = False,
202    **fields: object,
203) -> None:
204    if not logger.isEnabledFor(level):
205        return
206    _validated_field_name(event)
207    for name in fields:
208        _validated_field_name(name)
209    logger.log(
210        level,
211        "",
212        exc_info=exc_info,
213        extra={"gateway_event": event, "gateway_fields": dict(fields)},
214    )
215
216
217def _write_emergency(stderr: TextIO) -> None:
218    try:
219        stderr.write(_EMERGENCY_MESSAGE)
220        stderr.flush()
221    except Exception:
222        pass
223
224
225class SafeRotatingFileHandler(RotatingFileHandler):
226    """Rotating handler whose sink errors never escape into business code."""
227
228    def __init__(
229        self,
230        filename: Path,
231        *,
232        stderr: TextIO,
233        max_bytes: int,
234        backup_count: int,
235    ) -> None:
236        self._emergency_stderr = stderr
237        super().__init__(
238            filename,
239            mode="a",
240            maxBytes=max_bytes,
241            backupCount=backup_count,
242            encoding="utf-8",
243        )
244
245    def handleError(self, record: logging.LogRecord) -> None:  # noqa: N802
246        _write_emergency(self._emergency_stderr)
247
248
249class _SafeDelegatingHandler(logging.Handler):
250    """Contain exceptions from injected handlers used by failure-path tests."""
251
252    def __init__(self, delegate: logging.Handler, stderr: TextIO) -> None:
253        super().__init__(delegate.level)
254        self._delegate = delegate
255        self._stderr = stderr
256
257    def emit(self, record: logging.LogRecord) -> None:
258        try:
259            self._delegate.handle(record)
260        except Exception:
261            _write_emergency(self._stderr)
262
263    def close(self) -> None:
264        with suppress(Exception):
265            self._delegate.close()
266        super().close()
267
268
269class DebugLoggingSession:
270    """Own one debug logging configuration and restore prior logger state."""
271
272    def __init__(
273        self,
274        logger: logging.Logger,
275        handlers: tuple[logging.Handler, ...],
276        redactor: SecretRedactor,
277        *,
278        previous_level: int,
279        previous_propagate: bool,
280    ) -> None:
281        self._logger = logger
282        self._handlers = handlers
283        self._redactor = redactor
284        self._previous_level = previous_level
285        self._previous_propagate = previous_propagate
286        self._closed = False
287
288    def add_secrets(self, *secrets: SecretValue | Iterable[SecretValue]) -> None:
289        collected: list[SecretValue] = []
290        for item in secrets:
291            if isinstance(item, SecretValue):
292                collected.append(item)
293            else:
294                collected.extend(item)
295        self._redactor.add_secrets(collected)
296
297    def close(self) -> None:
298        if self._closed:
299            return
300        self._closed = True
301        for handler in self._handlers:
302            self._logger.removeHandler(handler)
303            with suppress(Exception):
304                handler.flush()
305            with suppress(Exception):
306                handler.close()
307        self._logger.setLevel(self._previous_level)
308        self._logger.propagate = self._previous_propagate
309
310
311def _default_file_handler(
312    log_file: Path,
313    *,
314    stderr: TextIO,
315    max_bytes: int,
316    backup_count: int,
317) -> logging.Handler:
318    return SafeRotatingFileHandler(
319        log_file,
320        stderr=stderr,
321        max_bytes=max_bytes,
322        backup_count=backup_count,
323    )
324
325
326def configure_debug_logging(
327    log_file: Path,
328    *,
329    stderr: TextIO,
330    max_bytes: int = LOG_MAX_BYTES,
331    backup_count: int = LOG_BACKUP_COUNT,
332    file_handler_factory: FileHandlerFactory | None = None,
333) -> DebugLoggingSession:
334    """Install project-only DEBUG handlers with rotation and final redaction."""
335
336    logger = logging.getLogger("agent_search_gateway")
337    previous_level = logger.level
338    previous_propagate = logger.propagate
339    handlers: list[logging.Handler] = []
340    try:
341        if max_bytes <= 0:
342            raise ValueError("max_bytes must be positive")
343        if backup_count < 0:
344            raise ValueError("backup_count must not be negative")
345        log_file.parent.mkdir(parents=True, exist_ok=True)
346        redactor = SecretRedactor()
347        formatter = KeyValueFormatter(redactor)
348
349        stderr_handler = logging.StreamHandler(stderr)
350        stderr_handler.setFormatter(formatter)
351        handlers.append(stderr_handler)
352
353        factory = file_handler_factory or _default_file_handler
354        file_handler = factory(
355            log_file,
356            stderr=stderr,
357            max_bytes=max_bytes,
358            backup_count=backup_count,
359        )
360        if not isinstance(file_handler, logging.Handler):
361            raise TypeError("file handler factory must return logging.Handler")
362        file_handler.setFormatter(formatter)
363        if file_handler_factory is not None:
364            file_handler = _SafeDelegatingHandler(file_handler, stderr)
365        handlers.append(file_handler)
366
367        for handler in handlers:
368            handler.__dict__["_agent_search_gateway_debug_owned"] = True
369            logger.addHandler(handler)
370        logger.setLevel(logging.DEBUG)
371        logger.propagate = False
372        return DebugLoggingSession(
373            logger,
374            tuple(handlers),
375            redactor,
376            previous_level=previous_level,
377            previous_propagate=previous_propagate,
378        )
379    except Exception as exc:
380        for handler in handlers:
381            logger.removeHandler(handler)
382            with suppress(Exception):
383                handler.close()
384        logger.setLevel(previous_level)
385        logger.propagate = previous_propagate
386        raise ConfigFailure(ErrorCode.CONFIG_ERROR, "Failed to configure debug logging") from exc
LOG_MAX_BYTES = 5242880
LOG_BACKUP_COUNT = 3
FileHandlerFactory = collections.abc.Callable[..., logging.Handler]
class SecretValue:
27class SecretValue:
28    """A secret whose implicit string representations are always redacted."""
29
30    __slots__ = ("_value",)
31
32    def __init__(self, value: str) -> None:
33        self._value = value
34
35    def reveal(self) -> str:
36        return self._value
37
38    def __repr__(self) -> str:
39        return "<redacted>"
40
41    def __str__(self) -> str:
42        return "<redacted>"

A secret whose implicit string representations are always redacted.

SecretValue(value: str)
32    def __init__(self, value: str) -> None:
33        self._value = value
def reveal(self) -> str:
35    def reveal(self) -> str:
36        return self._value
class SecretRedactor:
45class SecretRedactor:
46    """Redact configured secrets from fully rendered logging output."""
47
48    def __init__(self, secrets: Iterable[SecretValue] = ()) -> None:
49        self._secrets: set[str] = set()
50        self._ordered_secrets: tuple[str, ...] = ()
51        self.add_secrets(secrets)
52
53    def add_secrets(self, secrets: Iterable[SecretValue]) -> None:
54        for secret in secrets:
55            value = secret.reveal()
56            if not value:
57                continue
58            self._secrets.add(value)
59            escaped = json.dumps(value, ensure_ascii=False)[1:-1]
60            if escaped:
61                self._secrets.add(escaped)
62        self._ordered_secrets = tuple(sorted(self._secrets, key=len, reverse=True))
63
64    def redact(self, rendered: str) -> str:
65        for secret in self._ordered_secrets:
66            rendered = rendered.replace(secret, "<redacted>")
67        return rendered

Redact configured secrets from fully rendered logging output.

SecretRedactor( secrets: Iterable[SecretValue] = ())
48    def __init__(self, secrets: Iterable[SecretValue] = ()) -> None:
49        self._secrets: set[str] = set()
50        self._ordered_secrets: tuple[str, ...] = ()
51        self.add_secrets(secrets)
def add_secrets( self, secrets: Iterable[SecretValue]) -> None:
53    def add_secrets(self, secrets: Iterable[SecretValue]) -> None:
54        for secret in secrets:
55            value = secret.reveal()
56            if not value:
57                continue
58            self._secrets.add(value)
59            escaped = json.dumps(value, ensure_ascii=False)[1:-1]
60            if escaped:
61                self._secrets.add(escaped)
62        self._ordered_secrets = tuple(sorted(self._secrets, key=len, reverse=True))
def redact(self, rendered: str) -> str:
64    def redact(self, rendered: str) -> str:
65        for secret in self._ordered_secrets:
66            rendered = rendered.replace(secret, "<redacted>")
67        return rendered
class SecretRedactingFilter(logging.Filter):
70class SecretRedactingFilter(logging.Filter):
71    """Compatibility filter for existing provider tests and ad-hoc handlers."""
72
73    def __init__(self, secrets: Iterable[SecretValue]) -> None:
74        super().__init__()
75        self._redactor = SecretRedactor(secrets)
76
77    def filter(self, record: logging.LogRecord) -> bool:
78        record.msg = self._redactor.redact(record.getMessage())
79        record.args = ()
80        return True

Compatibility filter for existing provider tests and ad-hoc handlers.

SecretRedactingFilter(secrets: Iterable[SecretValue])
73    def __init__(self, secrets: Iterable[SecretValue]) -> None:
74        super().__init__()
75        self._redactor = SecretRedactor(secrets)

Initialize a filter.

Initialize with the name of the logger which, together with its children, will have its events allowed through the filter. If no name is specified, allow every event.

def filter(self, record: logging.LogRecord) -> bool:
77    def filter(self, record: logging.LogRecord) -> bool:
78        record.msg = self._redactor.redact(record.getMessage())
79        record.args = ()
80        return True

Determine if the specified record is to be logged.

Returns True if the record should be logged, or False otherwise. If deemed appropriate, the record may be modified in-place.

def normalize_log_reason(value: str, max_chars: int = 160) -> str:
83def normalize_log_reason(value: str, max_chars: int = 160) -> str:
84    if max_chars <= 0:
85        raise ValueError("max_chars must be positive")
86    normalized = " ".join(value.split())
87    if len(normalized) <= max_chars:
88        return normalized
89    if max_chars == 1:
90        return "…"
91    return f"{normalized[: max_chars - 1]}…"
def elapsed_ms(monotonic: Callable[[], float], started: float) -> int:
94def elapsed_ms(monotonic: Callable[[], float], started: float) -> int:
95    """Convert a monotonic interval to a non-negative whole-millisecond duration."""
96
97    return max(0, int((monotonic() - started) * 1000))

Convert a monotonic interval to a non-negative whole-millisecond duration.

def target_url_for_log(value: str) -> str:
100def target_url_for_log(value: str) -> str:
101    """Strip URI userinfo while retaining target path/query/fragment diagnostics."""
102
103    return _url_for_log(value, keep_query=True, keep_fragment=True)

Strip URI userinfo while retaining target path/query/fragment diagnostics.

def http_endpoint_for_log(value: str) -> str:
106def http_endpoint_for_log(value: str) -> str:
107    """Render an HTTP endpoint without userinfo or request-specific query data."""
108
109    return _url_for_log(value, keep_query=False, keep_fragment=False)

Render an HTTP endpoint without userinfo or request-specific query data.

class KeyValueFormatter(logging.Formatter):
160class KeyValueFormatter(logging.Formatter):
161    """Render structured events as deterministic, single physical lines."""
162
163    def __init__(self, redactor: SecretRedactor) -> None:
164        super().__init__()
165        self._redactor = redactor
166
167    def format(self, record: logging.LogRecord) -> str:
168        event = getattr(record, "gateway_event", None)
169        raw_fields = getattr(record, "gateway_fields", None)
170        if isinstance(event, str) and isinstance(raw_fields, dict):
171            fields = dict(raw_fields)
172        else:
173            event = "log"
174            fields = {"message": record.getMessage()}
175
176        values: dict[str, object] = {
177            _validated_field_name(str(key)): value for key, value in fields.items()
178        }
179        values["event"] = event
180        request_id = current_request_id() or "-"
181        parts = [record.levelname, f"request={request_id}"]
182        emitted: set[str] = set()
183        for name in _PREFERRED_FIELD_ORDER:
184            if name not in values:
185                continue
186            parts.append(f"{name}={_render_scalar(values[name])}")
187            emitted.add(name)
188        for name in sorted(values.keys() - emitted):
189            parts.append(f"{name}={_render_scalar(values[name])}")
190
191        if record.exc_info:
192            traceback_text = self.formatException(record.exc_info)
193            parts.append(f"traceback={_render_string(traceback_text)}")
194        return self._redactor.redact(" ".join(parts))

Render structured events as deterministic, single physical lines.

KeyValueFormatter(redactor: SecretRedactor)
163    def __init__(self, redactor: SecretRedactor) -> None:
164        super().__init__()
165        self._redactor = redactor

Initialize the formatter with specified format strings.

Initialize the formatter either with the specified format string, or a default as described above. Allow for specialized date formatting with the optional datefmt argument. If datefmt is omitted, you get an ISO8601-like (or RFC 3339-like) format.

Use a style parameter of '%', '{' or '$' to specify that you want to use one of %-formatting, :meth:str.format ({}) formatting or :class:string.Template formatting in your format string.

.. versionchanged:: 3.2 Added the style parameter.

def format(self, record: logging.LogRecord) -> str:
167    def format(self, record: logging.LogRecord) -> str:
168        event = getattr(record, "gateway_event", None)
169        raw_fields = getattr(record, "gateway_fields", None)
170        if isinstance(event, str) and isinstance(raw_fields, dict):
171            fields = dict(raw_fields)
172        else:
173            event = "log"
174            fields = {"message": record.getMessage()}
175
176        values: dict[str, object] = {
177            _validated_field_name(str(key)): value for key, value in fields.items()
178        }
179        values["event"] = event
180        request_id = current_request_id() or "-"
181        parts = [record.levelname, f"request={request_id}"]
182        emitted: set[str] = set()
183        for name in _PREFERRED_FIELD_ORDER:
184            if name not in values:
185                continue
186            parts.append(f"{name}={_render_scalar(values[name])}")
187            emitted.add(name)
188        for name in sorted(values.keys() - emitted):
189            parts.append(f"{name}={_render_scalar(values[name])}")
190
191        if record.exc_info:
192            traceback_text = self.formatException(record.exc_info)
193            parts.append(f"traceback={_render_string(traceback_text)}")
194        return self._redactor.redact(" ".join(parts))

Format the specified record as text.

The record's attribute dictionary is used as the operand to a string formatting operation which yields the returned string. Before formatting the dictionary, a couple of preparatory steps are carried out. The message attribute of the record is computed using LogRecord.getMessage(). If the formatting string uses the time (as determined by a call to usesTime(), formatTime() is called to format the event time. If there is exception information, it is formatted using formatException() and appended to the message.

def log_event( logger: logging.Logger, level: int, event: str, *, exc_info: bool | BaseException = False, **fields: object) -> None:
197def log_event(
198    logger: logging.Logger,
199    level: int,
200    event: str,
201    *,
202    exc_info: bool | BaseException = False,
203    **fields: object,
204) -> None:
205    if not logger.isEnabledFor(level):
206        return
207    _validated_field_name(event)
208    for name in fields:
209        _validated_field_name(name)
210    logger.log(
211        level,
212        "",
213        exc_info=exc_info,
214        extra={"gateway_event": event, "gateway_fields": dict(fields)},
215    )
class SafeRotatingFileHandler(logging.handlers.RotatingFileHandler):
226class SafeRotatingFileHandler(RotatingFileHandler):
227    """Rotating handler whose sink errors never escape into business code."""
228
229    def __init__(
230        self,
231        filename: Path,
232        *,
233        stderr: TextIO,
234        max_bytes: int,
235        backup_count: int,
236    ) -> None:
237        self._emergency_stderr = stderr
238        super().__init__(
239            filename,
240            mode="a",
241            maxBytes=max_bytes,
242            backupCount=backup_count,
243            encoding="utf-8",
244        )
245
246    def handleError(self, record: logging.LogRecord) -> None:  # noqa: N802
247        _write_emergency(self._emergency_stderr)

Rotating handler whose sink errors never escape into business code.

SafeRotatingFileHandler( filename: pathlib.Path, *, stderr: <class 'TextIO'>, max_bytes: int, backup_count: int)
229    def __init__(
230        self,
231        filename: Path,
232        *,
233        stderr: TextIO,
234        max_bytes: int,
235        backup_count: int,
236    ) -> None:
237        self._emergency_stderr = stderr
238        super().__init__(
239            filename,
240            mode="a",
241            maxBytes=max_bytes,
242            backupCount=backup_count,
243            encoding="utf-8",
244        )

Open the specified file and use it as the stream for logging.

By default, the file grows indefinitely. You can specify particular values of maxBytes and backupCount to allow the file to rollover at a predetermined size.

Rollover occurs whenever the current log file is nearly maxBytes in length. If backupCount is >= 1, the system will successively create new files with the same pathname as the base file, but with extensions ".1", ".2" etc. appended to it. For example, with a backupCount of 5 and a base file name of "app.log", you would get "app.log", "app.log.1", "app.log.2", ... through to "app.log.5". The file being written to is always "app.log" - when it gets filled up, it is closed and renamed to "app.log.1", and if files "app.log.1", "app.log.2" etc. exist, then they are renamed to "app.log.2", "app.log.3" etc. respectively.

If maxBytes is zero, rollover never occurs.

def handleError(self, record: logging.LogRecord) -> None:
246    def handleError(self, record: logging.LogRecord) -> None:  # noqa: N802
247        _write_emergency(self._emergency_stderr)

Handle errors which occur during an emit() call.

This method should be called from handlers when an exception is encountered during an emit() call. If raiseExceptions is false, exceptions get silently ignored. This is what is mostly wanted for a logging system - most users will not care about errors in the logging system, they are more interested in application errors. You could, however, replace this with a custom handler if you wish. The record which was being processed is passed in to this method.

class DebugLoggingSession:
270class DebugLoggingSession:
271    """Own one debug logging configuration and restore prior logger state."""
272
273    def __init__(
274        self,
275        logger: logging.Logger,
276        handlers: tuple[logging.Handler, ...],
277        redactor: SecretRedactor,
278        *,
279        previous_level: int,
280        previous_propagate: bool,
281    ) -> None:
282        self._logger = logger
283        self._handlers = handlers
284        self._redactor = redactor
285        self._previous_level = previous_level
286        self._previous_propagate = previous_propagate
287        self._closed = False
288
289    def add_secrets(self, *secrets: SecretValue | Iterable[SecretValue]) -> None:
290        collected: list[SecretValue] = []
291        for item in secrets:
292            if isinstance(item, SecretValue):
293                collected.append(item)
294            else:
295                collected.extend(item)
296        self._redactor.add_secrets(collected)
297
298    def close(self) -> None:
299        if self._closed:
300            return
301        self._closed = True
302        for handler in self._handlers:
303            self._logger.removeHandler(handler)
304            with suppress(Exception):
305                handler.flush()
306            with suppress(Exception):
307                handler.close()
308        self._logger.setLevel(self._previous_level)
309        self._logger.propagate = self._previous_propagate

Own one debug logging configuration and restore prior logger state.

DebugLoggingSession( logger: logging.Logger, handlers: tuple[logging.Handler, ...], redactor: SecretRedactor, *, previous_level: int, previous_propagate: bool)
273    def __init__(
274        self,
275        logger: logging.Logger,
276        handlers: tuple[logging.Handler, ...],
277        redactor: SecretRedactor,
278        *,
279        previous_level: int,
280        previous_propagate: bool,
281    ) -> None:
282        self._logger = logger
283        self._handlers = handlers
284        self._redactor = redactor
285        self._previous_level = previous_level
286        self._previous_propagate = previous_propagate
287        self._closed = False
def add_secrets( self, *secrets: SecretValue | Iterable[SecretValue]) -> None:
289    def add_secrets(self, *secrets: SecretValue | Iterable[SecretValue]) -> None:
290        collected: list[SecretValue] = []
291        for item in secrets:
292            if isinstance(item, SecretValue):
293                collected.append(item)
294            else:
295                collected.extend(item)
296        self._redactor.add_secrets(collected)
def close(self) -> None:
298    def close(self) -> None:
299        if self._closed:
300            return
301        self._closed = True
302        for handler in self._handlers:
303            self._logger.removeHandler(handler)
304            with suppress(Exception):
305                handler.flush()
306            with suppress(Exception):
307                handler.close()
308        self._logger.setLevel(self._previous_level)
309        self._logger.propagate = self._previous_propagate
def configure_debug_logging( log_file: pathlib.Path, *, stderr: <class 'TextIO'>, max_bytes: int = 5242880, backup_count: int = 3, file_handler_factory: Callable[..., logging.Handler] | None = None) -> DebugLoggingSession:
327def configure_debug_logging(
328    log_file: Path,
329    *,
330    stderr: TextIO,
331    max_bytes: int = LOG_MAX_BYTES,
332    backup_count: int = LOG_BACKUP_COUNT,
333    file_handler_factory: FileHandlerFactory | None = None,
334) -> DebugLoggingSession:
335    """Install project-only DEBUG handlers with rotation and final redaction."""
336
337    logger = logging.getLogger("agent_search_gateway")
338    previous_level = logger.level
339    previous_propagate = logger.propagate
340    handlers: list[logging.Handler] = []
341    try:
342        if max_bytes <= 0:
343            raise ValueError("max_bytes must be positive")
344        if backup_count < 0:
345            raise ValueError("backup_count must not be negative")
346        log_file.parent.mkdir(parents=True, exist_ok=True)
347        redactor = SecretRedactor()
348        formatter = KeyValueFormatter(redactor)
349
350        stderr_handler = logging.StreamHandler(stderr)
351        stderr_handler.setFormatter(formatter)
352        handlers.append(stderr_handler)
353
354        factory = file_handler_factory or _default_file_handler
355        file_handler = factory(
356            log_file,
357            stderr=stderr,
358            max_bytes=max_bytes,
359            backup_count=backup_count,
360        )
361        if not isinstance(file_handler, logging.Handler):
362            raise TypeError("file handler factory must return logging.Handler")
363        file_handler.setFormatter(formatter)
364        if file_handler_factory is not None:
365            file_handler = _SafeDelegatingHandler(file_handler, stderr)
366        handlers.append(file_handler)
367
368        for handler in handlers:
369            handler.__dict__["_agent_search_gateway_debug_owned"] = True
370            logger.addHandler(handler)
371        logger.setLevel(logging.DEBUG)
372        logger.propagate = False
373        return DebugLoggingSession(
374            logger,
375            tuple(handlers),
376            redactor,
377            previous_level=previous_level,
378            previous_propagate=previous_propagate,
379        )
380    except Exception as exc:
381        for handler in handlers:
382            logger.removeHandler(handler)
383            with suppress(Exception):
384                handler.close()
385        logger.setLevel(previous_level)
386        logger.propagate = previous_propagate
387        raise ConfigFailure(ErrorCode.CONFIG_ERROR, "Failed to configure debug logging") from exc

Install project-only DEBUG handlers with rotation and final redaction.