Coverage for src/taipanstack/utils/logging.py: 100%

180 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2Structured logging with context. 

3 

4Provides a configured logger with support for structured output, 

5context propagation, and proper formatting. 

6""" 

7 

8import logging 

9import logging as _std_logging 

10import re 

11import sys 

12from collections.abc import Iterator, MutableMapping 

13from contextlib import AbstractContextManager, contextmanager 

14from datetime import UTC, datetime 

15from functools import lru_cache 

16from typing import Literal, TypeVar, cast 

17 

18from taipanstack.utils.context import get_correlation_id 

19 

20try: 

21 import structlog 

22 

23 HAS_STRUCTLOG = True 

24except ImportError: 

25 HAS_STRUCTLOG = False 

26 

27 

28# Default log format 

29DEFAULT_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" 

30JSON_FORMAT = ( 

31 '{"timestamp": "%(asctime)s", "level": "%(levelname)s", ' 

32 '"logger": "%(name)s", "message": "%(message)s"}' 

33) 

34 

35SENSITIVE_KEY_PATTERNS: tuple[str, ...] = ( 

36 "password", 

37 "secret", 

38 "token", 

39 "authorization", 

40 "api_key", 

41) 

42 

43# Pre-compile the regex for O(1) checks per key instead of O(N*M) 

44_SENSITIVE_KEY_REGEX = ( 

45 re.compile("|".join(map(re.escape, SENSITIVE_KEY_PATTERNS)), re.IGNORECASE) 

46 if SENSITIVE_KEY_PATTERNS 

47 else None 

48) 

49 

50REDACTED_VALUE = "***REDACTED***" 

51 

52 

53@lru_cache(maxsize=1024) # type: ignore[misc] 

54def _is_sensitive(key: object, regex: re.Pattern[str] | None) -> bool: 

55 """Check if a key is sensitive using cached regex matching. 

56 

57 Args: 

58 key: The key to check. 

59 regex: The regex pattern to use for matching. 

60 

61 Returns: 

62 True if the key is a string and sensitive, False otherwise. 

63 

64 """ 

65 if regex is None: 

66 return False 

67 if not isinstance(key, str): 

68 return False 

69 return bool(regex.search(key)) 

70 

71 

72KT = TypeVar("KT") 

73VT = TypeVar("VT") 

74 

75 

76def _redact_mapping( 

77 obj: MutableMapping[KT, VT], 

78 seen: set[int], 

79) -> dict[KT, object]: 

80 """Redact a mapping object.""" 

81 seen.add(id(obj)) 

82 return { 

83 k: ( 

84 REDACTED_VALUE 

85 if _is_sensitive(k, _SENSITIVE_KEY_REGEX) 

86 else _redact(v, seen) 

87 ) 

88 for k, v in obj.items() 

89 } 

90 

91 

92def _redact_list(obj: list[VT], seen: set[int]) -> list[object]: 

93 """Redact a list object.""" 

94 seen.add(id(obj)) 

95 return [_redact(item, seen) for item in obj] 

96 

97 

98def _redact_tuple(obj: tuple[VT, ...], seen: set[int]) -> tuple[object, ...]: 

99 """Redact a tuple object.""" 

100 seen.add(id(obj)) 

101 return tuple(_redact(item, seen) for item in obj) 

102 

103 

104def _is_unhashable(item: object) -> bool: 

105 try: 

106 hash(item) 

107 return False 

108 except TypeError: 

109 return True 

110 

111 

112def _redact_set(obj: set[VT], seen: set[int]) -> set[object] | list[object]: 

113 """Redact a set object.""" 

114 seen.add(id(obj)) 

115 redacted_items = [_redact(item, seen) for item in obj] 

116 if any(_is_unhashable(item) for item in redacted_items): 

117 return redacted_items 

118 return set(redacted_items) 

119 

120 

121def _redact_iterable(obj: object, seen: set[int]) -> object: 

122 """Dispatch redaction based on iterable type.""" 

123 if isinstance(obj, list): 

124 return _redact_list(obj, seen) 

125 if isinstance(obj, tuple): 

126 return _redact_tuple(obj, seen) 

127 if isinstance(obj, set): 

128 return _redact_set(obj, seen) 

129 return obj 

130 

131 

132def _redact_collection(obj: object, seen: set[int]) -> object: 

133 """Dispatch redaction based on collection type.""" 

134 if isinstance(obj, (dict, MutableMapping)): 

135 return _redact_mapping(cast(MutableMapping[object, object], obj), seen) 

136 

137 return _redact_iterable(obj, seen) 

138 

139 

140def _redact(obj: object, seen: set[int] | None = None) -> object: 

141 """Redact sensitive data recursively with circular reference protection. 

