Coverage for src/local_deep_research/utilities/log_utils.py: 87%

301 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Utilities for logging. 

3""" 

4 

5# Needed for loguru annotations 

6from __future__ import annotations 

7 

8import asyncio 

9import inspect 

10 

11# import logging - needed for InterceptHandler compatibility 

12import logging 

13import os 

14import queue 

15import sys 

16import contextvars 

17import threading 

18from functools import wraps 

19from typing import Any, Callable 

20 

21import loguru 

22from loguru import logger 

23 

24from ..config.paths import get_logs_directory 

25from ..database.models import ResearchLog 

26from ..web.services.socketio_asgi import emit_to_subscribers 

27 

28 

29# Per-task context for research_id, replacing Flask's g.research_id. 

30# ContextVar works in both sync and async code and is correctly isolated 

31# per-task in asyncio (unlike threading.local in async code). 

32_research_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( 

33 "research_id", default=None 

34) 

35 

36_LOG_DIR = get_logs_directory() 

37# Lazy import: log_utils is imported very early by many bootstrap paths, so 

38# importing security at module top level risks an import cycle. 

39from ..security.directory_creation import create_directory # noqa: E402 

40 

41create_directory(_LOG_DIR, context="logs directory") 

42 

43 

44def _register_milestone_level() -> None: 

45 """Register the custom MILESTONE level. 

46 

47 Called at import rather than only from ``config_logger()``. Loguru levels 

48 are process-global, and ``research_service``'s ``progress_callback`` opens 

49 with ``bound_logger.log("MILESTONE", ...)`` — so in any process that never 

50 called ``config_logger()`` (i.e. every pytest run; it is invoked only from 

51 ``web/app.py``'s ``ldr-web`` entry point) that first call raises 

52 ``ValueError: Level 'MILESTONE' does not exist``. 

53 

54 The consequence was not a visible failure. The exception surfaced at the 

55 worker's first progress checkpoint, was re-raised inside the error handler 

56 and swallowed by a broad ``except``, so the research thread died before 

57 writing any terminal status and no assertion failed. Whether a given test 

58 process had the level at all depended on whether an unrelated module that 

59 registers it (two test modules do so at import time) had been collected 

60 first — i.e. research-worker tests were order-dependent by construction. 

61 """ 

62 try: 

63 logger.level("MILESTONE", no=26, color="<magenta><bold>") 

64 except ValueError: 

65 # Already registered — levels are process-global and permanent. 

66 pass 

67 

68 

69_register_milestone_level() 

70 

71# Thread-safe queue for database logs from background threads 

72_log_queue = queue.Queue(maxsize=1000) 

73 

74# Thread-local re-entry guard for `database_sink`. If a `logger.*` call 

75# fires while we're inside the sink (e.g. loguru's own error handler), 

76# we must not re-enter or the process deadlocks/recurses. 

77_sink_state = threading.local() 

78_queue_processor_thread = None 

79_queue_processor_lock = threading.Lock() 

80_stop_queue = threading.Event() 

81""" 

82Default log directory to use. 

83""" 

84 

85# Cap how much of a single log record's message we ship to the browser over 

86# socket.io. Some diagnostic / agent log lines can carry large payloads 

87# (model outputs, long tracebacks, extracted text) — many KB per call — which 

88# is useless in the UI (a single massive blob fills the viewport) and inflates both 

89# wire traffic and client-side state. Container-log/stderr, file, and DB 

90# sinks do not apply this cap, so longer text stays available for grep/DB 

91# queries -- up to the 32 KiB per-string bound the redaction patcher 

92# (``_sanitize_record``) applies to every sink. The cap bounds the 

93# *prefix* preserved from the original message; the wire payload is the 

94# prefix plus a short truncation indicator (~100 bytes), so it can exceed 

95# this value by that fixed overhead. 

96FRONTEND_MESSAGE_MAX_LENGTH = 2000 

97 

98# Cap the size of messages persisted to ResearchLog. The DB column is 

99# unbounded TEXT, so a long langgraph run can accumulate thousands of 

100# 10 KB rows — paginated reads (PR #4037) hide the symptom but don't 

101# stop the storage growth. Same prefix-plus-indicator semantics as 

102# FRONTEND_MESSAGE_MAX_LENGTH; container-log/stderr/file sinks do not 

103# apply this cap, so text up to the patcher's 32 KiB per-string bound is 

104# still available out-of-band. 

105DATABASE_MESSAGE_MAX_LENGTH = 5000 

106 

107 

108class InterceptHandler(logging.Handler): 

109 """ 

110 Intercepts logging messages and forwards them to Loguru's logger. 

111 """ 

112 

113 def emit(self, record: logging.LogRecord) -> None: 

114 # Get corresponding Loguru level if it exists. 

115 try: 

116 level: str | int = logger.level(record.levelname).name 

117 except ValueError: 

118 level = record.levelno 

119 

120 # Find caller from where originated the logged message. 

121 frame, depth = inspect.currentframe(), 0 

122 while frame: 122 ↛ 131line 122 didn't jump to line 131 because the condition on line 122 was always true

123 filename = frame.f_code.co_filename 

124 is_logging = filename == logging.__file__ 

125 is_frozen = "importlib" in filename and "_bootstrap" in filename 

126 if depth > 0 and not (is_logging or is_frozen): 

127 break 

128 frame = frame.f_back 

129 depth += 1 

130 

131 logger.opt(depth=depth, exception=record.exc_info).log( 

132 level, record.getMessage() 

133 ) 

134 

135 

136def log_for_research( 

137 to_wrap: Callable[[str, ...], Any], 

138) -> Callable[[str, ...], Any]: 

139 """ 

140 Decorator for a function that's part of the research process. It expects the function to 

141 take the research ID (UUID) as the first parameter, and configures all log 

142 messages made during this request to include the research ID. 

143 

144 Args: 

145 to_wrap: The function to wrap. Should take the research ID as the first parameter. 

146 

147 Returns: 

148 The wrapped function. 

149 

150 """ 

151 

152 @wraps(to_wrap) 

153 def wrapped(research_id: str, *args: Any, **kwargs: Any) -> Any: 

154 cv_handle = _research_id_var.set(research_id) 

155 try: 

156 return to_wrap(research_id, *args, **kwargs) 

157 finally: 

158 _research_id_var.reset(cv_handle) 

159 

160 return wrapped 

161 

162 

163def _get_research_context_fallback() -> dict | None: 

164 """Read the per-thread research context, if any. 

165 

166 Used as a fallback when individual log calls don't bind research_id/ 

167 username via ``logger.bind``. The research thread sets this once at 

168 startup via ``set_search_context``, so every subsequent log call from 

169 the same thread picks up research_id, username, and user_password 

170 automatically — without requiring every call site to remember to bind. 

171 """ 

172 try: 

173 from .thread_context import get_search_context 

174 

175 return get_search_context() 

176 except Exception: 

177 return None 

178 

179 

180def _get_request_username() -> str | None: 

181 """Read the request-scoped username, if any. 

182 

183 The FastAPI successor to main's ``flask.session.get("username")`` 

184 fallback in the log sinks. Reads the contextvar ``DatabaseMiddleware`` 

185 populates per request (and that background workers push explicitly via 

186 ``request_user``), so a log call that binds ``research_id`` without a 

187 ``username`` -- as report_assembly_service does from a real request 

188 thread -- still resolves an owner instead of being dropped. 

189 

190 Imported lazily and never allowed to raise: this runs inside a loguru 

191 sink, where an exception would break logging itself. 

192 """ 

193 try: 

194 from .request_context import get_current_username 

195 

196 return get_current_username() 

197 except Exception: 

198 return None 

199 

200 

201def _get_research_id(record=None) -> str | None: 

202 """ 

203 Gets the current research ID (UUID), if present. 

204 

205 Args: 

206 record: Optional loguru record that might contain bound research_id 

207 

208 Returns: 

209 The current research ID (UUID), or None if it does not exist. 

210 """ 

211 # First check if research_id is bound to the log record 

212 if record and "extra" in record and "research_id" in record["extra"]: 

213 return record["extra"]["research_id"] 

214 # Then check the contextvar (set by @log_for_research on the running 

215 # task/thread) 

216 rid = _research_id_var.get() 

217 if rid: 

218 return rid 

219 # Fall back to per-thread research context — research-thread logger 

220 # calls without an explicit bind still get attributed correctly. 

221 ctx = _get_research_context_fallback() 

222 if ctx: 

223 return ctx.get("research_id") 

224 return None 

225 

226 

227# Counters for swallowed exceptions in the logging path. Bare except: pass 

228# is required here (logging errors must not propagate or recurse), but we 

229# write a stderr line on each new occurrence so silent failures aren't 

230# invisible — only active when LDR_APP_DEBUG=true so production stderr 

231# stays clean. 

232_silent_exc_counts: dict[str, int] = {} 

233 

234 

235def _report_silent_exception( 

236 where: str, 

237 exc_type_name: str, 

238 username: str | None = None, 

239 research_id: str | None = None, 

240 level: str | None = None, 

241) -> None: 

242 """Surface a swallowed logging-path exception to stderr. 

243 

244 Bypasses ``logger`` to avoid recursing back through ``database_sink``. 

245 Rate-limited to first occurrence + every 100th repeat for the same 

246 ``where`` key, so a persistent failure mode doesn't flood the console. 

247 

248 Note: takes the exception's TYPE NAME as a plain string (not the 

249 exception object). The caller does ``type(exc).__name__`` and passes 

