Coverage for src/qdrant_loader/utils/logging.py: 38%

269 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:15 +0000

1"""Centralized logging configuration for the application.""" 

2 

3import logging 

4import re 

5from urllib.parse import unquote_plus 

6 

7import structlog 

8 

9from qdrant_loader.utils.sensitive import redact_sensitive_data 

10 

11 

12class QdrantVersionFilter(logging.Filter): 

13 """Filter to suppress Qdrant version check warnings.""" 

14 

15 def filter(self, record): 

16 return "version check" not in record.getMessage().lower() 

17 

18 

19class ApplicationFilter(logging.Filter): 

20 """Filter to only show logs from our application.""" 

21 

22 def filter(self, record): 

23 # Only show logs from our application 

24 return not record.name.startswith(("httpx", "httpcore", "urllib3")) 

25 

26 

27class SQLiteFilter(logging.Filter): 

28 """Filter to suppress verbose SQLite operation logs.""" 

29 

30 def filter(self, record): 

31 # Suppress verbose SQLite debug logs 

32 message = record.getMessage() 

33 sqlite_patterns = [ 

34 "executing functools.partial(<built-in method", 

35 "operation functools.partial(<built-in method", 

36 "executing <function connect.", 

37 "operation <function connect.", 

38 "returning exception", 

39 ] 

40 return not any(pattern in message for pattern in sqlite_patterns) 

41 

42 

43class VerbosityFilter(logging.Filter): 

44 """Filter to reduce verbosity of debug messages.""" 

45 

46 def filter(self, record): 

47 # Only filter DEBUG level messages 

48 if record.levelno != logging.DEBUG: 

49 return True 

50 

51 # Suppress overly verbose debug messages 

52 message = record.getMessage() 

53 verbose_patterns = [ 

54 "HTTP Request:", 

55 "Response status:", 

56 "Request headers:", 

57 "Response headers:", 

58 # PDF parsing debug messages 

59 "seek:", 

60 "nexttoken:", 

61 "do_keyword:", 

62 "nextobject:", 

63 "add_results:", 

64 "register:", 

65 "getobj:", 

66 "get_unichr:", 

67 "exec:", 

68 # Character encoding detection 

69 "confidence =", 

70 "prober hit error", 

71 "not active", 

72 # File processing verbosity 

73 "checking if file should be processed", 

74 "current configuration", 

75 "checking file extension", 

76 "file type detection", 

77 "file supported via", 

78 "starting metadata extraction", 

79 "completed metadata extraction", 

80 "document metadata:", 

81 # HTTP client debug 

82 "connect_tcp.started", 

83 "connect_tcp.complete", 

84 "send_request_headers", 

85 "send_request_body", 

86 "receive_response_headers", 

87 "receive_response_body", 

88 "response_closed", 

89 ] 

90 return not any(pattern in message for pattern in verbose_patterns) 

91 

92 

93class UvicornAccessRedactFilter(logging.Filter): 

94 """Redacts secret/token query-string values from uvicorn's access log line. 

95 

96 uvicorn logs the raw request line (including the query string) via 

97 ``record.args`` on the "uvicorn.access" logger, e.g. 

98 ``args = (client_addr, method, "/path?token=abc123", http_version, status)``. 

99 That logger has ``propagate=False`` in uvicorn's default logging config, so 

100 it never reaches the root logger's handlers/filters — the secret would 

101 otherwise be written to the access log in plaintext. Attach this filter 

102 directly to the "uvicorn.access" logger; per-logger filters run in 

103 ``Logger.handle()`` before any handler, so this applies regardless of what 

104 handlers uvicorn's own dictConfig installs (dictConfig only replaces 

105 handlers, not filters). 

106 """ 

107 

108 _SENSITIVE_QUERY_KEYS = { 

109 "token", 

110 "secret", 

111 "signature", 

112 "password", 

113 "authorization", 

114 "api_key", 

115 "api-key", 

116 "access_key", 

117 "access-key", 

118 "private_key", 

119 "private-key", 

120 "access_token", 

121 "access-token", 

122 } 