142 

143 Args: 

144 obj: The object to redact. 

145 seen: Set of IDs of already processed containers. 

146 

147 Returns: 

148 A new object with sensitive data redacted. 

149 

150 """ 

151 if _SENSITIVE_KEY_REGEX is None: 

152 return obj 

153 

154 if seen is None: 

155 seen = set() 

156 

157 # Detect circular references 

158 if id(obj) in seen: 

159 return REDACTED_VALUE 

160 

161 return _redact_collection(obj, seen) 

162 

163 

164def _redact_dict(d: MutableMapping[str, object]) -> None: 

165 """Redact sensitive keys in a dictionary in-place (top-level only). 

166 

167 Note: Nested structures are replaced with redacted copies to prevent 

168 shared state mutation. 

169 

170 Args: 

171 d: The dictionary to redact. 

172 

173 """ 

174 if _SENSITIVE_KEY_REGEX is None: 

175 return 

176 

177 for key, value in list(d.items()): 

178 if _is_sensitive(key, _SENSITIVE_KEY_REGEX): 

179 d[key] = REDACTED_VALUE 

180 else: 

181 d[key] = _redact(value) 

182 

183 

184def mask_sensitive_data_processor( 

185 _logger: object, 

186 _method: str, 

187 event_dict: MutableMapping[str, object], 

188) -> MutableMapping[str, object]: 

189 """Mask sensitive data in structlog event dictionaries. 

190 

191 Intercept the *event_dict* produced by structlog and replace the 

192 value of any key whose name contains a sensitive substring with 

193 ``"***REDACTED***"``. Matching is case-insensitive. 

194 

195 Args: 

196 _logger: The wrapped logger object (unused, required by structlog). 

197 _method: The name of the log method called (unused, required by structlog). 

198 event_dict: The structured event dictionary. 

199 

200 Returns: 

201 The event dictionary with sensitive values masked. 

202 

203 """ 

204 _redact_dict(event_dict) 

205 return event_dict 

206 

207 

208def correlation_id_processor( 

209 _logger: object, 

210 _method: str, 

211 event_dict: MutableMapping[str, object], 

212) -> MutableMapping[str, object]: 

213 """Structlog processor to inject correlation ID into events. 

214 

215 Args: 

216 _logger: The wrapped logger object. 

217 _method: The name of the log method called. 

218 event_dict: The structured event dictionary. 

219 

220 Returns: 

221 The event dictionary with correlation_id injected if sets. 

222 

223 """ 

224 correlation_id = get_correlation_id() 

225 if correlation_id is not None: 

226 event_dict["correlation_id"] = correlation_id 

227 return event_dict 

228 

229 

230class StackLogger: 

231 """Enhanced logger with context support. 

232 

233 Provides a wrapper around standard logging with additional features 

234 like context propagation and structured output support. 

235 

236 Attributes: 

237 name: Logger name. 

238 level: Current log level. 

239 

240 """ 

241 

242 def __init__( 

243 self, 

244 name: str = "taipanstack", 

245 level: str = "INFO", 

246 *, 

247 use_structured: bool = False, 

248 ) -> None: 

249 """Initialize the logger. 

250 

251 Args: 

252 name: Logger name. 

253 level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). 

254 use_structured: Use structured logging if structlog is available. 

255 

256 """ 

257 self.name = name 

258 self.level = level 

259 self._context: dict[str, object] = {} 

260 

261 if use_structured and HAS_STRUCTLOG: 

262 self._logger = structlog.get_logger(name) # type: ignore[misc] 

263 self._structured = True 

264 else: 

265 self._logger = logging.getLogger(name) 

266 self._logger.setLevel(getattr(logging, level.upper())) # type: ignore[misc] 

267 self._structured = False 

268 

269 def bind(self, **context: object) -> "StackLogger": 

270 """Add context to logger. 

271 

272 Args: 

273 **context: Key-value pairs to add to context. 

