Coverage for src/local_deep_research/metrics/search_tracker.py: 97%

153 statements  

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

1""" 

2Search call tracking system for metrics collection. 

3Similar to token_counter.py but tracks search engine usage. 

4""" 

5 

6from typing import Any, Dict, List, Optional 

7 

8from loguru import logger 

9from sqlalchemy import case, func 

10 

11from ..utilities.thread_context import get_search_context 

12from ..utilities.metric_write_health import ( 

13 MetricWriteReason, 

14 record_metric_write_drop, 

15) 

16from ..database.models import SearchCall 

17from .database import MetricsDatabase 

18from .query_utils import get_research_mode_condition, get_time_filter_condition 

19 

20# ``success_status`` of a search that was not sent because its engine was 

21# disabled or cooling down for the research (see engine_availability). 

22# Recorded so outages stay visible, but not counted as a search attempt: 

23# it has no response time, and it is excluded from call counts, averages 

24# and success rates. 

25SEARCH_STATUS_SKIPPED = "skipped_unavailable" 

26 

27 

28def _session_has_pending_orm_state(session: Any) -> bool: 

29 """True if *session* has unflushed ORM work ``begin_nested()`` would flush. 

30 

31 ``Session.begin_nested()`` flushes ``new``/``dirty``/``deleted`` before 

32 CREATE SAVEPOINT. A flush failure (SQLite writer lock held elsewhere) 

33 invalidates the caller's transaction; catching it cannot restore 

34 ``session.is_active``. Best-effort telemetry must skip in that case. 

35 """ 

36 try: 

37 for attr in ("new", "dirty", "deleted"): 

38 collection = getattr(session, attr, None) 

39 if collection and len(collection) > 0: 

40 return True 

41 except Exception: 

42 return False 

43 return False 

44 

45 

46class SearchTracker: 

47 """Track search engine calls and performance metrics.""" 

48 

49 def __init__(self, db: Optional[MetricsDatabase] = None): 

50 """Initialize the search tracker.""" 

51 self.db = db or MetricsDatabase() 

52 

53 @staticmethod 

54 def record_search( 

55 engine_name: str, 

56 query: str, 

57 results_count: int = 0, 

58 response_time_ms: int = 0, 

59 success: bool = True, 

60 error_message: Optional[str] = None, 

61 status: Optional[str] = None, 

62 error_type: Optional[str] = None, 

63 ) -> None: 

64 """Record a completed search operation directly to database. 

65 

66 ``status`` overrides the success/error status, e.g. 

67 ``SEARCH_STATUS_SKIPPED`` for a search skipped because its engine 

68 was unavailable; ``error_message`` then carries the reason. 

69 Pass ``error_type`` separately when the exception message has been 

70 sanitized. Plain text alone cannot identify an exception class. 

71 """ 

72 password = None 

73 

74 # Extract research context (thread-safe) 

75 context = get_search_context() 

76 

77 # Skip metrics recording in programmatic mode or when no context is set 

78 if context is None: 

79 logger.warning( 

80 "Skipping search metrics recording - no research context available " 

81 "(likely in programmatic mode)" 

82 ) 

83 return 

84 

85 research_id = context.get("research_id") 

86 

87 # Convert research_id to string if it's an integer (for backward compatibility) 

88 if isinstance(research_id, int): 

89 research_id = str(research_id) 

90 research_query = context.get("research_query") 

91 research_mode = context.get("research_mode", "unknown") 

92 research_phase = context.get("research_phase", "search") 

93 search_iteration = context.get("search_iteration", 0) 

94 

95 # Determine success status 

96 success_status = status or ("success" if success else "error") 

97 if status == SEARCH_STATUS_SKIPPED: 

98 error_type = "engine_unavailable" 

99 elif error_message and not error_type: 

100 error_type = ( 

101 type(error_message).__name__ 

102 if isinstance(error_message, Exception) 

103 else "unknown_error" 

104 ) 

105 