123 _QUERY_PAIR = re.compile(r'([?&])([^=&\s"]+)=([^&\s"]*)') 

124 

125 @classmethod 

126 def _redact_query_pair(cls, match: re.Match[str]) -> str: 

127 sep, raw_key, _value = match.groups() 

128 # uvicorn logs the raw, still percent-encoded request line, so a key 

129 # like "sec%72et" would slip past a literal match while Starlette/ 

130 # FastAPI (which percent-decodes query keys during parsing) still 

131 # resolves it to "secret" and accepts it as the webhook secret. 

132 # Decode before comparing so encoded keys are caught too. 

133 if unquote_plus(raw_key).lower() in cls._SENSITIVE_QUERY_KEYS: 

134 return f"{sep}{raw_key}=***REDACTED***" 

135 return match.group(0) 

136 

137 def filter(self, record: logging.LogRecord) -> bool: 

138 try: 

139 if isinstance(record.args, tuple) and len(record.args) >= 3: 

140 path = record.args[2] 

141 if isinstance(path, str) and "?" in path: 

142 redacted = self._QUERY_PAIR.sub(self._redact_query_pair, path) 

143 if redacted != path: 

144 record.args = ( 

145 record.args[0], 

146 record.args[1], 

147 redacted, 

148 *record.args[3:], 

149 ) 

150 except Exception: 

151 pass 

152 return True 

153 

154 

155class WindowsSafeConsoleHandler(logging.StreamHandler): 

156 """Custom console handler that handles Windows encoding issues.""" 

157 

158 # ANSI escape sequence pattern 

159 ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 

160 

161 def emit(self, record): 

162 """Emit a record, handling Windows console encoding issues.""" 

163 try: 

164 # Check if stream is still open before writing 

165 if hasattr(self.stream, "closed") and self.stream.closed: 

166 return # Skip logging if stream is closed 

167 

168 # Get the formatted message 

169 msg = self.format(record) 

170 

171 # For Windows, handle Unicode characters proactively 

172 import sys 

173 

174 if sys.platform == "win32": 

175 # Replace problematic Unicode characters with safe alternatives 

176 safe_msg = ( 

177 msg.replace("🚀", "[ROCKET]") 

178 .replace("📄", "[DOCUMENT]") 

179 .replace("✅", "[CHECK]") 

180 .replace("❌", "[CROSS]") 

181 .replace("⚠️", "[WARNING]") 

182 .replace("🔍", "[SEARCH]") 

183 .replace("💾", "[SAVE]") 

184 .replace("🔧", "[TOOL]") 

185 .replace("📊", "[CHART]") 

186 .replace("🎯", "[TARGET]") 

187 .replace("⚙️", "[GEAR]") 

188 .replace("⏱️", "[TIMER]") 

189 .replace("⏭️", "[NEXT]") 

190 .replace("🏗️", "[CONSTRUCTION]") 

191 .replace("1️⃣", "[1]") 

192 .replace("2️⃣", "[2]") 

193 .replace("3️⃣", "[3]") 

194 .replace("4️⃣", "[4]") 

195 ) 

196 try: 

197 self.stream.write(safe_msg + self.terminator) 

198 except UnicodeEncodeError: 

199 # Final fallback: encode with ASCII and replace all unsupported chars 

200 ascii_msg = safe_msg.encode("ascii", errors="replace").decode( 

201 "ascii" 

202 ) 

203 self.stream.write(ascii_msg + self.terminator) 

204 else: 

205 # For non-Windows, use standard handling 

206 self.stream.write(msg + self.terminator) 

207 

208 self.flush() 

209 except (ValueError, OSError): 

210 # Stream is closed or unavailable, skip silently 

211 pass 

212 except Exception: 

213 self.handleError(record) 

214 

215 

216class CleanFileHandler(logging.FileHandler): 

217 """Custom file handler that strips ANSI color codes from log messages.""" 