250 the result. This is deliberate — CodeQL's taint analyzer treats any 

251 function frame holding a ``BaseException`` captured from a password- 

252 bearing call site as tainted, and flags every stderr write inside 

253 that frame. Receiving only a type-name string severs the flow at 

254 the boundary. 

255 """ 

256 if os.environ.get("LDR_APP_DEBUG", "").lower() not in ("1", "true", "yes"): 256 ↛ 258line 256 didn't jump to line 258 because the condition on line 256 was always true

257 return 

258 n = _silent_exc_counts.get(where, 0) + 1 

259 _silent_exc_counts[where] = n 

260 if n != 1 and n % 100 != 0: 

261 return 

262 parts = [] 

263 if username is not None: 

264 parts.append(f"username={username!r}") 

265 if research_id is not None: 

266 parts.append(f"research_id={research_id!r}") 

267 if level is not None: 

268 parts.append(f"level={level!r}") 

269 ctx = " ".join(parts) 

270 # CodeQL's py/clear-text-logging-sensitive-data may flag this stderr 

271 # write because the function frame is reachable from the logging path. 

272 # (_write_log_to_database no longer holds any credential at all since 

273 # #5538 -- it binds to the already-open engine.) The data 

274 # actually written is only plain strings — `where` (literal), 

275 # `exc_type_name` (`type(exc).__name__`), and `username/research_id/level` 

276 # repr'd from the queue entry. No password value ever reaches the 

277 # formatter; the helper signature deliberately accepts only typed 

278 # primitives. If CodeQL flags this line, dismiss the alert as a 

279 # false positive in the Security tab with that justification. 

280 sys.stderr.write( 

281 f"[log-utils] {where} swallowed (count={n}): " 

282 f"{exc_type_name}{(' ' + ctx) if ctx else ''}\n" 

283 ) 

284 sys.stderr.flush() 

285 

286 

287def _clear_daemon_thread_credentials() -> None: 

288 """Drop any DB session / cached credential held by the current thread. 