106 # Record search call in database - only from background threads 

107 username = None 

108 dropped_reason: MetricWriteReason | None = None 

109 try: 

110 # Get username from context for thread-safe database 

111 username = context.get("username") 

112 if not username: 

113 logger.warning( 

114 "Cannot save search metrics - no username in research " 

115 f"context. Search engine: {engine_name}" 

116 ) 

117 return 

118 

119 # Get password from context 

120 password = context.get("user_password") 

121 if not password: 

122 dropped_reason = "missing_credentials" 

123 logger.warning( 

124 "Cannot save search metrics - no password in research " 

125 f"context. Search engine: {engine_name}, " 

126 f"username: {username}" 

127 ) 

128 return 

129 

130 try: 

131 

132 def _write_search(session: Any) -> None: 

133 search_call = SearchCall( 

134 research_id=research_id, 

135 research_query=research_query, 

136 research_mode=research_mode, 

137 research_phase=research_phase, 

138 search_iteration=search_iteration, 

139 search_engine=engine_name, 

140 query=query, 

141 results_count=results_count, 

142 response_time_ms=response_time_ms, 

143 success=int(success_status == "success"), 

144 success_status=success_status, 

145 error_type=error_type, 

146 error_message=str(error_message) 

147 if error_message 

148 else None, 

149 ) 

150 session.add(search_call) 

151 

152 logger.debug( 

153 f"Search call recorded to encrypted DB: {engine_name} - " 

154 f"{results_count} results in {response_time_ms}ms" 

155 ) 

156 

157 # Two paths, because get_user_db_session does not commit 

158 # on exit and metrics_writer.get_session() is a second 

159 # QueuePool connection: 

160 # 

161 # * in_scope(): reuse the thread-local session and write 

162 # inside a SAVEPOINT (telemetry_savepoint()). A nested 

163 # transaction isolates metrics failures from the caller's 

164 # session (preventing the caller's staged work from being 

165 # rolled back) and never calls session.commit(). The row 

166 # joins the caller's open write transaction, or commits 

167 # at once on its own when the scope has not written. 

168 # Session.begin_nested() flushes ALL pending ORM state 

169 # before CREATE SAVEPOINT. If that flush fails (e.g. 

170 # another connection holds SQLite's writer lock), 

171 # SQLAlchemy invalidates the caller's transaction and 

172 # catching the exception cannot restore it. Best-effort 

173 # telemetry therefore skips when the caller already has 

174 # unflushed work — the write cannot be isolated. 

175 # * otherwise: metrics_writer commits+closes its own 

176 # session. A thread-local write here would be rolled 

177 # back by the next get_session() at scope depth 0. 

178 from ..database.thread_local_session import ( 

179 thread_session_manager, 

180 ) 

181 

182 if thread_session_manager.in_scope(): 

183 from ..database.session_context import ( 

184 telemetry_savepoint, 

185 ) 

186 

187 # Borrow directly: session provisioning can replace a 

188 # different owner's session or recover a failed one by 

189 # rolling back the caller's transaction. 

190 session = thread_session_manager.get_scoped_session( 

191 username 

192 ) 

193 if session is None: 

194 dropped_reason = "database_unavailable" 

195 # Unlike rollback-coupled loss, an owner mismatch is 

196 # an anomaly worth surfacing (as rate telemetry does). 

197 logger.warning( 

198 "Skipping in-scope search metrics without a " 

199 "matching owner session" 

200 ) 

201 return 

202 if _session_has_pending_orm_state(session): 

203 dropped_reason = "pending_transaction" 

204 logger.debug( 

205 "Skipping in-scope search metrics write; " 

206 "caller session has pending ORM state " 

207 "that Session.begin_nested() would flush " 

208 "before creating a SAVEPOINT" 

209 ) 

210 return 

211 with telemetry_savepoint(session): 

212 _write_search(session) 

213 else: 

214 from ..database.thread_metrics import metrics_writer 

215 

216 # Set password for this thread 