218 

219 # ANSI escape sequence pattern 

220 ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 

221 

222 def emit(self, record): 

223 """Emit a record, stripping ANSI codes from the message.""" 

224 try: 

225 # Check if stream is still open before writing 

226 if hasattr(self.stream, "closed") and self.stream.closed: 

227 return # Skip logging if stream is closed 

228 

229 # Get the formatted message 

230 msg = self.format(record) 

231 # Strip ANSI escape sequences 

232 clean_msg = self.ANSI_ESCAPE.sub("", msg) 

233 

234 # Handle Windows console encoding issues more robustly 

235 stream = self.stream 

236 try: 

237 stream.write(clean_msg + self.terminator) 

238 except UnicodeEncodeError: 

239 # Fallback for Windows console encoding issues 

240 # Replace problematic Unicode characters with safe alternatives 

241 import sys 

242 

243 if sys.platform == "win32": 

244 # Replace common emoji/Unicode characters with ASCII equivalents 

245 safe_msg = ( 

246 clean_msg.replace("🚀", "[ROCKET]") 

247 .replace("📄", "[DOCUMENT]") 

248 .replace("✅", "[CHECK]") 

249 .replace("❌", "[CROSS]") 

250 .replace("⚠️", "[WARNING]") 

251 .replace("🔍", "[SEARCH]") 

252 .replace("💾", "[SAVE]") 

253 .replace("🔧", "[TOOL]") 

254 .replace("📊", "[CHART]") 

255 .replace("🎯", "[TARGET]") 

256 .replace("⚙️", "[GEAR]") 

257 .replace("⏱️", "[TIMER]") 

258 .replace("⏭️", "[NEXT]") 

259 .replace("🏗️", "[CONSTRUCTION]") 

260 .replace("1️⃣", "[1]") 

261 .replace("2️⃣", "[2]") 

262 .replace("3️⃣", "[3]") 

263 .replace("4️⃣", "[4]") 

264 ) 

265 try: 

266 stream.write(safe_msg + self.terminator) 

267 except UnicodeEncodeError: 

268 # Final fallback: encode with ASCII and replace all unsupported chars 

269 ascii_msg = safe_msg.encode("ascii", errors="replace").decode( 

270 "ascii" 

271 ) 

272 stream.write(ascii_msg + self.terminator) 

273 else: 

274 # For non-Windows, try UTF-8 encoding with replacement 

275 safe_msg = clean_msg.encode("utf-8", errors="replace").decode( 

276 "utf-8", errors="replace" 

277 ) 

278 stream.write(safe_msg + self.terminator) 

279 self.flush() 

280 except (ValueError, OSError): 

281 # Stream is closed or unavailable, skip silently 

282 pass 

283 except Exception: 

284 self.handleError(record) 

285 

286 

287class CleanFormatter(logging.Formatter): 

288 """Custom formatter that shows only the message for INFO level logs.""" 

289 

290 def __init__(self, use_custom_renderer=False): 

291 super().__init__() 

292 self.use_custom_renderer = use_custom_renderer 

293 

294 def format(self, record): 

295 message = record.getMessage() 

296 

297 # If we're using the custom renderer, just return the message as-is 

298 # since the CustomConsoleRenderer already handled the formatting 

299 if self.use_custom_renderer: 

300 return message 

301 

302 # For INFO level, just show the message 

303 if record.levelno == logging.INFO: 

304 return message 

305 else: 

306 # For other levels, we need to reorder timestamp and level 

307 # Check if message starts with a timestamp (HH:MM:SS format) 

308 # The message might contain ANSI color codes, so we need to account for that 

309 time_pattern = ( 

310 r"^(?:\x1b\[[0-9;]*m)?(\d{2}:\d{2}:\d{2})(?:\x1b\[[0-9;]*m)?\s+(.*)" 

311 ) 

312 match = re.match(time_pattern, message) 

313 

314 if match: 

315 timestamp = match.group(1) 