274 

275 Returns: 

276 Self for chaining. 

277 

278 """ 

279 self._context.update(context) 

280 if self._structured and HAS_STRUCTLOG: 

281 self._logger = self._logger.bind(**context) # type: ignore[misc] 

282 return self 

283 

284 def unbind(self, *keys: str) -> "StackLogger": 

285 """Remove context keys. 

286 

287 Args: 

288 *keys: Keys to remove from context. 

289 

290 Returns: 

291 Self for chaining. 

292 

293 """ 

294 for key in keys: 

295 self._context.pop(key, None) 

296 if self._structured and HAS_STRUCTLOG: 

297 self._logger = self._logger.unbind(*keys) # type: ignore[misc] 

298 return self 

299 

300 def _format_message(self, message: str, **kwargs: object) -> str: 

301 """Format message with context. 

302 

303 Args: 

304 message: The log message. 

305 **kwargs: Additional context for this message. 

306 

307 Returns: 

308 Formatted message string. 

309 

310 """ 

311 if not kwargs and not self._context: 

312 return message 

313 

314 context = {**self._context, **kwargs} 

315 _redact_dict(context) 

316 

317 context_str = " ".join(f"{k}={v}" for k, v in context.items()) 

318 return f"{message} | {context_str}" 

319 

320 def debug(self, message: str, **kwargs: object) -> None: 

321 """Log a debug message. 

322 

323 Args: 

324 message: The message to log. 

325 **kwargs: Additional context. 

326 

327 """ 

328 if self._structured: 

329 self._logger.debug(message, **kwargs) # type: ignore[misc] 

330 else: 

331 self._logger.debug(self._format_message(message, **kwargs)) # type: ignore[misc] 

332 

333 def info(self, message: str, **kwargs: object) -> None: 

334 """Log an info message. 

335 

336 Args: 

337 message: The message to log. 

338 **kwargs: Additional context. 

339 

340 """ 

341 if self._structured: 

342 self._logger.info(message, **kwargs) # type: ignore[misc] 

343 else: 

344 self._logger.info(self._format_message(message, **kwargs)) # type: ignore[misc] 

345 

346 def warning(self, message: str, **kwargs: object) -> None: 

347 """Log a warning message. 

348 

349 Args: 

350 message: The message to log. 

351 **kwargs: Additional context. 

352 

353 """ 

354 if self._structured: 

355 self._logger.warning(message, **kwargs) # type: ignore[misc] 

356 else: 

357 self._logger.warning(self._format_message(message, **kwargs)) # type: ignore[misc] 

358 

359 def error(self, message: str, **kwargs: object) -> None: 

360 """Log an error message. 

361 

362 Args: 

363 message: The message to log. 

364 **kwargs: Additional context. 

365 

366 """ 

367 if self._structured: 

368 self._logger.error(message, **kwargs) # type: ignore[misc] 

369 else: 

370 self._logger.error(self._format_message(message, **kwargs)) # type: ignore[misc] 

371 

372 def critical(self, message: str, **kwargs: object) -> None: 

373 """Log a critical message. 

374 

375 Args: 

376 message: The message to log. 

377 **kwargs: Additional context. 

378 

379 """ 

380 if self._structured: 

381 self._logger.critical(message, **kwargs) # type: ignore[misc] 

382 else: 

383 self._logger.critical(self._format_message(message, **kwargs)) # type: ignore[misc] 

384 

385 def exception(self, message: str, **kwargs: object) -> None: 

386 """Log an exception with traceback. 

387 

388 Args: 

389 message: The message to log. 

390 **kwargs: Additional context. 

391 

