lucidicai 2.1.3__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lucidicai/__init__.py +32 -390
- lucidicai/api/client.py +31 -2
- lucidicai/api/resources/__init__.py +16 -1
- lucidicai/api/resources/dataset.py +422 -82
- lucidicai/api/resources/event.py +399 -27
- lucidicai/api/resources/experiment.py +108 -0
- lucidicai/api/resources/feature_flag.py +78 -0
- lucidicai/api/resources/prompt.py +84 -0
- lucidicai/api/resources/session.py +545 -38
- lucidicai/client.py +395 -480
- lucidicai/core/config.py +73 -48
- lucidicai/core/errors.py +3 -3
- lucidicai/sdk/bound_decorators.py +321 -0
- lucidicai/sdk/context.py +20 -2
- lucidicai/sdk/decorators.py +283 -74
- lucidicai/sdk/event.py +538 -36
- lucidicai/sdk/event_builder.py +2 -4
- lucidicai/sdk/features/dataset.py +391 -1
- lucidicai/sdk/features/feature_flag.py +344 -3
- lucidicai/sdk/init.py +49 -347
- lucidicai/sdk/session.py +502 -0
- lucidicai/sdk/shutdown_manager.py +103 -46
- lucidicai/session_obj.py +321 -0
- lucidicai/telemetry/context_capture_processor.py +13 -6
- lucidicai/telemetry/extract.py +60 -63
- lucidicai/telemetry/litellm_bridge.py +3 -44
- lucidicai/telemetry/lucidic_exporter.py +143 -131
- lucidicai/telemetry/openai_agents_instrumentor.py +2 -2
- lucidicai/telemetry/openai_patch.py +7 -6
- lucidicai/telemetry/telemetry_manager.py +183 -0
- lucidicai/telemetry/utils/model_pricing.py +21 -30
- lucidicai/telemetry/utils/provider.py +77 -0
- lucidicai/utils/images.py +27 -11
- lucidicai/utils/serialization.py +27 -0
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/METADATA +1 -1
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/RECORD +38 -29
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/WHEEL +0 -0
- {lucidicai-2.1.3.dist-info → lucidicai-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -8,18 +8,20 @@ import signal
|
|
|
8
8
|
import sys
|
|
9
9
|
import threading
|
|
10
10
|
import time
|
|
11
|
-
from typing import Dict, Optional, Set, Callable
|
|
11
|
+
from typing import Dict, Optional, Set, Callable, TYPE_CHECKING
|
|
12
12
|
from dataclasses import dataclass
|
|
13
13
|
|
|
14
14
|
from ..utils.logger import debug, info, warning, error, truncate_id
|
|
15
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ..client import LucidicAI
|
|
18
|
+
|
|
16
19
|
|
|
17
20
|
@dataclass
|
|
18
21
|
class SessionState:
|
|
19
22
|
"""State information for an active session."""
|
|
20
23
|
session_id: str
|
|
21
24
|
http_client: Optional[object] = None
|
|
22
|
-
event_queue: Optional[object] = None
|
|
23
25
|
is_shutting_down: bool = False
|
|
24
26
|
auto_end: bool = True
|
|
25
27
|
|
|
@@ -46,14 +48,18 @@ class ShutdownManager:
|
|
|
46
48
|
# only initialize once
|
|
47
49
|
if self._initialized:
|
|
48
50
|
return
|
|
49
|
-
|
|
51
|
+
|
|
50
52
|
self._initialized = True
|
|
51
53
|
self.active_sessions: Dict[str, SessionState] = {}
|
|
52
54
|
self.is_shutting_down = False
|
|
53
55
|
self.shutdown_complete = threading.Event()
|
|
54
56
|
self.listeners_registered = False
|
|
55
57
|
self._session_lock = threading.Lock()
|
|
56
|
-
|
|
58
|
+
|
|
59
|
+
# Client registry for multi-client support
|
|
60
|
+
self._clients: Dict[str, "LucidicAI"] = {}
|
|
61
|
+
self._client_lock = threading.Lock()
|
|
62
|
+
|
|
57
63
|
debug("[ShutdownManager] Initialized")
|
|
58
64
|
|
|
59
65
|
def register_session(self, session_id: str, state: SessionState) -> None:
|
|
@@ -87,16 +93,39 @@ class ShutdownManager:
|
|
|
87
93
|
|
|
88
94
|
def is_session_active(self, session_id: str) -> bool:
|
|
89
95
|
"""Check if a session is active.
|
|
90
|
-
|
|
96
|
+
|
|
91
97
|
Args:
|
|
92
98
|
session_id: Session identifier
|
|
93
|
-
|
|
99
|
+
|
|
94
100
|
Returns:
|
|
95
101
|
True if session is active
|
|
96
102
|
"""
|
|
97
103
|
with self._session_lock:
|
|
98
104
|
return session_id in self.active_sessions
|
|
99
|
-
|
|
105
|
+
|
|
106
|
+
def register_client(self, client: "LucidicAI") -> None:
|
|
107
|
+
"""Register a client for shutdown tracking.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
client: The LucidicAI client to register
|
|
111
|
+
"""
|
|
112
|
+
with self._client_lock:
|
|
113
|
+
self._clients[client._client_id] = client
|
|
114
|
+
debug(f"[ShutdownManager] Registered client {client._client_id[:8]}...")
|
|
115
|
+
|
|
116
|
+
# Ensure listeners are registered
|
|
117
|
+
self._ensure_listeners_registered()
|
|
118
|
+
|
|
119
|
+
def unregister_client(self, client_id: str) -> None:
|
|
120
|
+
"""Unregister a client.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
client_id: The client ID to unregister
|
|
124
|
+
"""
|
|
125
|
+
with self._client_lock:
|
|
126
|
+
self._clients.pop(client_id, None)
|
|
127
|
+
debug(f"[ShutdownManager] Unregistered client {client_id[:8]}...")
|
|
128
|
+
|
|
100
129
|
def _ensure_listeners_registered(self) -> None:
|
|
101
130
|
"""Register process exit listeners once."""
|
|
102
131
|
if self.listeners_registered:
|
|
@@ -164,46 +193,78 @@ class ShutdownManager:
|
|
|
164
193
|
if self.is_shutting_down:
|
|
165
194
|
debug(f"[ShutdownManager] Already shutting down, ignoring {trigger}")
|
|
166
195
|
return
|
|
167
|
-
|
|
196
|
+
|
|
168
197
|
self.is_shutting_down = True
|
|
169
|
-
|
|
198
|
+
|
|
199
|
+
# Check if there's anything to clean up
|
|
170
200
|
with self._session_lock:
|
|
171
201
|
session_count = len(self.active_sessions)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
with self._client_lock:
|
|
203
|
+
client_count = len(self._clients)
|
|
204
|
+
|
|
205
|
+
if session_count == 0 and client_count == 0:
|
|
206
|
+
debug("[ShutdownManager] No active sessions or clients to clean up")
|
|
207
|
+
# Reset flag so future shutdowns can proceed (e.g., if exception triggered
|
|
208
|
+
# shutdown before any sessions were created, then user creates sessions)
|
|
209
|
+
self.is_shutting_down = False
|
|
210
|
+
self.shutdown_complete.set()
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
info(f"[ShutdownManager] Shutdown initiated by {trigger}, ending {session_count} active session(s), {client_count} client(s)")
|
|
214
|
+
|
|
215
|
+
# perform shutdown in separate thread to avoid deadlocks
|
|
216
|
+
import threading
|
|
217
|
+
shutdown_thread = threading.Thread(
|
|
218
|
+
target=self._perform_shutdown,
|
|
219
|
+
name="ShutdownThread"
|
|
220
|
+
)
|
|
221
|
+
shutdown_thread.daemon = True
|
|
222
|
+
shutdown_thread.start()
|
|
223
|
+
|
|
224
|
+
# wait for shutdown with timeout - MUST be outside lock to avoid deadlock
|
|
225
|
+
if not self.shutdown_complete.wait(timeout=30):
|
|
226
|
+
warning("[ShutdownManager] Shutdown timeout after 30s")
|
|
191
227
|
|
|
192
228
|
def _perform_shutdown(self) -> None:
|
|
193
|
-
"""Perform the actual shutdown of all sessions."""
|
|
229
|
+
"""Perform the actual shutdown of all sessions and clients."""
|
|
194
230
|
debug("[ShutdownManager] _perform_shutdown thread started")
|
|
195
231
|
try:
|
|
232
|
+
# First, flush all pending background events before ending sessions
|
|
233
|
+
# This ensures telemetry from the exporter is sent
|
|
234
|
+
try:
|
|
235
|
+
debug("[ShutdownManager] Flushing pending events before session cleanup")
|
|
236
|
+
from ..sdk.event import flush
|
|
237
|
+
flush(timeout=5.0)
|
|
238
|
+
debug("[ShutdownManager] Event flush complete")
|
|
239
|
+
except Exception as e:
|
|
240
|
+
error(f"[ShutdownManager] Error flushing events: {e}")
|
|
241
|
+
|
|
242
|
+
# Close all registered clients (this will also end their sessions)
|
|
243
|
+
clients_to_close = []
|
|
244
|
+
with self._client_lock:
|
|
245
|
+
clients_to_close = list(self._clients.values())
|
|
246
|
+
|
|
247
|
+
if clients_to_close:
|
|
248
|
+
debug(f"[ShutdownManager] Closing {len(clients_to_close)} registered client(s)")
|
|
249
|
+
for client in clients_to_close:
|
|
250
|
+
try:
|
|
251
|
+
debug(f"[ShutdownManager] Closing client {client._client_id[:8]}...")
|
|
252
|
+
client.close()
|
|
253
|
+
except Exception as e:
|
|
254
|
+
error(f"[ShutdownManager] Error closing client: {e}")
|
|
255
|
+
|
|
256
|
+
# Also handle sessions registered directly (legacy support)
|
|
196
257
|
sessions_to_end = []
|
|
197
|
-
|
|
258
|
+
|
|
198
259
|
with self._session_lock:
|
|
199
260
|
# collect sessions that need ending
|
|
200
261
|
for session_id, state in self.active_sessions.items():
|
|
201
262
|
if state.auto_end and not state.is_shutting_down:
|
|
202
263
|
state.is_shutting_down = True
|
|
203
264
|
sessions_to_end.append((session_id, state))
|
|
204
|
-
|
|
265
|
+
|
|
205
266
|
debug(f"[ShutdownManager] Found {len(sessions_to_end)} sessions to end")
|
|
206
|
-
|
|
267
|
+
|
|
207
268
|
# end all sessions
|
|
208
269
|
for session_id, state in sessions_to_end:
|
|
209
270
|
try:
|
|
@@ -214,13 +275,14 @@ class ShutdownManager:
|
|
|
214
275
|
|
|
215
276
|
# Final telemetry shutdown after all sessions are ended
|
|
216
277
|
try:
|
|
217
|
-
from ..sdk.init import
|
|
218
|
-
|
|
278
|
+
from ..sdk.init import get_tracer_provider
|
|
279
|
+
tracer_provider = get_tracer_provider()
|
|
280
|
+
if tracer_provider:
|
|
219
281
|
debug("[ShutdownManager] Final OpenTelemetry shutdown")
|
|
220
282
|
try:
|
|
221
283
|
# Final flush and shutdown with longer timeout
|
|
222
|
-
|
|
223
|
-
|
|
284
|
+
tracer_provider.force_flush(timeout_millis=5000)
|
|
285
|
+
tracer_provider.shutdown()
|
|
224
286
|
debug("[ShutdownManager] OpenTelemetry shutdown complete")
|
|
225
287
|
except Exception as e:
|
|
226
288
|
error(f"[ShutdownManager] Error in final telemetry shutdown: {e}")
|
|
@@ -244,25 +306,20 @@ class ShutdownManager:
|
|
|
244
306
|
session_id: Session identifier
|
|
245
307
|
state: Session state
|
|
246
308
|
"""
|
|
247
|
-
# Flush OpenTelemetry spans first
|
|
309
|
+
# Flush OpenTelemetry spans first
|
|
248
310
|
try:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if
|
|
311
|
+
from ..sdk.init import get_tracer_provider
|
|
312
|
+
tracer_provider = get_tracer_provider()
|
|
313
|
+
if tracer_provider:
|
|
252
314
|
debug(f"[ShutdownManager] Flushing OpenTelemetry spans for session {truncate_id(session_id)}")
|
|
253
315
|
try:
|
|
254
316
|
# Force flush with 3 second timeout
|
|
255
|
-
|
|
317
|
+
tracer_provider.force_flush(timeout_millis=3000)
|
|
256
318
|
except Exception as e:
|
|
257
319
|
error(f"[ShutdownManager] Error flushing spans: {e}")
|
|
258
320
|
except ImportError:
|
|
259
321
|
pass # SDK not initialized
|
|
260
322
|
|
|
261
|
-
# Skip event queue flush during shutdown to avoid hanging
|
|
262
|
-
# The queue worker is a daemon thread and will flush on its own
|
|
263
|
-
if state.event_queue:
|
|
264
|
-
debug(f"[ShutdownManager] Skipping event queue flush during shutdown for session {truncate_id(session_id)}")
|
|
265
|
-
|
|
266
323
|
# end session via API if http client present
|
|
267
324
|
if state.http_client and session_id:
|
|
268
325
|
try:
|
lucidicai/session_obj.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Session object - Represents an active session bound to a LucidicAI client.
|
|
2
|
+
|
|
3
|
+
A Session is returned by client.sessions.create() and provides:
|
|
4
|
+
- Context manager support for automatic session binding and cleanup
|
|
5
|
+
- Methods to update and end the session
|
|
6
|
+
- Access to the parent client for operations within the session
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional, List, Any, TYPE_CHECKING
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from .sdk.context import current_session_id, current_client
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .client import LucidicAI
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("Lucidic")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Session:
|
|
21
|
+
"""Represents an active session bound to a LucidicAI client.
|
|
22
|
+
|
|
23
|
+
Sessions track a unit of work (conversation, workflow, etc.) and provide:
|
|
24
|
+
- Automatic context binding when used as a context manager
|
|
25
|
+
- Methods to update session metadata and end the session
|
|
26
|
+
- Reference to the parent client for additional operations
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
client = LucidicAI(api_key="...", providers=["openai"])
|
|
30
|
+
|
|
31
|
+
# Using as context manager (recommended)
|
|
32
|
+
with client.sessions.create(session_name="My Session") as session:
|
|
33
|
+
# Session is active, LLM calls are tracked
|
|
34
|
+
response = openai_client.chat.completions.create(...)
|
|
35
|
+
|
|
36
|
+
# Manual usage
|
|
37
|
+
session = client.sessions.create(session_name="Manual Session", auto_end=False)
|
|
38
|
+
try:
|
|
39
|
+
# Do work
|
|
40
|
+
pass
|
|
41
|
+
finally:
|
|
42
|
+
session.end()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
client: "LucidicAI",
|
|
48
|
+
session_id: str,
|
|
49
|
+
session_name: Optional[str] = None,
|
|
50
|
+
auto_end: bool = True,
|
|
51
|
+
):
|
|
52
|
+
"""Initialize a Session.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
client: The LucidicAI client that owns this session
|
|
56
|
+
session_id: The unique session identifier
|
|
57
|
+
session_name: Optional human-readable name for the session
|
|
58
|
+
auto_end: Whether to automatically end the session on context exit
|
|
59
|
+
"""
|
|
60
|
+
self._client = client
|
|
61
|
+
self._session_id = session_id
|
|
62
|
+
self._session_name = session_name
|
|
63
|
+
self._auto_end = auto_end
|
|
64
|
+
self._ended = False
|
|
65
|
+
self._context_token = None
|
|
66
|
+
self._client_token = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def session_id(self) -> str:
|
|
70
|
+
"""Get the session ID."""
|
|
71
|
+
return self._session_id
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def session_name(self) -> Optional[str]:
|
|
75
|
+
"""Get the session name."""
|
|
76
|
+
return self._session_name
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def client(self) -> "LucidicAI":
|
|
80
|
+
"""Get the parent client."""
|
|
81
|
+
return self._client
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_finished(self) -> bool:
|
|
85
|
+
"""Check if the session has been ended."""
|
|
86
|
+
return self._ended
|
|
87
|
+
|
|
88
|
+
def update(
|
|
89
|
+
self,
|
|
90
|
+
task: Optional[str] = None,
|
|
91
|
+
session_eval: Optional[float] = None,
|
|
92
|
+
session_eval_reason: Optional[str] = None,
|
|
93
|
+
is_successful: Optional[bool] = None,
|
|
94
|
+
is_successful_reason: Optional[str] = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Update the session metadata.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
task: Update the task description
|
|
100
|
+
session_eval: Evaluation score (0.0 to 1.0)
|
|
101
|
+
session_eval_reason: Reason for the evaluation score
|
|
102
|
+
is_successful: Whether the session was successful
|
|
103
|
+
is_successful_reason: Reason for success/failure status
|
|
104
|
+
"""
|
|
105
|
+
if self._ended:
|
|
106
|
+
logger.warning(f"[Session] Attempted to update ended session {self._session_id[:8]}...")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
updates = {}
|
|
110
|
+
if task is not None:
|
|
111
|
+
updates["task"] = task
|
|
112
|
+
if session_eval is not None:
|
|
113
|
+
updates["session_eval"] = session_eval
|
|
114
|
+
if session_eval_reason is not None:
|
|
115
|
+
updates["session_eval_reason"] = session_eval_reason
|
|
116
|
+
if is_successful is not None:
|
|
117
|
+
updates["is_successful"] = is_successful
|
|
118
|
+
if is_successful_reason is not None:
|
|
119
|
+
updates["is_successful_reason"] = is_successful_reason
|
|
120
|
+
|
|
121
|
+
if updates:
|
|
122
|
+
logger.debug(
|
|
123
|
+
f"[Session] update() called - session_id={self._session_id[:8]}..., updates={updates}"
|
|
124
|
+
)
|
|
125
|
+
self._client.sessions.update(self._session_id, updates)
|
|
126
|
+
logger.debug(f"[Session] update() completed for session {self._session_id[:8]}...")
|
|
127
|
+
else:
|
|
128
|
+
logger.debug(f"[Session] update() called with no updates for session {self._session_id[:8]}...")
|
|
129
|
+
|
|
130
|
+
async def aupdate(
|
|
131
|
+
self,
|
|
132
|
+
task: Optional[str] = None,
|
|
133
|
+
session_eval: Optional[float] = None,
|
|
134
|
+
session_eval_reason: Optional[str] = None,
|
|
135
|
+
is_successful: Optional[bool] = None,
|
|
136
|
+
is_successful_reason: Optional[str] = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Update the session metadata (async version).
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
task: Update the task description
|
|
142
|
+
session_eval: Evaluation score (0.0 to 1.0)
|
|
143
|
+
session_eval_reason: Reason for the evaluation score
|
|
144
|
+
is_successful: Whether the session was successful
|
|
145
|
+
is_successful_reason: Reason for success/failure status
|
|
146
|
+
"""
|
|
147
|
+
if self._ended:
|
|
148
|
+
logger.warning(f"[Session] Attempted to update ended session {self._session_id[:8]}...")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
updates = {}
|
|
152
|
+
if task is not None:
|
|
153
|
+
updates["task"] = task
|
|
154
|
+
if session_eval is not None:
|
|
155
|
+
updates["session_eval"] = session_eval
|
|
156
|
+
if session_eval_reason is not None:
|
|
157
|
+
updates["session_eval_reason"] = session_eval_reason
|
|
158
|
+
if is_successful is not None:
|
|
159
|
+
updates["is_successful"] = is_successful
|
|
160
|
+
if is_successful_reason is not None:
|
|
161
|
+
updates["is_successful_reason"] = is_successful_reason
|
|
162
|
+
|
|
163
|
+
if updates:
|
|
164
|
+
logger.debug(
|
|
165
|
+
f"[Session] aupdate() called - session_id={self._session_id[:8]}..., updates={updates}"
|
|
166
|
+
)
|
|
167
|
+
await self._client.sessions.aupdate(self._session_id, updates)
|
|
168
|
+
logger.debug(f"[Session] aupdate() completed for session {self._session_id[:8]}...")
|
|
169
|
+
else:
|
|
170
|
+
logger.debug(f"[Session] aupdate() called with no updates for session {self._session_id[:8]}...")
|
|
171
|
+
|
|
172
|
+
def end(
|
|
173
|
+
self,
|
|
174
|
+
is_successful: Optional[bool] = None,
|
|
175
|
+
is_successful_reason: Optional[str] = None,
|
|
176
|
+
session_eval: Optional[float] = None,
|
|
177
|
+
session_eval_reason: Optional[str] = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""End the session.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
is_successful: Whether the session was successful
|
|
183
|
+
is_successful_reason: Reason for success/failure status
|
|
184
|
+
session_eval: Evaluation score (0.0 to 1.0)
|
|
185
|
+
session_eval_reason: Reason for the evaluation score
|
|
186
|
+
"""
|
|
187
|
+
if self._ended:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Unbind context before ending
|
|
191
|
+
self._unbind_context()
|
|
192
|
+
|
|
193
|
+
self._client.sessions.end(
|
|
194
|
+
session_id=self._session_id,
|
|
195
|
+
is_successful=is_successful,
|
|
196
|
+
is_successful_reason=is_successful_reason,
|
|
197
|
+
session_eval=session_eval,
|
|
198
|
+
session_eval_reason=session_eval_reason,
|
|
199
|
+
)
|
|
200
|
+
self._ended = True
|
|
201
|
+
|
|
202
|
+
async def aend(
|
|
203
|
+
self,
|
|
204
|
+
is_successful: Optional[bool] = None,
|
|
205
|
+
is_successful_reason: Optional[str] = None,
|
|
206
|
+
session_eval: Optional[float] = None,
|
|
207
|
+
session_eval_reason: Optional[str] = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""End the session (async version).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
is_successful: Whether the session was successful
|
|
213
|
+
is_successful_reason: Reason for success/failure status
|
|
214
|
+
session_eval: Evaluation score (0.0 to 1.0)
|
|
215
|
+
session_eval_reason: Reason for the evaluation score
|
|
216
|
+
"""
|
|
217
|
+
if self._ended:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
# Unbind context before ending
|
|
221
|
+
self._unbind_context()
|
|
222
|
+
|
|
223
|
+
await self._client.sessions.aend(
|
|
224
|
+
session_id=self._session_id,
|
|
225
|
+
is_successful=is_successful,
|
|
226
|
+
is_successful_reason=is_successful_reason,
|
|
227
|
+
session_eval=session_eval,
|
|
228
|
+
session_eval_reason=session_eval_reason,
|
|
229
|
+
)
|
|
230
|
+
self._ended = True
|
|
231
|
+
|
|
232
|
+
def _bind_context(self) -> None:
|
|
233
|
+
"""Bind this session to the current context."""
|
|
234
|
+
self._context_token = current_session_id.set(self._session_id)
|
|
235
|
+
self._client_token = current_client.set(self._client)
|
|
236
|
+
|
|
237
|
+
def _unbind_context(self) -> None:
|
|
238
|
+
"""Unbind this session from the current context."""
|
|
239
|
+
if self._context_token:
|
|
240
|
+
current_session_id.reset(self._context_token)
|
|
241
|
+
self._context_token = None
|
|
242
|
+
if self._client_token:
|
|
243
|
+
current_client.reset(self._client_token)
|
|
244
|
+
self._client_token = None
|
|
245
|
+
|
|
246
|
+
def __enter__(self) -> "Session":
|
|
247
|
+
"""Enter the session context - binds session to current context."""
|
|
248
|
+
# Only bind if not already bound (create_session() already binds context)
|
|
249
|
+
if self._context_token is None:
|
|
250
|
+
self._bind_context()
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
254
|
+
"""Exit the session context - unbinds and optionally ends the session."""
|
|
255
|
+
# Flush telemetry before ending
|
|
256
|
+
try:
|
|
257
|
+
from .telemetry.telemetry_manager import get_telemetry_manager
|
|
258
|
+
|
|
259
|
+
manager = get_telemetry_manager()
|
|
260
|
+
if manager.is_telemetry_initialized:
|
|
261
|
+
logger.debug(
|
|
262
|
+
f"[Session] Flushing telemetry for session {self._session_id}"
|
|
263
|
+
)
|
|
264
|
+
flush_success = manager.force_flush(timeout_millis=5000)
|
|
265
|
+
if not flush_success:
|
|
266
|
+
logger.warning("[Session] Telemetry flush may be incomplete")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.debug(f"[Session] Error flushing telemetry: {e}")
|
|
269
|
+
|
|
270
|
+
# Unbind context
|
|
271
|
+
self._unbind_context()
|
|
272
|
+
|
|
273
|
+
# End session if auto_end is enabled
|
|
274
|
+
if self._auto_end and not self._ended:
|
|
275
|
+
try:
|
|
276
|
+
self.end()
|
|
277
|
+
except Exception as e:
|
|
278
|
+
# Don't mask the original exception
|
|
279
|
+
logger.debug(f"[Session] Error ending session: {e}")
|
|
280
|
+
|
|
281
|
+
async def __aenter__(self) -> "Session":
|
|
282
|
+
"""Enter the async session context - binds session to current context."""
|
|
283
|
+
# Only bind if not already bound (acreate_session() already binds context)
|
|
284
|
+
if self._context_token is None:
|
|
285
|
+
self._bind_context()
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
289
|
+
"""Exit the async session context - unbinds and optionally ends the session."""
|
|
290
|
+
import asyncio
|
|
291
|
+
|
|
292
|
+
# Flush telemetry before ending
|
|
293
|
+
try:
|
|
294
|
+
from .telemetry.telemetry_manager import get_telemetry_manager
|
|
295
|
+
|
|
296
|
+
manager = get_telemetry_manager()
|
|
297
|
+
if manager.is_telemetry_initialized:
|
|
298
|
+
logger.debug(
|
|
299
|
+
f"[Session] Flushing telemetry for async session {self._session_id}"
|
|
300
|
+
)
|
|
301
|
+
flush_success = manager.force_flush(timeout_millis=5000)
|
|
302
|
+
if not flush_success:
|
|
303
|
+
logger.warning("[Session] Telemetry flush may be incomplete")
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.debug(f"[Session] Error flushing telemetry: {e}")
|
|
306
|
+
|
|
307
|
+
# Unbind context
|
|
308
|
+
self._unbind_context()
|
|
309
|
+
|
|
310
|
+
# End session if auto_end is enabled
|
|
311
|
+
if self._auto_end and not self._ended:
|
|
312
|
+
try:
|
|
313
|
+
await self.aend()
|
|
314
|
+
except Exception as e:
|
|
315
|
+
# Don't mask the original exception
|
|
316
|
+
logger.debug(f"[Session] Error ending async session: {e}")
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str:
|
|
319
|
+
status = "ended" if self._ended else "active"
|
|
320
|
+
name_part = f", name={self._session_name!r}" if self._session_name else ""
|
|
321
|
+
return f"<Session(id={self._session_id[:8]}...{name_part}, status={status})>"
|
|
@@ -31,13 +31,13 @@ class ContextCaptureProcessor(SpanProcessor):
|
|
|
31
31
|
|
|
32
32
|
try:
|
|
33
33
|
session_id = current_session_id.get(None)
|
|
34
|
-
except Exception:
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
except Exception as e:
|
|
35
|
+
debug(f"[ContextCapture] Failed to get session_id from contextvar: {e}")
|
|
36
|
+
|
|
37
37
|
try:
|
|
38
38
|
parent_event_id = current_parent_event_id.get(None)
|
|
39
|
-
except Exception:
|
|
40
|
-
|
|
39
|
+
except Exception as e:
|
|
40
|
+
debug(f"[ContextCapture] Failed to get parent_event_id from contextvar: {e}")
|
|
41
41
|
|
|
42
42
|
# If not found in contextvars, try OpenTelemetry baggage
|
|
43
43
|
# This handles cases where spans are created in different threads
|
|
@@ -57,12 +57,19 @@ class ContextCaptureProcessor(SpanProcessor):
|
|
|
57
57
|
if session_id:
|
|
58
58
|
span.set_attribute("lucidic.session_id", session_id)
|
|
59
59
|
debug(f"[ContextCapture] Set session_id attribute for span {span.name}")
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
if parent_event_id:
|
|
62
62
|
span.set_attribute("lucidic.parent_event_id", parent_event_id)
|
|
63
63
|
debug(f"[ContextCapture] Captured parent_event_id {truncate_id(parent_event_id)} for span {span.name}")
|
|
64
64
|
else:
|
|
65
65
|
debug(f"[ContextCapture] No parent_event_id available for span {span.name}")
|
|
66
|
+
|
|
67
|
+
# Capture client_id for multi-client routing
|
|
68
|
+
from lucidicai.sdk.context import get_active_client
|
|
69
|
+
client = get_active_client()
|
|
70
|
+
if client:
|
|
71
|
+
span.set_attribute("lucidic.client_id", client._client_id)
|
|
72
|
+
debug(f"[ContextCapture] Set client_id attribute for span {span.name}")
|
|
66
73
|
|
|
67
74
|
except Exception as e:
|
|
68
75
|
# Never fail span creation due to context capture
|