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

112 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 ..database.models import SearchCall 

13from .database import MetricsDatabase 

14from .query_utils import get_research_mode_condition, get_time_filter_condition 

15 

16 

17class SearchTracker: 

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

19 

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

21 """Initialize the search tracker.""" 

22 self.db = db or MetricsDatabase() 

23 

24 @staticmethod 

25 def record_search( 

26 engine_name: str, 

27 query: str, 

28 results_count: int = 0, 

29 response_time_ms: int = 0, 

30 success: bool = True, 

31 error_message: Optional[str] = None, 

32 ) -> None: 

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

34 password = None 

35 

36 # Extract research context (thread-safe) 

37 context = get_search_context() 

38 

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

40 if context is None: 

41 logger.warning( 

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

43 "(likely in programmatic mode)" 

44 ) 

45 return 

46 

47 research_id = context.get("research_id") 

48 

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

50 if isinstance(research_id, int): 

51 research_id = str(research_id) 

52 research_query = context.get("research_query") 

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

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

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

56 

57 # Determine success status 

58 success_status = "success" if success else "error" 

59 error_type = None 

60 if error_message: 

61 error_type = ( 

62 type(error_message).__name__ 

63 if isinstance(error_message, Exception) 

64 else "unknown_error" 

65 ) 

66 

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

68 try: 

69 # Get username from context for thread-safe database 

70 username = context.get("username") 

71 if not username: 

72 logger.warning( 

73 f"Cannot save search metrics - no username in research context. " 

74 f"Search: {engine_name} for '{query}'" 

75 ) 

76 return 

77 

78 # Get password from context 

79 password = context.get("user_password") 

80 if not password: 

81 logger.warning( 

82 f"Cannot save search metrics - no password in research context. " 

83 f"Search: {engine_name} for '{query}', username: {username}" 

84 ) 

85 return 

86 

87 # Use thread-safe metrics writer 

88 from ..database.thread_metrics import metrics_writer 

89 

90 try: 

91 # Set password for this thread 

92 metrics_writer.set_user_password(username, password) 

93 

94 with metrics_writer.get_session(username) as session: 

95 search_call = SearchCall( 

96 research_id=research_id, 

97 research_query=research_query, 

98 research_mode=research_mode, 

99 research_phase=research_phase, 

100 search_iteration=search_iteration, 

101 search_engine=engine_name, 

102 query=query, 

103 results_count=results_count, 

104 response_time_ms=response_time_ms, 

105 success_status=success_status, 

106 error_type=error_type, 

107 error_message=str(error_message) 

108 if error_message 

109 else None, 

110 ) 

111 session.add(search_call) 

112 

113 logger.debug( 

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

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

116 ) 

117 except Exception as e: 

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

119 from ..security.log_sanitizer import scrub_error 

120 

121 safe_msg = scrub_error(e, password) 

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

123 

124 except Exception as e: 

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

126 from ..security.log_sanitizer import scrub_error 

127 

128 safe_msg = scrub_error(e, password) 

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

130 

131 def get_search_metrics( 

132 self, 

133 period: str = "30d", 

134 research_mode: str = "all", 

135 username: Optional[str] = None, 

136 password: Optional[str] = None, 

137 ) -> Dict[str, Any]: 

138 """Get search engine usage metrics.""" 

139 with self.db.get_session( 

140 username=username, password=password 

141 ) as session: 

142 try: 

143 # Build base query with filters 

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

145 SearchCall.search_engine.isnot(None) 

146 ) 

147 

148 # Apply time filter 

149 time_condition = get_time_filter_condition( 

150 period, SearchCall.timestamp 

151 ) 

152 if time_condition is not None: 

153 query = query.filter(time_condition) 

154 

155 # Apply research mode filter 

156 mode_condition = get_research_mode_condition( 

157 research_mode, SearchCall.research_mode 

158 ) 

159 if mode_condition is not None: 

160 query = query.filter(mode_condition) 

161 

162 # Get search engine statistics using ORM aggregation 