289 

290 Invoked at the end of every log-queue drain iteration. The queue 

291 daemon is a long-lived ``daemon=True`` thread whose credentials the 

292 dead-thread sweeper (``cleanup_dead_threads``) can never reclaim, 

293 because the thread stays alive for the whole process. Clearing here 

294 guarantees the ``log-queue-processor`` thread never retains a 

295 plaintext credential in ``thread_local_session``'s 

296 ``_thread_credentials``. 

297 

298 Best-effort: must never raise, or a single cleanup hiccup would crash 

299 the daemon and silently stop all subsequent log persistence. 

300 """ 

301 try: 

302 from ..database.thread_local_session import cleanup_current_thread 

303 

304 cleanup_current_thread() 

305 except Exception: 

306 pass # noqa: silent-exception — cleanup must not kill the log daemon 

307 

308 

309def _process_log_queue(): 

310 """Process logs from the queue in a dedicated background thread. 

311 

312 Under FastAPI there's no Flask app context to gate on — we just 

313 drain the queue and write entries directly. Main gated this on 

314 ``has_app_context()`` and re-queued otherwise; with no such context 

315 here the write is unconditional, which is safe because 

316 ``_write_log_to_database`` now refuses to open a closed database. 

317 

318 Safe to run off the main thread: the write binds a session to the 

319 user's already-open engine, and the underlying SQLite engines are 

320 opened with ``check_same_thread=False``. It never reopens a closed 

321 database, so a post-logout backlog can't resurrect a user's 

