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
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.
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.
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))
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.
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.
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.
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]}…"
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.
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.
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.
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.
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.
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.
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 )
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.
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.
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.
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.
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
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
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.