316 rest_of_message = match.group(2) 

317 

318 # Check if the rest_of_message already contains a level tag 

319 # This can happen when CustomConsoleRenderer already formatted it 

320 level_in_message_pattern = ( 

321 r"^\[(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s" 

322 ) 

323 if re.match(level_in_message_pattern, rest_of_message): 

324 # Level already present, don't add another one 

325 return f"{timestamp} {rest_of_message}" 

326 else: 

327 # Add level tag 

328 return f"{timestamp} [{record.levelname}] {rest_of_message}" 

329 else: 

330 # No timestamp found 

331 # Check if message already has a level tag at the beginning 

332 level_in_message_pattern = ( 

333 r"^\[(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s" 

334 ) 

335 if re.match(level_in_message_pattern, message): 

336 # Level already present, don't add another one 

337 return message 

338 else: 

339 # Add level tag 

340 return f"[{record.levelname}] {message}" 

341 

342 

343class FileRenderer: 

344 """Custom renderer for file output without timestamps (FileFormatter will add them).""" 

345 

346 def __call__(self, logger, method_name, event_dict): 

347 # Extract the main message 

348 event = event_dict.pop("event", "") 

349 

350 # Format additional key-value pairs 

351 if event_dict: 

352 extras = " ".join(f"{k}={v}" for k, v in event_dict.items()) 

353 return f"{event} {extras}".strip() 

354 else: 

355 return event 

356 

357 

358class FileFormatter(logging.Formatter): 

359 """Custom formatter for file output that provides clean, readable logs without ANSI codes.""" 

360 

361 # ANSI escape sequence pattern 

362 ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 

363 

364 def format(self, record): 

365 # Get the timestamp 

366 timestamp = self.formatTime(record, "%Y-%m-%d %H:%M:%S") 

367 

368 # Get the level name 

369 level = record.levelname 

370 

371 # Get the message (this will be the already formatted structlog message) 

372 message = record.getMessage() 

373 

374 # First, strip ANSI escape sequences 

375 clean_message = self.ANSI_ESCAPE.sub("", message) 

376 

377 # Now check if the clean message starts with a timestamp and remove it 

378 # Pattern for structlog output: "HH:MM:SS message content" 

379 time_pattern = r"^\d{2}:\d{2}:\d{2}\s+" 

380 if re.match(time_pattern, clean_message): 

381 # Remove the structlog timestamp since we're adding our own 

382 clean_message = re.sub(time_pattern, "", clean_message) 

383 

384 # Check if the message already contains a level tag to avoid duplication 

385 level_in_message_pattern = r"^\[(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s" 

386 has_level_tag = re.match(level_in_message_pattern, clean_message) 

387 

388 # Format based on log level 

389 if record.levelno == logging.INFO: 

390 # For INFO level, use a clean format: timestamp | message 

391 return f"{timestamp} | {clean_message}" 

392 else: 

393 # For other levels, include the level only if not already present 

394 if has_level_tag: 

395 return f"{timestamp} | {clean_message}" 

396 else: 

397 return f"{timestamp} | [{level}] {clean_message}" 

398 

399 

400class CustomConsoleRenderer: 

401 """Custom console renderer that formats timestamp and level correctly.""" 

402 

403 def __init__(self, colors=True): 

404 self.colors = colors 

405 self._console_renderer = structlog.dev.ConsoleRenderer(colors=colors) 

406 # ANSI color codes 

407 self.gray = "\033[90m" if colors else "" 

408 self.green = "\033[92m" if colors else "" # Bright green for INFO 

409 self.yellow = "\033[93m" if colors else "" # Bright yellow for WARNING 

410 self.red = "\033[91m" if colors else "" # Bright red for ERROR 

411 self.magenta = "\033[95m" if colors else "" # Bright magenta for CRITICAL 

412 self.cyan = "\033[96m" if colors else "" # Bright cyan for DEBUG 

413 self.reset = "\033[0m" if colors else "" 

414 