322 connection from this thread (#5538). Errors are swallowed rather 

323 than crashing the processor. 

324 """ 

325 while not _stop_queue.is_set(): 

326 try: 

327 log_entry = _log_queue.get(timeout=0.1) 

328 if log_entry is None: 

329 continue 

330 try: 

331 _write_log_to_database(log_entry) 

332 finally: 

333 # Belt-and-braces credential hygiene (#5538). This daemon is 

334 # a long-lived daemon=True thread, so the dead-thread 

335 # credential sweeper (cleanup_dead_threads) never reclaims 

336 # it. Clear any per-thread DB session / cached credential at 

337 # the end of every drain iteration so the 

338 # log-queue-processor thread can never accumulate a 

339 # plaintext credential in _thread_credentials. 

340 _clear_daemon_thread_credentials() 

341 except queue.Empty: 

342 continue 

343 except Exception as exc: 

344 # noqa: silent-exception — must not let logging errors crash the log processor thread. 

345 # Wrap the report itself: if stderr is closed (broken pipe etc.) 

346 # an IOError from inside an except handler propagates and would 

347 # kill the daemon thread, silently breaking all subsequent log 

348 # persistence for the rest of the process lifetime. 

349 try: 

350 _report_silent_exception( 

351 "process_log_queue", type(exc).__name__ 

352 ) 

353 except Exception: 

354 pass # noqa: silent-exception — broken stderr must not kill the daemon 

355 

356 

357def _write_log_to_database(log_entry: dict) -> None: 

358 """Persist one queued log entry into the user's already-open database. 

359 

360 The queue drain must NEVER reopen a closed database. A log entry that 

361 was queued while a user was active can be drained by the background 

362 daemon *after* that user has logged out; reopening their encrypted DB 

363 here would silently re-add the connection (``is_user_connected`` would 

364 flip back to True) and re-cache their credentials — resurrecting 

365 session state with no user action and no attacker involved. 

366 

367 So we gate strictly on an already-open connection 

368 (``db_manager.is_user_connected``) and bind to the EXISTING engine via 

369 ``db_manager.get_session`` — which never opens a new database and needs 

370 no password. Entries for a user with no live connection (e.g. a 

371 post-logout backlog) are dropped; the full message is still available 

372 in the stderr/file sinks. 

373 

374 In-flight logging is preserved: while a user is active — including a 

375 running research job, which keeps their DB open — the connection stays 

376 live, so their logs continue to be written normally. 

377 """ 

378 from ..database.encrypted_db import db_manager 

379 

380 username = log_entry.get("username") 

381 if not username: 

382 return 

383 

384 # Gate on an already-open connection. Do NOT lazily reopen the DB: 

385 # after logout the connection is gone, and reopening it here would 

386 # resurrect the user's connected state from a stale queued backlog. 

387 if not db_manager.is_user_connected(username): 

388 return 

389 

390 db_session = None 

391 try: 

392 # Binds a session to the ALREADY-open engine. Returns None (never 

393 # reopens) if the connection was closed in the small race between 

394 # the is_user_connected check above and this call; a rekey sidecar 

395 # fault in that window raises instead and is reported below like 

396 # any other failed write (the entry is dropped). We can't use 

397 # get_user_db_session here: it would resolve+cache a password and 

398 # lazily reopen the DB, which is exactly the post-logout 

399 # resurrection this fix removes. The session is explicitly closed 

400 # in the finally block below, so the QueuePool connection is 

401 # returned and no FD leaks. 

402 db_session = db_manager.get_session(username) # noqa: raw-session 

403 if db_session is not None: 403 ↛ 434line 403 didn't jump to line 434 because the condition on line 403 was always true

404 db_log = ResearchLog( 

405 timestamp=log_entry["timestamp"], 

406 message=log_entry["message"], 

407 module=log_entry["module"], 

408 function=log_entry["function"], 

409 line_no=log_entry["line_no"], 

410 level=log_entry["level"], 

411 research_id=log_entry["research_id"], 

412 ) 

413 db_session.add(db_log) 

414 db_session.commit() 

415 except Exception as exc: 

416 # noqa: silent-exception — DB errors in the logging path must not propagate or recurse. 

417 # Wrap the report itself so a broken-stderr IOError can't escape and 

418 # be re-caught by an outer logging-aware handler somewhere upstream. 

419 try: 

420 _report_silent_exception( 

421 "write_log_to_database", 

422 type(exc).__name__, 

423 username=log_entry.get("username"), 

424 research_id=log_entry.get("research_id"), 

425 level=log_entry.get("level"), 

426 ) 

427 except Exception: 

428 pass # noqa: silent-exception — broken stderr must not bubble out of logging path 

429 finally: 

430 # The session is freshly created per drain, bound to the shared 

431 # per-user engine — close it so its pooled connection is returned 

432 # (close() also rolls back any pending transaction from a failed 

433 # commit). Never let a close error escape the logging path. 

434 if db_session is not None: 

435 try: 

436 db_session.close() 

437 except Exception: 

438 pass # noqa: silent-exception — session close failure must not propagate 

439 

440 

441def database_sink(message: loguru.Message) -> None: 

442 """ 

443 Sink that saves messages to the database. 

444 Queues logs from background threads for later processing. 

445 

446 Args: 

447 message: The log message to save. 

448 

449 """ 

450 # Thread-local re-entry guard. Any code path inside this sink (or 

451 # `_write_log_to_database`) that happens to call a logger — including 

452 # loguru's own sink-error handler when `catch=True` is set — would 

453 # re-invoke this function and recurse. The `try/except Exception: pass` 

454 # in `_write_log_to_database` catches most cases, but a nested sink 

455 # error handler can still land here. Fail closed for the nested call. 

456 if getattr(_sink_state, "in_sink", False): 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 return 

458 _sink_state.in_sink = True 

459 try: 

460 record = message.record 

461 research_id = _get_research_id(record) 

462 

463 # Resolve the username the log belongs to. The queue daemon thread 

464 # can't read the research thread's ContextVar storage, so we capture 

465 # it here in the emitting thread. 

466 # 

467 # We deliberately do NOT capture the user's password. The queue entry 

468 # must never carry a plaintext credential: the background drain would 

469 # otherwise use it to reopen the user's encrypted DB after logout, 

470 # reinstating their connected state (#5538). The drain writes only 

471 # when the DB is already open -- see _write_log_to_database. 

472 # 

473 # Source priority: 

474 # 1. logger.bind(...) extras on the record itself 

475 # 2. per-thread research context (set once when the research thread 

476 # starts, so every subsequent log call inherits it without 

477 # requiring an explicit bind) 

478 username = record.get("extra", {}).get("username") 

479 ctx = _get_research_context_fallback() 

480 if ctx and not username: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 username = ctx.get("username") 

482 if research_id is not None and not username: 

483 # Third source, ported from main: it resolved the request user 

484 # via `flask.session.get("username")` when a research_id was 

485 # bound but no username was. That fallback is not dead code -- 

486 # report_assembly_service binds research_id alone from a real 

487 # request thread, and without this the entry resolves to 

488 # username=None and is silently dropped by the `if not username` 

489 # guard downstream. get_current_username() is the direct 

490 # successor (the contextvar DatabaseMiddleware populates). 

491 # 

492 # The `research_id is not None` condition is main's, and it is 

493 # load-bearing for data retention, not just noise control. 

494 # DatabaseMiddleware populates that contextvar for EVERY 

495 # authenticated request, so without the gate any INFO record 

496 # emitted during any request resolves an owner, clears the skip 

497 # guard below, and is persisted to that user's app_logs with 

498 # research_id = NULL. Those rows are unreachable by the only 

499 # mechanism that ever removes app_logs -- the ON DELETE CASCADE 

500 # from research_history -- so neither "delete this research" nor 

501 # "clear all history" can touch them, and there is no retention 

502 # job. They would accumulate permanently with no way to delete 

503 # them. main's comment put it as: "ResearchLog is research-scoped 

504 # by design -- auth and other system DEBUG logs (research_id=None) 

505 # don't belong there." 

506 username = _get_request_username() 

507 

508 # Skip persistence for system logs that have no research context. 

509 # These can't be written to any per-user encrypted DB and would just 

510 # churn through the queue + daemon for no useful end state. 

511 if research_id is None and username is None: 

512 return 

513 

514 # Create log entry dict 

515 log_entry = { 

516 "timestamp": record["time"], 

517 # Prefix the message with exception type/value when the record 

518 # carries one (logger.exception / logger.opt(exception=...)). 

519 # Keeps the full traceback off the encrypted-DB row (diagnose=False 

520 # on this sink; password-redaction policy #4182) while still 

521 # leaving the database log useful for triage. 

522 "message": _truncate_for_database( 

523 _exception_context(record) + record["message"] 

524 ), 

525 "module": record["name"], 

526 "function": record["function"], 

527 "line_no": int(record["line"]), 

528 "level": record["level"].name, 

529 "research_id": research_id, 

530 "username": username, 

531 } 

532 

533 # Queue unless we are on a thread where a synchronous SQLCipher write 

534 # is actually safe — i.e. the main thread with NO event loop running 

535 # on it (startup, CLI). Everything else goes to the background writer. 

536 # 

537 # The event-loop check is load-bearing and its absence was a migration 

538 # regression. Flask's version of this guard read 

539 # if not has_app_context() or current_thread().name != "MainThread" 

540 # and the port dropped the first clause (correctly — there is no Flask) 

541 # while keeping a thread-name test whose MEANING INVERTED. Under 

542 # Werkzeug, request and Socket.IO handlers ran on "Thread-N", so the 

543 # synchronous branch was effectively startup-only. Under uvicorn the 

544 # event loop IS "MainThread" (web/app.py calls uvicorn.run() from 

545 # main()), so every log line emitted on the loop took the synchronous 

546 # branch: _write_log_to_database -> get_user_db_session -> potentially 

547 # open_user_database, i.e. SQLCipher PBKDF2 key derivation, engine 

548 # build and a commit, all on the event loop. With workers=1 that 

549 # stalls the entire process. 

550 # 

551 # Thread name is the wrong question; "is a loop running here" is the 

552 # right one, and it stays correct however the server is launched. 

553 # Note no test could observe this: TestClient drives the app from an 

554 # "asyncio-portal-*" thread, never "MainThread". 

555 try: 

556 asyncio.get_running_loop() 

557 on_event_loop = True 

558 except RuntimeError: 

559 on_event_loop = False 

560 

561 if on_event_loop or threading.current_thread().name != "MainThread": 

562 try: 

563 _log_queue.put_nowait(log_entry) 

564 except queue.Full: 

565 pass # Drop log if queue is full to avoid blocking 

566 else: 

567 _write_log_to_database(log_entry) 

568 finally: 

569 _sink_state.in_sink = False 

570 

571 

572def _truncate_for_database(message: str) -> str: 

573 """Bound the persisted size of a log message. 

574 

575 ``DATABASE_MESSAGE_MAX_LENGTH`` is the preserved-prefix length; 

576 the stored string is at most that plus a ~100-char suffix that 

577 reports the original length so debug context is not lost. The 

578 container-log/stderr/file sinks keep up to the patcher's 32 KiB 

579 per-string bound. 

580 """ 

581 if len(message) <= DATABASE_MESSAGE_MAX_LENGTH: 

582 return message 

583 return message[:DATABASE_MESSAGE_MAX_LENGTH] + _truncation_suffix(message) 

584 

585 

586def _truncation_suffix(message: str) -> str: 

587 """Indicator appended by the DB and frontend caps. 

588 

589 The reported length counts what the patcher's 32 KiB bound already 

590 dropped (its omission marker records the count), so it is the length 

591 before any truncation; the server logs hold at most the first 

592 ``_LOG_REDACTION_MAX_CHARS`` characters. 

593 """ 

594 # Deferred import to avoid potential circular imports during module initialization 

595 from ..security.log_sanitizer import ( 

596 _LOG_REDACTION_MAX_CHARS, 

597 log_length_before_truncation, 

598 ) 

599 

600 return ( 

601 f"… (truncated; server logs keep up to {_LOG_REDACTION_MAX_CHARS} " 

602 f"chars; original length: {log_length_before_truncation(message)} " 

603 f"chars)" 

604 ) 

605 

606 

607def _exception_context(record) -> str: 

608 """Render a short exception prefix from the log record, if any. 

609 

610 Loguru stores the active exception in ``record["exception"]`` as a 

611 ``RecordException`` namedtuple (or None) populated by ``.exception()`` 

612 or by ``logger.opt(exception=...)``. Without this, the database sink 

613 persists messages like ``"LangGraph agent error"`` with the actual 

614 exception type and message only on stderr — investigators querying 

615 ``app_logs`` see an empty ERROR row. The exception is always 

616 separately available in container-log/stderr/file sinks; this just 

617 gives the DB row enough context to triage. The full traceback is 

618 deliberately NOT included because ``diagnose=False`` on the database 

619 sink (see ``config_logger``) and the password-redaction policy 

620 (#4182) forbid it for encrypted-DB persistence. 

621 """ 

622 exc = record.get("exception") if record else None 

623 if not exc: 

624 return "" 

625 # RecordException is a namedtuple of (type, value, traceback). 

626 # ``value`` is the exception instance (or None if un-picklable). 

627 exc_type = getattr(exc, "type", None) 

628 exc_value = getattr(exc, "value", None) 

629 if exc_type is None and isinstance(exc, tuple) and len(exc) >= 1: 

630 exc_type = exc[0] 

631 if exc_value is None and len(exc) >= 2: 631 ↛ 633line 631 didn't jump to line 633 because the condition on line 631 was always true

632 exc_value = exc[1] 

633 if exc_type is None: 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true

634 return "" 

635 # Deferred import to avoid potential circular imports during module initialization 

636 from ..security.log_sanitizer import ( 

637 _redact_additional_shapes, 

638 sanitize_error_message, 

639 type_name_for_log, 

640 ) 

641 

642 # ``type`` is the original class unless the patcher had to replace it 

643 # (``security.log_sanitizer._record_type_is_safe``). Its name is read 

644 # through ``type``'s own descriptor (never a metaclass's ``__name__``) 

645 # and redacted, as in the patcher's rebuilt 

646 # ``RuntimeError("<TypeName>: ...")`` text. 

647 type_name = type_name_for_log(exc_type) 

648 if exc_value is None: 648 ↛ 649line 648 didn't jump to line 649 because the condition on line 648 was never true

649 return f"[{type_name}] " 

650 truncated = False 

651 try: 

652 value_text = str(exc_value) 

653 # The patcher renders an application exception as a rebuilt 

654 # ``RuntimeError("<TypeName>: ...")`` (``RuntimeError("<TypeName>")`` 

655 # when its text is empty) while ``type`` stays the original class; 

656 # drop the repeated name so the row does not read 

657 # ``[TypeName: TypeName: ...]`` or ``[TypeName: TypeName]``. 

658 repeated = "{}: ".format(type_name) 

659 if type(exc_value) is not exc_type: 

660 if value_text.startswith(repeated): 

661 value_text = value_text[len(repeated) :] 

662 elif value_text == type_name: 662 ↛ 664line 662 didn't jump to line 664 because the condition on line 662 was always true

663 value_text = "" 

664 if len(value_text) > 4096: 

665 value_text = value_text[:4096] 

666 # The credential shapes the log-record path adds on top of 

667 # sanitize_error_message (``Api-Key:`` headers, slash-bearing DSN 

668 # passwords, ``;``/``,`` credential parameters, quoted credential 

669 # keys) run on the 4096-bounded text BEFORE the 240-character cap: 

670 # a value the cap cuts below a pattern's minimum length (or a DSN 

671 # cut before its '@') would no longer be recognised, and its head 

672 # would print. This prefix goes into the DB row with no other 

673 # redaction pass after it. 

674 value_text = _redact_additional_shapes( 

675 sanitize_error_message(value_text) 

676 ) 

677 if len(value_text) > 240: 

678 # And once more on the capped text (before the ellipsis, which 

679 # an unterminated quoted value would otherwise take): these 

680 # patterns only ever replace part of their input with a 

681 # marker, so a second pass can hide more, never less. 

682 value_text = _redact_additional_shapes(value_text[:237]) 

683 truncated = True 

684 except Exception: 

685 value_text = "<unprintable exception>" 

686 if not value_text: 

687 return f"[{type_name}] " 

688 # The name and the text are each clean, but a credential pattern can 

689 # span the two (a class named ``InvalidAuthorization`` and a bare token 

690 # read as an ``Authorization:`` header): sanitize the joined line. 

691 line = _redact_additional_shapes( 

692 sanitize_error_message(f"{type_name}: {value_text}") 

693 ) 

694 cap = len(type_name) + 2 + 240 

695 if len(line) > cap - int(truncated): 695 ↛ 696line 695 didn't jump to line 696 because the condition on line 695 was never true

696 line = _redact_additional_shapes(line[: cap - 1]) 

697 truncated = True 

698 if truncated: 

699 line += "…" 

700 return f"[{line}] " 

701 

702 

703def _truncate_for_frontend(message: str) -> str: 

704 """Bound the wire size of an outbound log message. 

705 

706 ``FRONTEND_MESSAGE_MAX_LENGTH`` caps the *preserved prefix* of the 

707 original message. When truncation kicks in, a short indicator is 

708 appended that names the original length and points the user at the 

709 server-side logs, which keep up to 32 KiB of it, so the returned string is 

710 ``FRONTEND_MESSAGE_MAX_LENGTH`` plus the fixed indicator overhead 

711 (~100 bytes). Verbose diagnostic / agent logs (model outputs, long 

712 tracebacks, extracted text) are useless in the UI when displayed in 

713 full and inflate socket payloads + client-side memory; 

714 container-log/stderr, file, and DB sinks do not apply this cap. 

715 """ 

716 if len(message) <= FRONTEND_MESSAGE_MAX_LENGTH: 

717 return message 

718 return message[:FRONTEND_MESSAGE_MAX_LENGTH] + _truncation_suffix(message) 

719 

720 

721def frontend_progress_sink(message: loguru.Message) -> None: 

722 """ 

723 Sink that sends messages to the frontend. 

724 

725 Args: 

726 message: The log message to send. 

727 

728 """ 

729 record = message.record 

730 research_id = _get_research_id(record) 

731 if research_id is None: 

732 # If we don't have a research ID, don't send anything. 

733 # Can't use logger here as it causes deadlock 

734 return 

735 

736 # Defence in depth (R4-09): never forward policy-audit log lines 

737 # to WebSocket subscribers. They carry engine names + reason codes 

738 # which could leak the active scope to a cross-origin observer under 

739 # CORS=*. Today policy_audit logs don't bind research_id so the 

740 # research_id guard above already skips them; this filter is the 

741 # explicit guarantee in case a future call site binds both. 

742 if record.get("extra", {}).get("policy_audit"): 

743 return 

744 

745 # Whose research this is. Subscriptions are keyed by (owner, research_id) 

746 # because a benchmark id is only unique within one user's database, so an 

747 # emit without the owner reaches nobody. Resolved the same way 

748 # ``database_sink`` resolves it: the bound ``username`` extra first, then 

749 # the per-thread research context set when the research thread started. 

750 username = record.get("extra", {}).get("username") 

751 if not username: 

752 ctx = _get_research_context_fallback() 

753 if ctx: 753 ↛ 754line 753 didn't jump to line 754 because the condition on line 753 was never true

754 username = ctx.get("username") 

755 if not username: 

756 # Same third source as database_sink; see the note there. 

757 username = _get_request_username() 

758 if not username: 

759 # Nothing to scope the emit to. Dropping is the fail-closed choice: 

760 # the alternative (emit to every subscriber of this id) is exactly 

761 # the cross-user leak the keying exists to prevent. 

762 return 

763 

764 frontend_log = { 

765 "log_entry": { 

766 "message": _truncate_for_frontend(record["message"]), 

767 "type": record["level"].name, # Keep original case 

768 "time": record["time"].isoformat(), 

769 }, 

770 } 

771 emit_to_subscribers( 

772 "research_progress", 

773 research_id, 

774 frontend_log, 

775 owner=username, 

776 enable_logging=False, 

777 ) 

778 

779 

780def flush_log_queue(): 

781 """Drain all pending logs from the queue to the database. 

782 

783 Called from the FastAPI lifespan shutdown (see ``fastapi_app.py``) 

784 to flush whatever the background daemon hadn't written before the 

785 DB was closed. The request path doesn't call this — the 

786 ``start_log_queue_processor`` daemon handles steady-state drainage 

787 so requests never block on DB writes. 

788 """ 

789 flushed = 0 

790 while not _log_queue.empty(): 

791 try: 

792 log_entry = _log_queue.get_nowait() 

793 _write_log_to_database(log_entry) 

794 flushed += 1 

795 except queue.Empty: 

796 break 

797 except Exception: 

798 pass # noqa: silent-exception — DB errors during log flush must not propagate 

799 

800 if flushed > 0: 

801 logger.debug(f"Flushed {flushed} queued log entries to database") 

802 

803 

804def start_log_queue_processor(app=None) -> threading.Thread: 

805 """Start the background daemon that drains the log queue into the DB. 

806 

807 Idempotent: calling twice is a no-op (returns the existing thread). 

808 Under FastAPI there's no Flask app context to push, so the daemon 

809 just runs ``_process_log_queue`` directly. The ``app`` parameter is 

810 accepted for backward compat with any legacy Flask call sites that 

811 still pass it; it is otherwise ignored. 

812 

813 Returns: 

814 The daemon thread (running). 

815 """ 

816 global _queue_processor_thread 

817 with _queue_processor_lock: 

818 if ( 

819 _queue_processor_thread is not None 

820 and _queue_processor_thread.is_alive() 

821 ): 

822 return _queue_processor_thread 

823 

824 _stop_queue.clear() 

825 _queue_processor_thread = threading.Thread( 

826 target=_process_log_queue, 

827 name="log-queue-processor", 

828 daemon=True, 

829 ) 

830 _queue_processor_thread.start() 

831 thread = _queue_processor_thread 

832 logger.info("Log queue processor daemon started") 

833 return thread 

834 

835 

836def stop_log_queue_processor(timeout: float = 2.0) -> None: 

837 """Signal the log queue processor to stop and wait briefly for it.""" 

838 global _queue_processor_thread 

839 _stop_queue.set() 

840 with _queue_processor_lock: 

841 thread = _queue_processor_thread 

842 if thread is not None: 

843 thread.join(timeout=timeout) 

844 # Only clear the reference if the thread actually exited. If join 

845 # timed out the daemon is still running, and clearing the ref would 

846 # let a subsequent start_log_queue_processor() spawn a second 

847 # daemon that drains the same queue concurrently. Re-check identity 

848 # under the lock so we don't accidentally null out a fresh thread 

849 # that another start spawned in the meantime. 

850 if not thread.is_alive(): 

851 with _queue_processor_lock: 

852 if _queue_processor_thread is thread: 852 ↛ exitline 852 didn't jump to the function exit

853 _queue_processor_thread = None 

854 

855 

856def _sanitize_record(record) -> None: 

857 """Global loguru patcher: scrub every record before it reaches a sink. 

858 

859 Runs once per record, ahead of ALL sinks (stderr, the encrypted per-user 

860 DB, the browser progress stream, and the persistent, unencrypted 

861 ``<LDR_DATA_DIR>/logs/*.log`` file), so it is the single chokepoint that 

862 protects every sink regardless of per-call-site logging discipline: 

863 

864 * Control / format characters are stripped from the message (prevents log 

865 forging via CR/LF, RLO, zero-width, etc.). 

866 * Credential *shapes* and bare ``field=value`` secrets are redacted from 

867 the message — the sink-level backstop for any call site that logged a 

868 credential without going through ``scrub_error`` first. 

869 * Structured ``extra`` / ``logger.bind(...)`` values are redacted by 

870 sensitive key name AND by embedded credential shape, catching the 

871 recurring family bug of a secret carried as a bound structured value 

872 (a token/URL nested under a key the name-based checks miss). 

873 * The *rendered* exception graph (value, notes, chained and grouped 

874 exceptions) is scrubbed too, as a rebuilt copy, so a credential 

875 embedded in an exception's string does not render verbatim to the 

876 default-format stderr / file sinks — the message scrub alone never 

877 reaches it. 

878 

879 All of this is ``security.log_sanitizer.redact_log_record``, which the 

880 MCP subprocess installs as its own patcher too (``mcp/server.py``'s 

881 ``configure_mcp_logging``); it fails closed per field. It is imported 

882 lazily (never at this module's import time): importing ``..security`` 

883 runs ``install_audit_hook()``, and ``log_utils`` must stay importable 

884 before the security layer is wired. 

885 """ 

886 from ..security.log_sanitizer import redact_log_record 

887 

888 redact_log_record(record) 

889 

890 

891def install_log_redaction() -> None: 

892 """Install the shared credential-redaction patcher without adding sinks. 

893 

894 Entry points that configure their own sinks, such as the benchmark CLI, 

895 use this before enabling their package logger. Importing this module 

896 alone does not change the host process's Loguru configuration. 

897 """ 

898 logger.configure(patcher=_sanitize_record) 

899 

900 

901def config_logger(name: str, debug: bool = False) -> None: 

902 """ 

903 Configures the default logger. 

904 

905 Args: 

906 name: The name to use for the log file. 

907 debug: Whether to enable unsafe debug logging. 

908 

909 """ 

910 install_log_redaction() 

911 

912 logger.enable("local_deep_research") 

913 logger.remove() 

914 

915 # Log to console (stderr), database, and frontend (Socket.IO). 

916 # All three sinks track the stderr level — sending DEBUG logs to the 

917 # DB and to every connected Socket.IO client in production would flood 

918 # storage and the network with high-frequency internal events. 

919 # `diagnose=False` on the DB/frontend sinks: loguru's `diagnose=True` 

920 # dumps local variables on exceptions, which can contain request 

921 # bodies, session data, or DB rows — we don't want those leaving the 

922 # stderr sink. 

923 sink_level = "DEBUG" if debug else "INFO" 

924 

925 # loguru's diagnose=True renders repr() of every local variable in every 

926 # traceback frame on exceptions. Under LDR_APP_DEBUG that would dump 

927 # credentials living in frame locals (api_key, SQLCipher password, 

928 # Authorization headers) into the stderr sink. Gate diagnose behind a 

929 # separate explicit opt-in so enabling LDR_APP_DEBUG for general debug 

930 # output does not also enable localvar dumps. Default OFF even when debug 

931 # is on, and never enabled on the DB/frontend sinks. 

932 # 

933 # security/secure_logging.py gates provider/engine exception tracebacks 

934 # on the same two flags (its is_diagnose_mode()). Deliberate divergence: 

935 # the wrapper reads LDR_APP_DEBUG from the environment only, while the 

936 # ``debug`` argument here may also come from the app.debug DB setting / 

937 # legacy server_config.json — so DB-enabled debug affects this sink's 

938 # diagnose but never the wrapper's traceback gate. Kept inline (not 

939 # env_truthy) because log_utils must not import security at module 

940 # level: security/__init__.py runs install_audit_hook() on import. 

941 diagnose = debug and os.environ.get( 

942 "LDR_LOGURU_DIAGNOSE", "" 

943 ).strip().lower() in ("1", "true", "yes") 

944 

945 # ``diagnose`` renders the repr() of every frame-local — which can 

946 # include the SQLCipher master password and other credentials — into 

947 # the rendered exception block. Allow it ONLY on the ephemeral, local 

948 # stderr sink the operator explicitly opted into. The database sink 

949 # PERSISTS into the user's own encrypted DB and the frontend sink 

950 # SHIPS to the browser, so they must NEVER render frame locals, even 

951 # under LDR_LOGURU_DIAGNOSE. This single chokepoint protects every 

952 # credential-bearing exception handler app-wide against the 

953 # frame-locals leak, independent of per-site logging discipline 

954 # (#4182). 

955 # 

956 # enqueue=True on stderr: loguru hands the formatted record to a 

957 # multiprocessing.SimpleQueue and a dedicated background thread does the 

958 # actual stderr write, decoupling a log call from stderr I/O for up to 

959 # one queue pipe buffer (~64KB on Linux) rather than never blocking. 

960 # Without it, when stderr back-pressures (e.g. a slow/full `docker logs` 

961 # pipe in CI) the lock-holder blocks mid-write and ALL logging threads 

962 # pile up behind the lock, freezing the request pipeline for ~60s 

963 # (#4431). Captured forensically: 3/5 server threads parked in loguru's 

964 # _protected_lock under load. This is a bounded grace window, not 

965 # immunity: SimpleQueue.put() still runs inside Handler.emit's 

966 # _protected_lock, and its OS-pipe send_bytes() blocks once that buffer 

967 # fills — e.g. if the background writer thread itself stalls on a 

968 # wedged stderr long enough to drain nothing, reproducing the same 

969 # pile-up (loguru/_handler.py Handler.emit / _queued_writer, 

970 # multiprocessing/queues.py SimpleQueue.put). The database/progress 

971 # sinks keep their own emitting-thread context capture and are left 

972 # synchronous. 

973 logger.add(sys.stderr, level=sink_level, diagnose=diagnose, enqueue=True) 

974 logger.add(database_sink, level=sink_level, diagnose=False) 

975 logger.add(frontend_progress_sink, level=sink_level, diagnose=False) 

976 

977 if debug: 

978 logger.warning( 

979 "DEBUG logging is enabled (LDR_APP_DEBUG=true). " 

980 "Logs may contain sensitive data (queries, answers, API responses). " 

981 "Do NOT use in production." 

982 ) 

983 

984 if diagnose: 

985 logger.warning( 

986 "LDR_LOGURU_DIAGNOSE is enabled: exception tracebacks will include " 

987 "local variable values, which may contain credentials (API keys, " 

988 "passwords, tokens). Do NOT use in production." 

989 ) 

990 

991 # Optionally log to file if enabled (disabled by default for security) 

992 # Check environment variable first, then database setting 

993 enable_file_logging = ( 

994 os.environ.get("LDR_ENABLE_FILE_LOGGING", "").lower() == "true" 

995 ) 

996 

997 # File logging is controlled only by environment variable for simplicity 

998 # Database settings are not available at logger initialization time 

999 

1000 if enable_file_logging: 

1001 log_file = _LOG_DIR / f"{name}.log" 

1002 logger.add( 

1003 log_file, 

1004 level="DEBUG", 

1005 rotation="10 MB", 

1006 retention="7 days", 

1007 compression="zip", 

1008 # diagnose=False: the file sink is persistent and unencrypted, 

1009 # so — like the DB and frontend sinks — it must never render 

1010 # frame-local credentials, even under LDR_LOGURU_DIAGNOSE. 

1011 # Frame-local dumps go only to the ephemeral stderr sink, which 

1012 # is what the policy comment above describes (#4182). 

1013 diagnose=False, 

1014 ) 

1015 logger.warning( 

1016 f"File logging enabled - logs will be written to {log_file}. " 

1017 "WARNING: Log files are unencrypted and may contain sensitive data!" 

1018 ) 

1019 

1020 # Add a special log level for milestones. Also registered at import 

1021 # (see _register_milestone_level) so processes that never call this 

1022 # function still have it; kept here for the re-entrant case. 

1023 _register_milestone_level()