217 metrics_writer.set_user_password(username, password) 

218 

219 with metrics_writer.get_session(username) as session: 

220 _write_search(session) 

221 except Exception as e: 

222 dropped_reason = "write_error" 

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

224 from ..security.log_sanitizer import scrub_error 

225 

226 safe_msg = scrub_error(e, password) 

227 logger.warning(f"Failed to write search metrics: {safe_msg}") 

228 

229 except Exception as e: 

230 dropped_reason = "write_error" 

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

232 from ..security.log_sanitizer import scrub_error 

233 

234 safe_msg = scrub_error(e, password) 

235 logger.warning(f"Failed to record search call: {safe_msg}") 

236 finally: 

237 # A write and its context-manager cleanup can both fail. Count 

238 # this recording attempt once after all nested handlers finish. 

239 if dropped_reason is not None: 

240 record_metric_write_drop(username, "search", dropped_reason) 

241 

242 def get_search_metrics( 

243 self, 

244 period: str = "30d", 

245 research_mode: str = "all", 

246 username: Optional[str] = None, 

247 password: Optional[str] = None, 

248 ) -> Dict[str, Any]: 

249 """Get search engine usage metrics.""" 

250 with self.db.get_session( 

251 username=username, password=password 

252 ) as session: 

253 try: 

254 # Build base query with filters 

255 query = session.query(SearchCall).filter( 

256 SearchCall.search_engine.isnot(None) 

257 ) 

258 

259 # Apply time filter 

260 time_condition = get_time_filter_condition( 

261 period, SearchCall.timestamp 

262 ) 

263 if time_condition is not None: 

264 query = query.filter(time_condition) 

265 

266 # Apply research mode filter 

267 mode_condition = get_research_mode_condition( 

268 research_mode, SearchCall.research_mode 

269 ) 

270 if mode_condition is not None: 

271 query = query.filter(mode_condition) 

272 

273 # Get search engine statistics using ORM aggregation 

274 skipped = SearchCall.success_status == SEARCH_STATUS_SKIPPED 

275 search_stats = session.query( 

276 SearchCall.search_engine, 

277 func.sum(case((skipped, 0), else_=1)).label("call_count"), 

278 func.avg( 

279 case((skipped, None), else_=SearchCall.response_time_ms) 

280 ).label("avg_response_time"), 

281 func.sum(SearchCall.results_count).label("total_results"), 

282 func.avg( 

283 case((skipped, None), else_=SearchCall.results_count) 

284 ).label("avg_results_per_call"), 

285 func.sum( 

286 case( 

287 (SearchCall.success_status == "success", 1), else_=0 

288 ) 

289 ).label("success_count"), 

290 func.sum( 

291 case((SearchCall.success_status == "error", 1), else_=0) 

292 ).label("error_count"), 

293 func.sum(case((skipped, 1), else_=0)).label( 

294 "skipped_count" 

295 ), 

296 ).filter(SearchCall.search_engine.isnot(None)) 

297 

298 # Apply same filters to stats query 

299 if time_condition is not None: 

300 search_stats = search_stats.filter(time_condition) 

301 if mode_condition is not None: 

302 search_stats = search_stats.filter(mode_condition) 

303 

304 search_stats = ( 

305 search_stats.group_by(SearchCall.search_engine) 

306 .order_by(func.count().desc()) 

307 .all() 

308 ) 

309 

310 # Get recent search calls 

311 recent_calls_query = session.query(SearchCall) 

312 if time_condition is not None: 

313 recent_calls_query = recent_calls_query.filter( 

314 time_condition 

315 ) 

316 if mode_condition is not None: 

317 recent_calls_query = recent_calls_query.filter( 

318 mode_condition 

319 ) 

320 

321 recent_calls = ( 

322 recent_calls_query.order_by(SearchCall.timestamp.desc()) 

323 .limit(20) 

324 .all() 

325 ) 

326 