415 def _get_level_color(self, level): 

416 """Get the appropriate color for a log level.""" 

417 level_colors = { 

418 "DEBUG": self.cyan, 

419 "INFO": self.green, # Green for INFO 

420 "WARNING": self.yellow, 

421 "ERROR": self.red, 

422 "CRITICAL": self.magenta, 

423 } 

424 return level_colors.get(level, "") 

425 

426 def __call__(self, logger, method_name, event_dict): 

427 # Extract timestamp if present 

428 timestamp = event_dict.pop("timestamp", None) 

429 

430 # Get the level from method_name 

431 level = method_name.upper() 

432 

433 # Use the default console renderer to format the rest 

434 formatted = self._console_renderer(logger, method_name, event_dict) 

435 

436 # If we have a timestamp 

437 if timestamp and isinstance(timestamp, str) and len(timestamp) >= 8: 

438 time_part = timestamp[:8] # Get HH:MM:SS part 

439 

440 # Remove the timestamp from the formatted message if it's there 

441 if formatted.startswith(time_part): 

442 formatted = formatted[len(time_part) :].lstrip() 

443 

444 # Add gray color to timestamp 

445 colored_timestamp = f"{self.gray}{time_part}{self.reset}" 

446 

447 # Get colored level for all levels including INFO 

448 level_color = self._get_level_color(level) 

449 colored_level = ( 

450 f"{level_color}[{level}]{self.reset}" if level_color else f"[{level}]" 

451 ) 

452 

453 # Show timestamp, colored level, and message for all levels 

454 return f"{colored_timestamp} {colored_level} {formatted}" 

455 

456 # Fallback if no timestamp 

457 level_color = self._get_level_color(level) 

458 colored_level = ( 

459 f"{level_color}[{level}]{self.reset}" if level_color else f"[{level}]" 

460 ) 

461 return f"{colored_level} {formatted}" 

462 

463 

464class LoggingConfig: 

465 """Centralized logging configuration.""" 

466 

467 _initialized = False 

468 _current_config = None 

469 

470 @classmethod 

471 def setup( 

472 cls, 

473 level: str = "INFO", 

474 format: str = "console", 

475 file: str | None = None, 

476 suppress_qdrant_warnings: bool = True, 

477 clean_output: bool = True, 

478 ) -> None: 

479 """Setup logging configuration. 

480 

481 Args: 

482 level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 

483 format: Log format (json or console) 

484 file: Path to log file (optional) 

485 suppress_qdrant_warnings: Whether to suppress Qdrant version check warnings 

486 clean_output: Whether to use clean, less verbose output 

487 """ 

488 try: 

489 # Convert string level to logging level 

490 numeric_level = getattr(logging, level.upper()) 

491 except AttributeError: 

492 raise ValueError(f"Invalid log level: {level}") from None 

493 

494 # Reset logging configuration 

495 logging.getLogger().handlers = [] 

496 structlog.reset_defaults() 

497 

498 # Create a list of handlers 

499 handlers = [] 

500 

501 # Add console handler with Windows encoding support 

502 

503 # Use Windows-safe console handler for all platforms 

504 console_handler = WindowsSafeConsoleHandler() 

505 

506 if clean_output and format == "console": 

507 # Use clean formatter for console output 

508 console_handler.setFormatter(CleanFormatter(use_custom_renderer=True)) 

509 else: 

510 console_handler.setFormatter(logging.Formatter("%(message)s")) 

511 

512 console_handler.addFilter(ApplicationFilter()) # Only show our application logs 

513 console_handler.addFilter(SQLiteFilter()) # Suppress verbose SQLite logs 

514 

515 if clean_output: 

516 console_handler.addFilter(VerbosityFilter()) # Reduce verbosity 

517 

518 handlers.append(console_handler) 

519 

520 # Add file handler if file is configured 

521 if file: 

522 file_handler = CleanFileHandler(file) 

523 file_handler.setFormatter(FileFormatter()) 