392 """ 

393 if self._structured: 

394 self._logger.exception(message, **kwargs) # type: ignore[misc] 

395 else: 

396 self._logger.exception(self._format_message(message, **kwargs)) # type: ignore[misc] 

397 

398 

399def _configure_structlog(*, level: int | str | None = None) -> None: 

400 """Configure structlog with default processors and settings.""" 

401 if level is not None: 

402 if isinstance(level, str): 

403 level = getattr(_std_logging, level.upper(), _std_logging.INFO) 

404 _std_logging.basicConfig(level=level) # type: ignore[misc] 

405 

406 structlog.configure( 

407 processors=[ 

408 structlog.stdlib.filter_by_level, 

409 structlog.stdlib.add_logger_name, 

410 structlog.stdlib.add_log_level, # type: ignore[misc] 

411 structlog.processors.TimeStamper(fmt="iso"), 

412 correlation_id_processor, 

413 mask_sensitive_data_processor, 

414 structlog.processors.StackInfoRenderer(), 

415 structlog.processors.format_exc_info, 

416 structlog.processors.UnicodeDecoder(), 

417 structlog.processors.JSONRenderer(), 

418 ], 

419 wrapper_class=structlog.stdlib.BoundLogger, 

420 context_class=dict, 

421 logger_factory=structlog.stdlib.LoggerFactory(), 

422 ) 

423 

424 

425def _get_log_format(format_type: str) -> str: 

426 """Get the appropriate log format string.""" 

427 if format_type == "simple": 

428 return "%(levelname)s: %(message)s" 

429 elif format_type == "json": 

430 return JSON_FORMAT 

431 else: 

432 return DEFAULT_FORMAT 

433 

434 

435def setup_logging( 

436 level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", 

437 *, 

438 format_type: Literal["simple", "detailed", "json"] = "detailed", 

439 log_file: str | None = None, 

440 use_structured: bool = False, 

441) -> None: 

442 """Configure the root logger. 

443 

444 Args: 

445 level: Log level to set. 

446 format_type: Output format type. 

447 log_file: Optional file to log to. 

448 use_structured: Use structlog if available. 

449 

450 """ 

451 # Configure structlog if available and requested 

452 if use_structured and HAS_STRUCTLOG: 

453 _configure_structlog() 

454 return 

455 

456 log_format = _get_log_format(format_type) 

457 

458 handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)] 

459 

460 if log_file: 

461 handlers.append(logging.FileHandler(log_file, encoding="utf-8")) 

462 

463 logging.basicConfig( 

464 level=getattr(logging, level.upper()), # type: ignore[misc] 

465 format=log_format, 

466 handlers=handlers, 

467 force=True, 

468 ) 

469 

470 

471def get_logger( 

472 name: str = "taipanstack", 

473 *, 

474 level: str = "INFO", 

475 use_structured: bool = False, 

476) -> StackLogger: 

477 """Get a configured logger instance. 

478 

479 Args: 

480 name: Logger name. 

481 level: Log level. 

482 use_structured: Use structlog if available. 

483 

484 Returns: 

485 Configured StackLogger instance. 

486 

487 Example: 

488 >>> logger = get_logger("my_module") 

489 >>> logger.bind(request_id="123").info("Processing request") 

490 

491 """ 

492 return StackLogger(name, level, use_structured=use_structured) 

493 

494 

495def log_operation( 

496 operation: str, 

497 *, 

498 logger: StackLogger | None = None, 

499 level: str = "INFO", 

500 expected_exceptions: tuple[type[Exception], ...] | type[Exception] = Exception, 

501) -> AbstractContextManager[StackLogger]: 

502 """Context manager for logging operations. 

503 

504 Args: 

505 operation: Name of the operation. 

506 logger: Logger to use (creates one if not provided). 

507 level: Log level for messages. 

508 expected_exceptions: Exceptions to catch and log as failures. 

509 

510 Yields: 

511 The logger instance. 

512 

513 Example: 

514 >>> with log_operation("setup") as logger: 

515 ... logger.info("Setting up environment") 

516 

517 """ 

518 

519 @contextmanager 

520 def _log_context() -> Iterator[StackLogger]: 

521 nonlocal logger 

522 if logger is None: 

523 logger = get_logger() 

524 

525 start_time = datetime.now(UTC) 

526 logger.bind(operation=operation) 

527 

528 log_method = getattr(logger, level.lower()) # type: ignore[misc] 

529 log_method(f"Starting: {operation}") 

530 

531 try: 

532 yield logger 

533 duration = (datetime.now(UTC) - start_time).total_seconds() 

534 log_method(f"Completed: {operation}", duration_seconds=duration) 

535 except expected_exceptions as e: 

536 duration = (datetime.now(UTC) - start_time).total_seconds() 

537 logger.exception( 

538 f"Failed: {operation}", 

539 duration_seconds=duration, 

540 error=str(e), 

541 ) 

542 raise 

543 finally: 

544 logger.unbind("operation") 

545 

546 return _log_context()