327 return { 

328 "search_engine_stats": [ 

329 { 

330 "engine": stat.search_engine, 

331 "call_count": stat.call_count, 

332 "avg_response_time": stat.avg_response_time or 0, 

333 "total_results": stat.total_results or 0, 

334 "avg_results_per_call": stat.avg_results_per_call 

335 or 0, 

336 "success_rate": ( 

337 (stat.success_count / stat.call_count * 100) 

338 if stat.call_count 

339 else 0 

340 ), 

341 "error_count": stat.error_count or 0, 

342 "skipped_count": stat.skipped_count or 0, 

343 } 

344 for stat in search_stats 

345 ], 

346 "recent_calls": [ 

347 { 

348 "engine": call.search_engine, 

349 "query": ( 

350 call.query[:100] + "..." 

351 if len(call.query or "") > 100 

352 else call.query 

353 ), 

354 "results_count": call.results_count, 

355 "response_time_ms": call.response_time_ms, 

356 "success_status": call.success_status, 

357 "timestamp": str(call.timestamp), 

358 } 

359 for call in recent_calls 

360 ], 

361 } 

362 

363 except Exception: 

364 logger.warning("Error getting search metrics") 

365 return {"search_engine_stats": [], "recent_calls": []} 

366 

367 def get_research_search_metrics( 

368 self, 

369 research_id: str, 

370 username: Optional[str] = None, 

371 password: Optional[str] = None, 

372 ) -> Dict[str, Any]: 

373 """Get search metrics for a specific research session.""" 

374 with self.db.get_session( 

375 username=username, password=password 

376 ) as session: 

377 try: 

378 # Get all search calls for this research 

379 search_calls = ( 

380 session.query(SearchCall) 

381 .filter(SearchCall.research_id == research_id) 

382 .order_by(SearchCall.timestamp.asc()) 

383 .all() 

384 ) 

385 

386 # Get search engine stats for this research 

387 skipped = SearchCall.success_status == SEARCH_STATUS_SKIPPED 

388 engine_stats = ( 

389 session.query( 

390 SearchCall.search_engine, 

391 func.sum(case((skipped, 0), else_=1)).label( 

392 "call_count" 

393 ), 

394 func.avg( 

395 case( 

396 (skipped, None), 

397 else_=SearchCall.response_time_ms, 

398 ) 

399 ).label("avg_response_time"), 

400 func.sum(SearchCall.results_count).label( 

401 "total_results" 

402 ), 

403 func.sum( 

404 case( 

405 (SearchCall.success_status == "success", 1), 

406 else_=0, 

407 ) 

408 ).label("success_count"), 

409 func.sum(case((skipped, 1), else_=0)).label( 

410 "skipped_count" 

411 ), 

412 ) 

413 .filter(SearchCall.research_id == research_id) 

414 .group_by(SearchCall.search_engine) 

415 .order_by(func.count().desc()) 

416 .all() 

417 ) 

418 

419 # Calculate totals; skipped searches were never sent. 

420 attempted = [ 

421 call 

422 for call in search_calls 

423 if call.success_status != SEARCH_STATUS_SKIPPED 

424 ] 

425 total_searches = len(attempted) 

426 skipped_searches = len(search_calls) - total_searches 

427 total_results = sum( 

428 call.results_count or 0 for call in search_calls 

429 ) 

430 avg_response_time = ( 

431 sum(call.response_time_ms or 0 for call in attempted) 

432 / total_searches 

433 if total_searches > 0 

434 else 0 

435 ) 

436 successful_searches = sum( 

437 1 for call in attempted if call.success_status == "success" 

438 ) 

439 success_rate = ( 

440 (successful_searches / total_searches * 100) 

441 if total_searches > 0 

442 else 0 

443 ) 

444 