524 file_handler.addFilter( 

525 SQLiteFilter() 

526 ) # Suppress verbose SQLite logs in files too 

527 # Don't apply verbosity filter to file logs - keep everything for debugging 

528 handlers.append(file_handler) 

529 

530 # Configure standard logging 

531 logging.basicConfig( 

532 level=numeric_level, 

533 format="%(message)s", 

534 handlers=handlers, 

535 ) 

536 

537 # Apply SQLite filter to all existing loggers 

538 root_logger = logging.getLogger() 

539 for handler in root_logger.handlers: 

540 handler.addFilter(SQLiteFilter()) 

541 

542 # uvicorn.access has propagate=False in uvicorn's own logging config, so 

543 # it bypasses the root logger's redaction above. Attach directly so 

544 # webhook secrets/tokens in the request query string aren't logged in 

545 # plaintext (see UvicornAccessRedactFilter docstring for details). 

546 uvicorn_access_logger = logging.getLogger("uvicorn.access") 

547 if not any( 

548 isinstance(f, UvicornAccessRedactFilter) 

549 for f in uvicorn_access_logger.filters 

550 ): 

551 uvicorn_access_logger.addFilter(UvicornAccessRedactFilter()) 

552 

553 # Add filter to suppress Qdrant version check warnings 

554 if suppress_qdrant_warnings: 

555 qdrant_logger = logging.getLogger("qdrant_client") 

556 qdrant_logger.addFilter(QdrantVersionFilter()) 

557 

558 # Suppress verbose SQLAlchemy and SQLite logs 

559 sqlalchemy_loggers = [ 

560 "sqlalchemy.engine", 

561 "sqlalchemy.dialects", 

562 "sqlalchemy.pool", 

563 "aiosqlite", 

564 "sqlite3", 

565 ] 

566 

567 for logger_name in sqlalchemy_loggers: 

568 logger = logging.getLogger(logger_name) 

569 logger.addFilter(SQLiteFilter()) 

570 logger.setLevel(logging.WARNING) # Only show warnings and errors 

571 

572 # Suppress verbose third-party library debug logs 

573 noisy_loggers = [ 

574 "chardet", # Character encoding detection 

575 "chardet.charsetprober", 

576 "chardet.latin1prober", 

577 "chardet.mbcharsetprober", 

578 "chardet.sbcharsetprober", 

579 "chardet.utf8prober", 

580 "pdfminer", # PDF parsing 

581 "pdfminer.pdfparser", 

582 "pdfminer.pdfdocument", 

583 "pdfminer.pdfinterp", 

584 "pdfminer.converter", 

585 "pdfplumber", # PDF processing 

586 "markitdown", # File conversion 

587 "httpcore", # HTTP client debug logs 

588 "httpx", # HTTP client debug logs 

589 "gensim", # Topic modeling library 

590 "gensim.models", # LDA model training logs 

591 "gensim.models.ldamodel", # Specific LDA training logs 

592 "gensim.corpora", # Dictionary and corpus logs 

593 ] 

594 

595 for logger_name in noisy_loggers: 

596 logger = logging.getLogger(logger_name) 

597 logger.setLevel(logging.WARNING) # Only show warnings and errors 

598 

599 # Configure structlog processors based on format and clean_output 

600 # Redaction processor to mask sensitive fields in event_dict 

601 def _redact_sensitive(logger, method_name, event_dict): # type: ignore[no-redef] 

602 sensitive_keys = { 

603 "api_key", 

604 "llm_api_key", 

605 "authorization", 

606 "Authorization", 

607 "token", 

608 "access_token", 

609 "secret", 

610 "password", 

611 } 

612 sensitive_key_pattern = re.compile( 

613 r"(?i)(?:^|[_-])(token|secret|password|api[_-]?key|access[_-]?key|private[_-]?key|authorization)(?:$|[_-])" 

614 ) 

615 

616 def _is_sensitive_key(key: object) -> bool: 

617 if not isinstance(key, str): 

618 return False 