163 search_stats = session.query( 

164 SearchCall.search_engine, 

165 func.count().label("call_count"), 

166 func.avg(SearchCall.response_time_ms).label( 

167 "avg_response_time" 

168 ), 

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

170 func.avg(SearchCall.results_count).label( 

171 "avg_results_per_call" 

172 ), 

173 func.sum( 

174 case( 

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

176 ) 

177 ).label("success_count"), 

178 func.sum( 

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

180 ).label("error_count"), 

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

182 

183 # Apply same filters to stats query 

184 if time_condition is not None: 

185 search_stats = search_stats.filter(time_condition) 

186 if mode_condition is not None: 

187 search_stats = search_stats.filter(mode_condition) 

188 

189 search_stats = ( 

190 search_stats.group_by(SearchCall.search_engine) 

191 .order_by(func.count().desc()) 

192 .all() 

193 ) 

194 

195 # Get recent search calls 

196 recent_calls_query = session.query(SearchCall) 

197 if time_condition is not None: 

198 recent_calls_query = recent_calls_query.filter( 

199 time_condition 

200 ) 

201 if mode_condition is not None: 

202 recent_calls_query = recent_calls_query.filter( 

203 mode_condition 

204 ) 

205 

206 recent_calls = ( 

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

208 .limit(20) 

209 .all() 

210 ) 

211 

212 return { 

213 "search_engine_stats": [ 

214 { 

215 "engine": stat.search_engine, 

216 "call_count": stat.call_count, 

217 "avg_response_time": stat.avg_response_time or 0, 

218 "total_results": stat.total_results or 0, 

219 "avg_results_per_call": stat.avg_results_per_call 

220 or 0, 

221 "success_rate": ( 

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

223 if stat.call_count > 0 

224 else 0 

225 ), 

226 "error_count": stat.error_count or 0, 

227 } 

228 for stat in search_stats 

229 ], 

230 "recent_calls": [ 

231 { 

232 "engine": call.search_engine, 

233 "query": ( 

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

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

236 else call.query 

237 ), 

238 "results_count": call.results_count, 

239 "response_time_ms": call.response_time_ms, 

240 "success_status": call.success_status, 

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

242 } 

243 for call in recent_calls 

244 ], 

245 } 

246 

247 except Exception: 

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

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

250 

251 def get_research_search_metrics( 

252 self, 

253 research_id: str, 

254 username: Optional[str] = None, 

255 password: Optional[str] = None, 

256 ) -> Dict[str, Any]: 

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

258 with self.db.get_session( 

259 username=username, password=password 

260 ) as session: 

261 try: 

262 # Get all search calls for this research 

263 search_calls = ( 

264 session.query(SearchCall) 

265 .filter(SearchCall.research_id == research_id) 

266 .order_by(SearchCall.timestamp.asc()) 

267 .all() 

268 ) 

269 

270 # Get search engine stats for this research 

271 engine_stats = ( 

272 session.query( 

273 SearchCall.search_engine, 

274 func.count().label("call_count"), 

275 func.avg(SearchCall.response_time_ms).label( 

276 "avg_response_time" 

277 ), 

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

279 "total_results" 

280 ), 

281 func.sum( 

282 case( 

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

284 else_=0, 

285 ) 

286 ).label("success_count"), 

287 ) 

288 .filter(SearchCall.research_id == research_id) 

289 .group_by(SearchCall.search_engine) 

290 .order_by(func.count().desc()) 

291 .all() 

292 ) 

293 

294 # Calculate totals 

295 total_searches = len(search_calls) 

296 total_results = sum( 

297 call.results_count or 0 for call in search_calls 

298 ) 

299 avg_response_time = ( 

300 sum(call.response_time_ms or 0 for call in search_calls) 

301 / total_searches 

302 if total_searches > 0 

303 else 0 

304 ) 

305 successful_searches = sum( 

306 1 

307 for call in search_calls 

308 if call.success_status == "success" 

309 ) 

310 success_rate = ( 

311 (successful_searches / total_searches * 100) 

312 if total_searches > 0 

313 else 0 

314 ) 

315 

316 return { 

317 "total_searches": total_searches, 

318 "total_results": total_results, 

319 "avg_response_time": round(avg_response_time), 

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

321 "search_calls": [ 

322 { 

323 "engine": call.search_engine, 

324 "query": call.query, 

325 "results_count": call.results_count, 

326 "response_time_ms": call.response_time_ms, 

327 "success_status": call.success_status, 

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

329 } 

330 for call in search_calls 

331 ], 

332 "engine_stats": [ 

333 { 

334 "engine": stat.search_engine, 

335 "call_count": stat.call_count, 

336 "avg_response_time": stat.avg_response_time or 0, 

337 "total_results": stat.total_results or 0, 

338 "success_rate": ( 

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

340 if stat.call_count > 0 

341 else 0 

342 ), 

343 } 

344 for stat in engine_stats 

345 ], 

346 } 

347 

348 except Exception: 

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

350 return { 

351 "total_searches": 0, 

352 "total_results": 0, 

353 "avg_response_time": 0, 

354 "success_rate": 0, 

355 "search_calls": [], 

356 "engine_stats": [], 

357 } 

358 

359 def get_search_time_series( 

360 self, 

361 period: str = "30d", 

362 research_mode: str = "all", 

363 username: Optional[str] = None, 

364 password: Optional[str] = None, 

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

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

367 

368 Args: 

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

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

371 username: Username for database access 

372 password: Password for database access 

373 

374 Returns: 

375 List of time series data points with search engine activity 

376 """ 

377 with self.db.get_session( 

378 username=username, password=password 

379 ) as session: 

380 try: 

381 # Build base query 

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

383 SearchCall.search_engine.isnot(None), 

384 SearchCall.timestamp.isnot(None), 

385 ) 

386 

387 # Apply time filter 

388 time_condition = get_time_filter_condition( 

389 period, SearchCall.timestamp 

390 ) 

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

392 query = query.filter(time_condition) 

393 

394 # Apply research mode filter 

395 mode_condition = get_research_mode_condition( 

396 research_mode, SearchCall.research_mode 

397 ) 

398 if mode_condition is not None: 

399 query = query.filter(mode_condition) 

400 

401 # Get all search calls ordered by time 

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

403 

404 # Create time series data 

405 time_series = [] 

406 for call in search_calls: 

407 time_series.append( 

408 { 

409 "timestamp": ( 

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

411 ), 

412 "search_engine": call.search_engine, 

413 "results_count": call.results_count or 0, 

414 "response_time_ms": call.response_time_ms or 0, 

415 "success_status": call.success_status, 

416 "query": ( 

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

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

419 else call.query 

420 ), 

421 } 

422 ) 

423 

424 return time_series 

425 

426 except Exception: 

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

428 return [] 

429 

430 

431def get_search_tracker() -> SearchTracker: 

432 """Create a SearchTracker instance. 

433 

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

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

436 """ 

437 return SearchTracker()