445 return { 

446 "total_searches": total_searches, 

447 "skipped_searches": skipped_searches, 

448 "total_results": total_results, 

449 "avg_response_time": round(avg_response_time), 

450 "success_rate": round(success_rate, 1), 

451 "search_calls": [ 

452 { 

453 "engine": call.search_engine, 

454 "query": call.query, 

455 "results_count": call.results_count, 

456 "response_time_ms": call.response_time_ms, 

457 "success_status": call.success_status, 

458 "timestamp": str(call.timestamp), 

459 } 

460 for call in search_calls 

461 ], 

462 "engine_stats": [ 

463 { 

464 "engine": stat.search_engine, 

465 "call_count": stat.call_count, 

466 "avg_response_time": stat.avg_response_time or 0, 

467 "total_results": stat.total_results or 0, 

468 "success_rate": ( 

469 (stat.success_count / stat.call_count * 100) 

470 if stat.call_count 

471 else 0 

472 ), 

473 "skipped_count": stat.skipped_count or 0, 

474 } 

475 for stat in engine_stats 

476 ], 

477 } 

478 

479 except Exception: 

480 logger.warning("Error getting research search metrics") 

481 return { 

482 "total_searches": 0, 

483 "skipped_searches": 0, 

484 "total_results": 0, 

485 "avg_response_time": 0, 

486 "success_rate": 0, 

487 "search_calls": [], 

488 "engine_stats": [], 

489 } 

490 

491 def get_search_time_series( 

492 self, 

493 period: str = "30d", 

494 research_mode: str = "all", 

495 username: Optional[str] = None, 

496 password: Optional[str] = None, 

497 ) -> List[Dict[str, Any]]: 

498 """Get search activity time series data for charting. 

499 

500 Args: 

501 period: Time period to filter by ('7d', '30d', '3m', '1y', 'all') 

502 research_mode: Research mode to filter by ('quick', 'detailed', 'all') 

503 username: Username for database access 

504 password: Password for database access 

505 

506 Returns: 

507 List of time series data points with search engine activity 

508 """ 

509 with self.db.get_session( 

510 username=username, password=password 

511 ) as session: 

512 try: 

513 # Build base query 

514 query = session.query(SearchCall).filter( 

515 SearchCall.search_engine.isnot(None), 

516 SearchCall.timestamp.isnot(None), 

517 ) 

518 

519 # Apply time filter 

520 time_condition = get_time_filter_condition( 

521 period, SearchCall.timestamp 

522 ) 

523 if time_condition is not None: 523 ↛ 527line 523 didn't jump to line 527 because the condition on line 523 was always true

524 query = query.filter(time_condition) 

525 

526 # Apply research mode filter 

527 mode_condition = get_research_mode_condition( 

528 research_mode, SearchCall.research_mode 

529 ) 

530 if mode_condition is not None: 

531 query = query.filter(mode_condition) 

532 

533 # Get all search calls ordered by time 

534 search_calls = query.order_by(SearchCall.timestamp.asc()).all() 

535 

536 # Create time series data 

537 time_series = [] 

538 for call in search_calls: 

539 if call.success_status == SEARCH_STATUS_SKIPPED: 539 ↛ 541line 539 didn't jump to line 541 because the condition on line 539 was never true

540 # Not search activity: nothing was sent. 

541 continue 

542 time_series.append( 

543 { 

544 "timestamp": ( 

545 str(call.timestamp) if call.timestamp else None 

546 ), 

547 "search_engine": call.search_engine, 

548 "results_count": call.results_count or 0, 

549 "response_time_ms": call.response_time_ms or 0, 

550 "success_status": call.success_status, 

551 "query": ( 

552 call.query[:50] + "..." 

553 if call.query and len(call.query) > 50 

554 else call.query 

555 ), 

556 } 

557 ) 

558 

559 return time_series 

560 

561 except Exception: 

562 logger.warning("Error getting search time series") 

563 return [] 

564 

565 

566def get_search_tracker() -> SearchTracker: 

567 """Create a SearchTracker instance. 

568 

569 Returns a fresh instance each time. Callers should pass username/password 

570 to the query methods so the correct per-user database is accessed. 

571 """ 

572 return SearchTracker()