619 return key in sensitive_keys or bool(sensitive_key_pattern.search(key)) 

620 

621 def _mask(value: str) -> str: 

622 try: 

623 if not isinstance(value, str) or not value: 

624 return "***REDACTED***" 

625 # Preserve small hint while masking majority 

626 if len(value) <= 8: 

627 return "***REDACTED***" 

628 return value[:2] + "***REDACTED***" + value[-2:] 

629 except Exception: 

630 return "***REDACTED***" 

631 

632 def _sanitize_string(value: str) -> str: 

633 try: 

634 return redact_sensitive_data(value, mask="***REDACTED***") 

635 except Exception: 

636 return "***REDACTED***" 

637 

638 def _deep_redact(obj): 

639 try: 

640 if isinstance(obj, dict): 

641 red = {} 

642 for k, v in obj.items(): 

643 if _is_sensitive_key(k): 

644 red[k] = ( 

645 _mask(v) if isinstance(v, str) else "***REDACTED***" 

646 ) 

647 else: 

648 red[k] = _deep_redact(v) 

649 return red 

650 if isinstance(obj, list): 

651 return [_deep_redact(i) for i in obj] 

652 if isinstance(obj, str): 

653 return _sanitize_string(obj) 

654 return obj 

655 except Exception: 

656 return obj 

657 

658 return _deep_redact(event_dict) 

659 

660 if clean_output and format == "console": 

661 # Minimal processors for clean output 

662 processors = [ 

663 structlog.stdlib.filter_by_level, 

664 structlog.processors.TimeStamper(fmt="%H:%M:%S"), 

665 _redact_sensitive, 

666 CustomConsoleRenderer(colors=True), 

667 ] 

668 else: 

669 # Full processors for detailed output 

670 processors = [ 

671 structlog.stdlib.filter_by_level, 

672 structlog.stdlib.add_logger_name, 

673 structlog.stdlib.add_log_level, 

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

675 structlog.processors.StackInfoRenderer(), 

676 structlog.processors.UnicodeDecoder(), 

677 structlog.processors.CallsiteParameterAdder( 

678 [ 

679 structlog.processors.CallsiteParameter.FILENAME, 

680 structlog.processors.CallsiteParameter.FUNC_NAME, 

681 structlog.processors.CallsiteParameter.LINENO, 

682 ] 

683 ), 

684 _redact_sensitive, 

685 ] 

686 

687 if format == "json": 

688 processors.append(structlog.processors.JSONRenderer()) 

689 else: 

690 processors.append(structlog.dev.ConsoleRenderer(colors=True)) 

691 

692 # Configure structlog 

693 structlog.configure( 

694 processors=processors, 

695 wrapper_class=structlog.make_filtering_bound_logger(numeric_level), 

696 logger_factory=structlog.stdlib.LoggerFactory(), 

697 cache_logger_on_first_use=False, # Disable caching to ensure new configuration is used 

698 ) 

699 

700 cls._initialized = True 

701 cls._current_config = ( 

702 level, 

703 format, 

704 file, 

705 suppress_qdrant_warnings, 

706 clean_output, 

707 ) 

708 

709 @classmethod 

710 def get_logger(cls, name: str | None = None) -> structlog.BoundLogger: 

711 """Get a logger instance. 

712 

713 Args: 

714 name: Logger name. If None, will use the calling module's name. 

715 

716 Returns: 

717 structlog.BoundLogger: Logger instance 

718 """ 

719 if not cls._initialized: 

720 # Initialize with default settings if not already initialized 

721 cls.setup() 

722 return structlog.get_logger(name) 

723 

724 

725# Standardize on core logging configuration 

726try: 

727 from qdrant_loader_core.logging import ( 

728 LoggingConfig as _CoreLoggingConfig, # type: ignore 

729 ) 

730 

731 LoggingConfig = _CoreLoggingConfig # type: ignore[assignment] 

732except Exception: 

733 # Fallback to local implementation if core is unavailable 

734 pass