Coverage for src/local_deep_research/database/thread_metrics.py: 94%

61 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1""" 

2Thread-safe metrics database access. 

3 

4This module provides a way for background threads to write metrics 

5to the user's encrypted database by creating thread-local connections 

6with the provided password. 

7""" 

8 

9import threading 

10from contextlib import contextmanager 

11from typing import Optional 

12 

13from loguru import logger 

14from sqlalchemy.orm import Session 

15 

16from .encrypted_db import db_manager 

17 

18 

19class ThreadSafeMetricsWriter: 

20 """ 

21 Thread-safe writer for metrics to encrypted user databases. 

22 Creates encrypted connections per thread using provided passwords. 

23 """ 

24 

25 def __init__(self): 

26 self._thread_local = threading.local() 

27 

28 def set_user_password(self, username: str, password: str): 

29 """ 

30 Store user password for the current thread. 

31 This allows the thread to create its own encrypted connection. 

32 

33 IMPORTANT: This is safe because: 

34 1. Password is already in memory (user is logged in) 

35 2. It's only stored thread-locally 

36 3. It's explicitly cleared by `clear_passwords()` (invoked from 

37 `cleanup_current_thread()`) so pooled worker threads do not 

38 retain credentials between tasks. 

39 """ 

40 

41 if not hasattr(self._thread_local, "passwords"): 

42 self._thread_local.passwords = {} 

43 self._thread_local.passwords[username] = password 

44 

45 def clear_passwords(self): 

46 """Remove all passwords cached on the current thread's local store. 

47 

48 Called by `cleanup_current_thread()` so that pooled worker threads 

49 don't retain plaintext credentials between tasks. 

50 """ 

51 if hasattr(self._thread_local, "passwords"): 

52 self._thread_local.passwords.clear() 

53 

54 @contextmanager 

55 def get_session(self, username: str = None) -> Session: 

56 """ 

57 Get a database session for metrics in the current thread. 

58 Creates a new encrypted connection if needed. 

59 

60 Args: 

61 username: The username for database access. If not provided, 

62 will attempt to get it from Flask session. 

63 """ 

64 # If username not provided, try to get it from Flask session 

65 if username is None: 

66 try: 

67 from flask import session as flask_session 

68 from werkzeug.exceptions import Unauthorized 

69 

70 username = flask_session.get("username") 

71 if not username: 

72 raise Unauthorized("No username in Flask session") 

73 except (ImportError, RuntimeError) as e: 

74 # Flask context not available or no session 

75 raise ValueError(f"Cannot determine username: {e}") 

76 

77 # Get password for this user in this thread 

78 if not hasattr(self._thread_local, "passwords"): 

79 raise ValueError("No password set for thread metrics access") 

80 

81 password = self._thread_local.passwords.get(username) 

82 

83 if not password: 

84 raise ValueError( 

85 f"No password available for user {username} in this thread" 

86 ) 

87 

88 # Create a thread-safe session for this user 

89 session = None 

90 try: 

91 session = db_manager.create_thread_safe_session_for_metrics( 

92 username, password 

93 ) 

94 if not session: 

95 raise ValueError( # noqa: TRY301 — except does session rollback before re-raise 

96 f"Failed to create session for user {username}" 

97 ) 

98 yield session 

99 session.commit() 

100 except Exception: 

101 # ``logger.warning`` (no traceback) rather than 

102 # ``logger.exception``: ``password`` is a live local in this 

103 # frame, so rendering a traceback under ``diagnose=True`` 

104 # would dump the plaintext SQLCipher master password 

105 # (unrecoverable — TRUST.md §5). The exception still 

106 # propagates via the ``raise`` below, so no detail is 

107 # swallowed for the caller (#4182). 

108 logger.warning(f"Session error for {username}") 

109 if session: 

110 session.rollback() 

111 raise 

112 finally: 

113 if session: 113 ↛ exitline 113 didn't return from function 'get_session' because the condition on line 113 was always true

114 from ..utilities.resource_utils import safe_close 

115 

116 safe_close(session, "thread metrics session") 

117 

118 def write_token_metrics( 

119 self, username: str, research_id: Optional[int], token_data: dict 

120 ): 

121 """ 

122 Write token metrics from any thread. 

123 

124 Args: 

125 username: The username (for database access) 

126 research_id: The research ID 

127 token_data: Dictionary with token metrics data 

128 """ 

129 with self.get_session(username) as session: 

130 # Import here to avoid circular imports 

131 from .models import ModelUsage, TokenUsage 

132 

133 # Create TokenUsage record 

134 token_usage = TokenUsage( 

135 research_id=research_id, 

136 model_name=token_data.get("model_name"), 

137 model_provider=token_data.get("provider"), 

138 prompt_tokens=token_data.get("prompt_tokens", 0), 

139 completion_tokens=token_data.get("completion_tokens", 0), 

140 total_tokens=token_data.get("prompt_tokens", 0) 

141 + token_data.get("completion_tokens", 0), 

142 # Research context 

143 research_query=token_data.get("research_query"), 

144 research_mode=token_data.get("research_mode"), 

145 research_phase=token_data.get("research_phase"), 

146 search_iteration=token_data.get("search_iteration"), 

147 # Performance metrics 

148 response_time_ms=token_data.get("response_time_ms"), 

149 success_status=token_data.get("success_status", "success"), 

150 error_type=token_data.get("error_type"), 

151 # Search engine context 

152 search_engines_planned=token_data.get("search_engines_planned"), 

153 search_engine_selected=token_data.get("search_engine_selected"), 

154 # Call stack tracking 

155 calling_file=token_data.get("calling_file"), 

156 calling_function=token_data.get("calling_function"), 

157 call_stack=token_data.get("call_stack"), 

158 # Context overflow detection 

159 context_limit=token_data.get("context_limit"), 

160 context_truncated=token_data.get("context_truncated", False), 

161 tokens_truncated=token_data.get("tokens_truncated"), 

162 truncation_ratio=token_data.get("truncation_ratio"), 

163 # Raw Ollama metrics 

164 ollama_prompt_eval_count=token_data.get( 

165 "ollama_prompt_eval_count" 

166 ), 

167 ollama_eval_count=token_data.get("ollama_eval_count"), 

168 ollama_total_duration=token_data.get("ollama_total_duration"), 

169 ollama_load_duration=token_data.get("ollama_load_duration"), 

170 ollama_prompt_eval_duration=token_data.get( 

171 "ollama_prompt_eval_duration" 

172 ), 

173 ollama_eval_duration=token_data.get("ollama_eval_duration"), 

174 ) 

175 session.add(token_usage) 

176 

177 # Update or create model usage statistics (matches MainThread 

178 # path in token_counter.py _save_to_db). 

179 total_tokens = token_data.get("prompt_tokens", 0) + token_data.get( 

180 "completion_tokens", 0 

181 ) 

182 model_name = token_data.get("model_name") 

183 model_usage = ( 

184 session.query(ModelUsage) 

185 .filter_by(model_name=model_name) 

186 .first() 

187 ) 

188 

189 if model_usage: 

190 model_usage.total_tokens += total_tokens 

191 model_usage.total_calls += 1 

192 else: 

193 model_usage = ModelUsage( 

194 model_name=model_name, 

195 model_provider=token_data.get("provider"), 

196 total_tokens=total_tokens, 

197 total_calls=1, 

198 ) 

199 session.add(model_usage) 

200 

201 

202# Global instance for thread-safe metrics 

203metrics_writer = ThreadSafeMetricsWriter()