lucidicai 2.0.1__py3-none-any.whl → 2.1.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 +351 -876
- lucidicai/api/__init__.py +1 -0
- lucidicai/api/client.py +218 -0
- lucidicai/api/resources/__init__.py +1 -0
- lucidicai/api/resources/dataset.py +192 -0
- lucidicai/api/resources/event.py +88 -0
- lucidicai/api/resources/session.py +126 -0
- lucidicai/client.py +4 -1
- lucidicai/core/__init__.py +1 -0
- lucidicai/core/config.py +223 -0
- lucidicai/core/errors.py +60 -0
- lucidicai/core/types.py +35 -0
- lucidicai/dataset.py +2 -0
- lucidicai/errors.py +6 -0
- lucidicai/feature_flag.py +8 -0
- lucidicai/sdk/__init__.py +1 -0
- lucidicai/sdk/context.py +144 -0
- lucidicai/sdk/decorators.py +187 -0
- lucidicai/sdk/error_boundary.py +299 -0
- lucidicai/sdk/event.py +122 -0
- lucidicai/sdk/event_builder.py +304 -0
- lucidicai/sdk/features/__init__.py +1 -0
- lucidicai/sdk/features/dataset.py +605 -0
- lucidicai/sdk/features/feature_flag.py +383 -0
- lucidicai/sdk/init.py +271 -0
- lucidicai/sdk/shutdown_manager.py +302 -0
- lucidicai/telemetry/context_bridge.py +82 -0
- lucidicai/telemetry/context_capture_processor.py +25 -9
- lucidicai/telemetry/litellm_bridge.py +18 -24
- lucidicai/telemetry/lucidic_exporter.py +51 -36
- lucidicai/telemetry/utils/model_pricing.py +278 -0
- lucidicai/utils/__init__.py +1 -0
- lucidicai/utils/images.py +337 -0
- lucidicai/utils/logger.py +168 -0
- lucidicai/utils/queue.py +393 -0
- {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/METADATA +1 -1
- {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/RECORD +39 -12
- {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/WHEEL +0 -0
- {lucidicai-2.0.1.dist-info → lucidicai-2.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Shutdown manager for graceful cleanup.
|
|
2
|
+
|
|
3
|
+
Coordinates shutdown across all active sessions, ensuring proper cleanup
|
|
4
|
+
on process exit. Inspired by TypeScript SDK's shutdown-manager.ts.
|
|
5
|
+
"""
|
|
6
|
+
import atexit
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Dict, Optional, Set, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ..utils.logger import debug, info, warning, error, truncate_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SessionState:
|
|
19
|
+
"""State information for an active session."""
|
|
20
|
+
session_id: str
|
|
21
|
+
http_client: Optional[object] = None
|
|
22
|
+
event_queue: Optional[object] = None
|
|
23
|
+
is_shutting_down: bool = False
|
|
24
|
+
auto_end: bool = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ShutdownManager:
|
|
28
|
+
"""Singleton manager for coordinating shutdown across all active sessions.
|
|
29
|
+
|
|
30
|
+
Ensures process listeners are only registered once and all sessions
|
|
31
|
+
are properly ended on exit.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_instance: Optional['ShutdownManager'] = None
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def __new__(cls):
|
|
38
|
+
if cls._instance is None:
|
|
39
|
+
with cls._lock:
|
|
40
|
+
if cls._instance is None:
|
|
41
|
+
cls._instance = super().__new__(cls)
|
|
42
|
+
cls._instance._initialized = False
|
|
43
|
+
return cls._instance
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
# only initialize once
|
|
47
|
+
if self._initialized:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
self._initialized = True
|
|
51
|
+
self.active_sessions: Dict[str, SessionState] = {}
|
|
52
|
+
self.is_shutting_down = False
|
|
53
|
+
self.shutdown_complete = threading.Event()
|
|
54
|
+
self.listeners_registered = False
|
|
55
|
+
self._session_lock = threading.Lock()
|
|
56
|
+
|
|
57
|
+
debug("[ShutdownManager] Initialized")
|
|
58
|
+
|
|
59
|
+
def register_session(self, session_id: str, state: SessionState) -> None:
|
|
60
|
+
"""Register a new active session.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
session_id: Session identifier
|
|
64
|
+
state: Session state information
|
|
65
|
+
"""
|
|
66
|
+
with self._session_lock:
|
|
67
|
+
debug(f"[ShutdownManager] Registering session {truncate_id(session_id)}, auto_end={state.auto_end}")
|
|
68
|
+
self.active_sessions[session_id] = state
|
|
69
|
+
|
|
70
|
+
# ensure listeners are registered
|
|
71
|
+
self._ensure_listeners_registered()
|
|
72
|
+
|
|
73
|
+
def unregister_session(self, session_id: str) -> None:
|
|
74
|
+
"""Unregister a session after it ends.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
session_id: Session identifier
|
|
78
|
+
"""
|
|
79
|
+
with self._session_lock:
|
|
80
|
+
debug(f"[ShutdownManager] Unregistering session {truncate_id(session_id)}")
|
|
81
|
+
self.active_sessions.pop(session_id, None)
|
|
82
|
+
|
|
83
|
+
def get_active_session_count(self) -> int:
|
|
84
|
+
"""Get count of active sessions."""
|
|
85
|
+
with self._session_lock:
|
|
86
|
+
return len(self.active_sessions)
|
|
87
|
+
|
|
88
|
+
def is_session_active(self, session_id: str) -> bool:
|
|
89
|
+
"""Check if a session is active.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
session_id: Session identifier
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if session is active
|
|
96
|
+
"""
|
|
97
|
+
with self._session_lock:
|
|
98
|
+
return session_id in self.active_sessions
|
|
99
|
+
|
|
100
|
+
def _ensure_listeners_registered(self) -> None:
|
|
101
|
+
"""Register process exit listeners once."""
|
|
102
|
+
if self.listeners_registered:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
self.listeners_registered = True
|
|
106
|
+
debug("[ShutdownManager] Registering global shutdown listeners (atexit, SIGINT, SIGTERM, uncaught exceptions)")
|
|
107
|
+
|
|
108
|
+
# register atexit handler for normal termination
|
|
109
|
+
atexit.register(self._handle_exit)
|
|
110
|
+
|
|
111
|
+
# register signal handlers for interrupts
|
|
112
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
113
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
114
|
+
|
|
115
|
+
# register uncaught exception handler
|
|
116
|
+
sys.excepthook = self._exception_handler
|
|
117
|
+
|
|
118
|
+
def _signal_handler(self, signum, frame):
|
|
119
|
+
"""Handle shutdown signals."""
|
|
120
|
+
info(f"[ShutdownManager] Received signal {signum}, initiating graceful shutdown")
|
|
121
|
+
self._handle_shutdown(f"signal_{signum}")
|
|
122
|
+
# exit after cleanup
|
|
123
|
+
sys.exit(0)
|
|
124
|
+
|
|
125
|
+
def _exception_handler(self, exc_type, exc_value, exc_traceback):
|
|
126
|
+
"""Handle uncaught exceptions."""
|
|
127
|
+
# log the exception
|
|
128
|
+
error(f"[ShutdownManager] Uncaught exception: {exc_type.__name__}: {exc_value}")
|
|
129
|
+
|
|
130
|
+
# Create an error event for the uncaught exception
|
|
131
|
+
try:
|
|
132
|
+
from ..sdk.event import create_event
|
|
133
|
+
import traceback
|
|
134
|
+
|
|
135
|
+
error_message = f"{exc_type.__name__}: {exc_value}"
|
|
136
|
+
traceback_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
|
137
|
+
|
|
138
|
+
create_event(
|
|
139
|
+
type="error_traceback",
|
|
140
|
+
error=error_message,
|
|
141
|
+
traceback=traceback_str
|
|
142
|
+
)
|
|
143
|
+
debug(f"[ShutdownManager] Created error_traceback event for uncaught exception")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
debug(f"[ShutdownManager] Failed to create error_traceback event: {e}")
|
|
146
|
+
|
|
147
|
+
# perform shutdown
|
|
148
|
+
self._handle_shutdown("uncaught_exception")
|
|
149
|
+
|
|
150
|
+
# call default handler
|
|
151
|
+
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
|
152
|
+
|
|
153
|
+
def _handle_exit(self):
|
|
154
|
+
"""Handle normal process exit."""
|
|
155
|
+
debug("[ShutdownManager] Normal process exit triggered (atexit)")
|
|
156
|
+
self._handle_shutdown("atexit")
|
|
157
|
+
|
|
158
|
+
def _handle_shutdown(self, trigger: str) -> None:
|
|
159
|
+
"""Coordinate shutdown of all sessions.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
trigger: What triggered the shutdown
|
|
163
|
+
"""
|
|
164
|
+
if self.is_shutting_down:
|
|
165
|
+
debug(f"[ShutdownManager] Already shutting down, ignoring {trigger}")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
self.is_shutting_down = True
|
|
169
|
+
|
|
170
|
+
with self._session_lock:
|
|
171
|
+
session_count = len(self.active_sessions)
|
|
172
|
+
if session_count == 0:
|
|
173
|
+
debug("[ShutdownManager] No active sessions to clean up")
|
|
174
|
+
self.shutdown_complete.set()
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
info(f"[ShutdownManager] Shutdown initiated by {trigger}, ending {session_count} active session(s)")
|
|
178
|
+
|
|
179
|
+
# perform shutdown in separate thread to avoid deadlocks
|
|
180
|
+
import threading
|
|
181
|
+
shutdown_thread = threading.Thread(
|
|
182
|
+
target=self._perform_shutdown,
|
|
183
|
+
name="ShutdownThread"
|
|
184
|
+
)
|
|
185
|
+
shutdown_thread.daemon = True
|
|
186
|
+
shutdown_thread.start()
|
|
187
|
+
|
|
188
|
+
# wait for shutdown with timeout
|
|
189
|
+
if not self.shutdown_complete.wait(timeout=30):
|
|
190
|
+
warning("[ShutdownManager] Shutdown timeout after 30s")
|
|
191
|
+
|
|
192
|
+
def _perform_shutdown(self) -> None:
|
|
193
|
+
"""Perform the actual shutdown of all sessions."""
|
|
194
|
+
debug("[ShutdownManager] _perform_shutdown thread started")
|
|
195
|
+
try:
|
|
196
|
+
sessions_to_end = []
|
|
197
|
+
|
|
198
|
+
with self._session_lock:
|
|
199
|
+
# collect sessions that need ending
|
|
200
|
+
for session_id, state in self.active_sessions.items():
|
|
201
|
+
if state.auto_end and not state.is_shutting_down:
|
|
202
|
+
state.is_shutting_down = True
|
|
203
|
+
sessions_to_end.append((session_id, state))
|
|
204
|
+
|
|
205
|
+
debug(f"[ShutdownManager] Found {len(sessions_to_end)} sessions to end")
|
|
206
|
+
|
|
207
|
+
# end all sessions
|
|
208
|
+
for session_id, state in sessions_to_end:
|
|
209
|
+
try:
|
|
210
|
+
debug(f"[ShutdownManager] Ending session {truncate_id(session_id)}")
|
|
211
|
+
self._end_session(session_id, state)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
error(f"[ShutdownManager] Error ending session {truncate_id(session_id)}: {e}")
|
|
214
|
+
|
|
215
|
+
# Final telemetry shutdown after all sessions are ended
|
|
216
|
+
try:
|
|
217
|
+
from ..sdk.init import _sdk_state
|
|
218
|
+
if hasattr(_sdk_state, 'tracer_provider') and _sdk_state.tracer_provider:
|
|
219
|
+
debug("[ShutdownManager] Final OpenTelemetry shutdown")
|
|
220
|
+
try:
|
|
221
|
+
# Final flush and shutdown with longer timeout
|
|
222
|
+
_sdk_state.tracer_provider.force_flush(timeout_millis=5000)
|
|
223
|
+
_sdk_state.tracer_provider.shutdown()
|
|
224
|
+
debug("[ShutdownManager] OpenTelemetry shutdown complete")
|
|
225
|
+
except Exception as e:
|
|
226
|
+
error(f"[ShutdownManager] Error in final telemetry shutdown: {e}")
|
|
227
|
+
except ImportError:
|
|
228
|
+
pass # SDK not initialized
|
|
229
|
+
|
|
230
|
+
info("[ShutdownManager] Shutdown complete")
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
error(f"[ShutdownManager] Unexpected error in _perform_shutdown: {e}")
|
|
234
|
+
import traceback
|
|
235
|
+
error(f"[ShutdownManager] Traceback: {traceback.format_exc()}")
|
|
236
|
+
finally:
|
|
237
|
+
debug("[ShutdownManager] Setting shutdown_complete event")
|
|
238
|
+
self.shutdown_complete.set()
|
|
239
|
+
|
|
240
|
+
def _end_session(self, session_id: str, state: SessionState) -> None:
|
|
241
|
+
"""End a single session with cleanup.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
session_id: Session identifier
|
|
245
|
+
state: Session state
|
|
246
|
+
"""
|
|
247
|
+
# Flush OpenTelemetry spans first (before event queue)
|
|
248
|
+
try:
|
|
249
|
+
# Get the global tracer provider if it exists
|
|
250
|
+
from ..sdk.init import _sdk_state
|
|
251
|
+
if hasattr(_sdk_state, 'tracer_provider') and _sdk_state.tracer_provider:
|
|
252
|
+
debug(f"[ShutdownManager] Flushing OpenTelemetry spans for session {truncate_id(session_id)}")
|
|
253
|
+
try:
|
|
254
|
+
# Force flush with 3 second timeout
|
|
255
|
+
_sdk_state.tracer_provider.force_flush(timeout_millis=3000)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
error(f"[ShutdownManager] Error flushing spans: {e}")
|
|
258
|
+
except ImportError:
|
|
259
|
+
pass # SDK not initialized
|
|
260
|
+
|
|
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
|
+
# end session via API if http client present
|
|
267
|
+
if state.http_client and session_id:
|
|
268
|
+
try:
|
|
269
|
+
debug(f"[ShutdownManager] Ending session {truncate_id(session_id)} via API")
|
|
270
|
+
debug(f"[ShutdownManager] http_client type: {type(state.http_client)}, keys: {state.http_client.keys() if isinstance(state.http_client, dict) else 'not a dict'}")
|
|
271
|
+
# state.http_client is a resources dict with 'sessions' key
|
|
272
|
+
if isinstance(state.http_client, dict) and 'sessions' in state.http_client:
|
|
273
|
+
state.http_client['sessions'].end_session(
|
|
274
|
+
session_id,
|
|
275
|
+
is_successful=False,
|
|
276
|
+
session_eval_reason="Process shutdown"
|
|
277
|
+
)
|
|
278
|
+
debug(f"[ShutdownManager] Session {truncate_id(session_id)} ended via API")
|
|
279
|
+
else:
|
|
280
|
+
debug(f"[ShutdownManager] Cannot end session - http_client not properly configured")
|
|
281
|
+
except Exception as e:
|
|
282
|
+
error(f"[ShutdownManager] Error ending session via API: {e}")
|
|
283
|
+
|
|
284
|
+
# unregister the session
|
|
285
|
+
self.unregister_session(session_id)
|
|
286
|
+
|
|
287
|
+
def reset(self) -> None:
|
|
288
|
+
"""Reset shutdown manager (for testing)."""
|
|
289
|
+
with self._session_lock:
|
|
290
|
+
self.active_sessions.clear()
|
|
291
|
+
self.is_shutting_down = False
|
|
292
|
+
self.shutdown_complete.clear()
|
|
293
|
+
# note: we don't reset listeners_registered as they persist
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# global singleton instance
|
|
297
|
+
_shutdown_manager = ShutdownManager()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_shutdown_manager() -> ShutdownManager:
|
|
301
|
+
"""Get the global shutdown manager instance."""
|
|
302
|
+
return _shutdown_manager
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Bridge between Lucidic context and OpenTelemetry context.
|
|
2
|
+
|
|
3
|
+
This module ensures that Lucidic's contextvars (session_id, parent_event_id)
|
|
4
|
+
are properly propagated through OpenTelemetry's context system, which is
|
|
5
|
+
necessary for instrumentors that create spans in different execution contexts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from opentelemetry import baggage, context as otel_context
|
|
10
|
+
from opentelemetry.trace import set_span_in_context
|
|
11
|
+
from ..utils.logger import debug, verbose, truncate_id
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def inject_lucidic_context() -> otel_context.Context:
|
|
15
|
+
"""Inject Lucidic context into OpenTelemetry baggage.
|
|
16
|
+
|
|
17
|
+
This ensures that our context variables are available to any spans
|
|
18
|
+
created by OpenTelemetry instrumentors, even if they run in different
|
|
19
|
+
threads or async contexts.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
OpenTelemetry Context with Lucidic values in baggage
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
from ..sdk.context import current_session_id, current_parent_event_id
|
|
26
|
+
|
|
27
|
+
ctx = otel_context.get_current()
|
|
28
|
+
|
|
29
|
+
# Get Lucidic context values
|
|
30
|
+
session_id = None
|
|
31
|
+
parent_event_id = None
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
session_id = current_session_id.get(None)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
parent_event_id = current_parent_event_id.get(None)
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
# Inject into OpenTelemetry baggage
|
|
44
|
+
if session_id:
|
|
45
|
+
ctx = baggage.set_baggage("lucidic.session_id", session_id, context=ctx)
|
|
46
|
+
debug(f"[ContextBridge] Injected session_id {truncate_id(session_id)} into OTel baggage")
|
|
47
|
+
|
|
48
|
+
if parent_event_id:
|
|
49
|
+
ctx = baggage.set_baggage("lucidic.parent_event_id", parent_event_id, context=ctx)
|
|
50
|
+
debug(f"[ContextBridge] Injected parent_event_id {truncate_id(parent_event_id)} into OTel baggage")
|
|
51
|
+
|
|
52
|
+
return ctx
|
|
53
|
+
|
|
54
|
+
except Exception as e:
|
|
55
|
+
verbose(f"[ContextBridge] Failed to inject context: {e}")
|
|
56
|
+
return otel_context.get_current()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_lucidic_context(ctx: Optional[otel_context.Context] = None) -> tuple[Optional[str], Optional[str]]:
|
|
60
|
+
"""Extract Lucidic context from OpenTelemetry baggage.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
ctx: OpenTelemetry context (uses current if not provided)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Tuple of (session_id, parent_event_id)
|
|
67
|
+
"""
|
|
68
|
+
if ctx is None:
|
|
69
|
+
ctx = otel_context.get_current()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
session_id = baggage.get_baggage("lucidic.session_id", context=ctx)
|
|
73
|
+
parent_event_id = baggage.get_baggage("lucidic.parent_event_id", context=ctx)
|
|
74
|
+
|
|
75
|
+
if session_id or parent_event_id:
|
|
76
|
+
debug(f"[ContextBridge] Extracted from OTel baggage - session: {truncate_id(session_id)}, parent: {truncate_id(parent_event_id)}")
|
|
77
|
+
|
|
78
|
+
return session_id, parent_event_id
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
verbose(f"[ContextBridge] Failed to extract context: {e}")
|
|
82
|
+
return None, None
|
|
@@ -8,13 +8,11 @@ processed asynchronously in different threads/contexts.
|
|
|
8
8
|
This fixes the nesting issue for ALL providers (OpenAI, Anthropic, LangChain, etc.)
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import logging
|
|
12
11
|
from typing import Optional
|
|
13
12
|
from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan
|
|
14
13
|
from opentelemetry.trace import Span
|
|
15
14
|
from opentelemetry import context as otel_context
|
|
16
|
-
|
|
17
|
-
logger = logging.getLogger("Lucidic")
|
|
15
|
+
from ..utils.logger import debug, verbose, truncate_id
|
|
18
16
|
|
|
19
17
|
|
|
20
18
|
class ContextCaptureProcessor(SpanProcessor):
|
|
@@ -24,33 +22,51 @@ class ContextCaptureProcessor(SpanProcessor):
|
|
|
24
22
|
"""Called when a span is started - capture context here."""
|
|
25
23
|
try:
|
|
26
24
|
# Import here to avoid circular imports
|
|
27
|
-
from lucidicai.context import current_session_id, current_parent_event_id
|
|
25
|
+
from lucidicai.sdk.context import current_session_id, current_parent_event_id
|
|
26
|
+
from .context_bridge import extract_lucidic_context
|
|
28
27
|
|
|
29
|
-
#
|
|
28
|
+
# Try to get from contextvars first
|
|
30
29
|
session_id = None
|
|
30
|
+
parent_event_id = None
|
|
31
|
+
|
|
31
32
|
try:
|
|
32
33
|
session_id = current_session_id.get(None)
|
|
33
34
|
except Exception:
|
|
34
35
|
pass
|
|
35
36
|
|
|
36
|
-
# Capture parent event ID from context
|
|
37
|
-
parent_event_id = None
|
|
38
37
|
try:
|
|
39
38
|
parent_event_id = current_parent_event_id.get(None)
|
|
40
39
|
except Exception:
|
|
41
40
|
pass
|
|
42
41
|
|
|
42
|
+
# If not found in contextvars, try OpenTelemetry baggage
|
|
43
|
+
# This handles cases where spans are created in different threads
|
|
44
|
+
if not session_id or not parent_event_id:
|
|
45
|
+
baggage_session, baggage_parent = extract_lucidic_context(parent_context)
|
|
46
|
+
if not session_id and baggage_session:
|
|
47
|
+
session_id = baggage_session
|
|
48
|
+
debug(f"[ContextCapture] Got session_id from OTel baggage for span {span.name}")
|
|
49
|
+
if not parent_event_id and baggage_parent:
|
|
50
|
+
parent_event_id = baggage_parent
|
|
51
|
+
debug(f"[ContextCapture] Got parent_event_id from OTel baggage for span {span.name}")
|
|
52
|
+
|
|
53
|
+
# Add debug logging to understand context propagation
|
|
54
|
+
debug(f"[ContextCapture] Processing span '{span.name}' - session: {truncate_id(session_id)}, parent: {truncate_id(parent_event_id)}")
|
|
55
|
+
|
|
43
56
|
# Store in span attributes for later retrieval
|
|
44
57
|
if session_id:
|
|
45
58
|
span.set_attribute("lucidic.session_id", session_id)
|
|
59
|
+
debug(f"[ContextCapture] Set session_id attribute for span {span.name}")
|
|
46
60
|
|
|
47
61
|
if parent_event_id:
|
|
48
62
|
span.set_attribute("lucidic.parent_event_id", parent_event_id)
|
|
49
|
-
|
|
63
|
+
debug(f"[ContextCapture] Captured parent_event_id {truncate_id(parent_event_id)} for span {span.name}")
|
|
64
|
+
else:
|
|
65
|
+
debug(f"[ContextCapture] No parent_event_id available for span {span.name}")
|
|
50
66
|
|
|
51
67
|
except Exception as e:
|
|
52
68
|
# Never fail span creation due to context capture
|
|
53
|
-
|
|
69
|
+
verbose(f"[ContextCapture] Failed to capture context: {e}")
|
|
54
70
|
|
|
55
71
|
def on_end(self, span: ReadableSpan) -> None:
|
|
56
72
|
"""Called when a span ends - no action needed."""
|
|
@@ -14,9 +14,10 @@ except ImportError:
|
|
|
14
14
|
def __init__(self, **kwargs):
|
|
15
15
|
pass
|
|
16
16
|
|
|
17
|
-
from lucidicai.
|
|
18
|
-
from lucidicai.
|
|
19
|
-
from lucidicai.
|
|
17
|
+
from lucidicai.sdk.event import create_event
|
|
18
|
+
from lucidicai.sdk.init import get_session_id
|
|
19
|
+
from lucidicai.telemetry.utils.model_pricing import calculate_cost
|
|
20
|
+
from lucidicai.sdk.context import current_parent_event_id
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger("Lucidic")
|
|
22
23
|
DEBUG = os.getenv("LUCIDIC_DEBUG", "False") == "True"
|
|
@@ -76,16 +77,12 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
76
77
|
def log_pre_api_call(self, model, messages, kwargs):
|
|
77
78
|
"""Called before the LLM API call"""
|
|
78
79
|
try:
|
|
79
|
-
|
|
80
|
-
if not
|
|
80
|
+
session_id = get_session_id()
|
|
81
|
+
if not session_id:
|
|
81
82
|
return
|
|
82
83
|
|
|
83
84
|
# Extract description from messages
|
|
84
85
|
description = self._format_messages(messages)
|
|
85
|
-
|
|
86
|
-
# Apply masking if configured
|
|
87
|
-
if hasattr(client, 'mask') and callable(client.mask):
|
|
88
|
-
description = client.mask(description)
|
|
89
86
|
|
|
90
87
|
# Store pre-call info for later use
|
|
91
88
|
call_id = kwargs.get("litellm_call_id", str(time.time())) if kwargs else str(time.time())
|
|
@@ -109,8 +106,8 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
109
106
|
self._register_callback(callback_id)
|
|
110
107
|
|
|
111
108
|
try:
|
|
112
|
-
|
|
113
|
-
if not
|
|
109
|
+
session_id = get_session_id()
|
|
110
|
+
if not session_id:
|
|
114
111
|
self._complete_callback(callback_id)
|
|
115
112
|
return
|
|
116
113
|
|
|
@@ -129,10 +126,6 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
129
126
|
# Extract response content
|
|
130
127
|
result = self._extract_response_content(response_obj)
|
|
131
128
|
|
|
132
|
-
# Apply masking to result if configured
|
|
133
|
-
if hasattr(client, 'mask') and callable(client.mask):
|
|
134
|
-
result = client.mask(result)
|
|
135
|
-
|
|
136
129
|
# Calculate cost if usage info is available
|
|
137
130
|
usage = self._extract_usage(response_obj)
|
|
138
131
|
cost = None
|
|
@@ -142,7 +135,7 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
142
135
|
# Extract any images from multimodal requests
|
|
143
136
|
images = self._extract_images_from_messages(messages)
|
|
144
137
|
|
|
145
|
-
#
|
|
138
|
+
# Get parent event ID from context
|
|
146
139
|
parent_id = None
|
|
147
140
|
try:
|
|
148
141
|
parent_id = current_parent_event_id.get(None)
|
|
@@ -150,10 +143,11 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
150
143
|
parent_id = None
|
|
151
144
|
|
|
152
145
|
# occurred_at/duration from datetimes
|
|
153
|
-
occ_dt = start_time if isinstance(start_time, datetime) else None
|
|
146
|
+
occ_dt = start_time.isoformat() if isinstance(start_time, datetime) else None
|
|
154
147
|
duration_secs = (end_time - start_time).total_seconds() if isinstance(start_time, datetime) and isinstance(end_time, datetime) else None
|
|
155
148
|
|
|
156
|
-
|
|
149
|
+
# Create event with correct field names
|
|
150
|
+
create_event(
|
|
157
151
|
type="llm_generation",
|
|
158
152
|
provider=provider,
|
|
159
153
|
model=model,
|
|
@@ -162,7 +156,7 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
162
156
|
input_tokens=(usage or {}).get("prompt_tokens", 0),
|
|
163
157
|
output_tokens=(usage or {}).get("completion_tokens", 0),
|
|
164
158
|
cost=cost,
|
|
165
|
-
parent_event_id=parent_id,
|
|
159
|
+
parent_event_id=parent_id, # This will be normalized by EventBuilder
|
|
166
160
|
occurred_at=occ_dt,
|
|
167
161
|
duration=duration_secs,
|
|
168
162
|
)
|
|
@@ -185,8 +179,8 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
185
179
|
self._register_callback(callback_id)
|
|
186
180
|
|
|
187
181
|
try:
|
|
188
|
-
|
|
189
|
-
if not
|
|
182
|
+
session_id = get_session_id()
|
|
183
|
+
if not session_id:
|
|
190
184
|
self._complete_callback(callback_id)
|
|
191
185
|
return
|
|
192
186
|
|
|
@@ -211,14 +205,14 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
211
205
|
parent_id = current_parent_event_id.get(None)
|
|
212
206
|
except Exception:
|
|
213
207
|
parent_id = None
|
|
214
|
-
occ_dt = start_time if isinstance(start_time, datetime) else None
|
|
208
|
+
occ_dt = start_time.isoformat() if isinstance(start_time, datetime) else None
|
|
215
209
|
duration_secs = (end_time - start_time).total_seconds() if isinstance(start_time, datetime) and isinstance(end_time, datetime) else None
|
|
216
210
|
|
|
217
|
-
|
|
211
|
+
create_event(
|
|
218
212
|
type="error_traceback",
|
|
219
213
|
error=error_msg,
|
|
220
214
|
traceback="",
|
|
221
|
-
parent_event_id=parent_id,
|
|
215
|
+
parent_event_id=parent_id, # This will be normalized by EventBuilder
|
|
222
216
|
occurred_at=occ_dt,
|
|
223
217
|
duration=duration_secs,
|
|
224
218
|
metadata={"provider": provider, "litellm": True}
|