lucidicai 2.1.2__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.
Files changed (39) hide show
  1. lucidicai/__init__.py +32 -390
  2. lucidicai/api/client.py +260 -92
  3. lucidicai/api/resources/__init__.py +16 -1
  4. lucidicai/api/resources/dataset.py +422 -82
  5. lucidicai/api/resources/event.py +399 -27
  6. lucidicai/api/resources/experiment.py +108 -0
  7. lucidicai/api/resources/feature_flag.py +78 -0
  8. lucidicai/api/resources/prompt.py +84 -0
  9. lucidicai/api/resources/session.py +545 -38
  10. lucidicai/client.py +395 -480
  11. lucidicai/core/config.py +73 -48
  12. lucidicai/core/errors.py +3 -3
  13. lucidicai/sdk/bound_decorators.py +321 -0
  14. lucidicai/sdk/context.py +20 -2
  15. lucidicai/sdk/decorators.py +283 -74
  16. lucidicai/sdk/event.py +538 -36
  17. lucidicai/sdk/event_builder.py +2 -4
  18. lucidicai/sdk/features/dataset.py +408 -232
  19. lucidicai/sdk/features/feature_flag.py +344 -3
  20. lucidicai/sdk/init.py +50 -279
  21. lucidicai/sdk/session.py +502 -0
  22. lucidicai/sdk/shutdown_manager.py +103 -46
  23. lucidicai/session_obj.py +321 -0
  24. lucidicai/telemetry/context_capture_processor.py +13 -6
  25. lucidicai/telemetry/extract.py +60 -63
  26. lucidicai/telemetry/litellm_bridge.py +3 -44
  27. lucidicai/telemetry/lucidic_exporter.py +143 -131
  28. lucidicai/telemetry/openai_agents_instrumentor.py +2 -2
  29. lucidicai/telemetry/openai_patch.py +7 -6
  30. lucidicai/telemetry/telemetry_manager.py +183 -0
  31. lucidicai/telemetry/utils/model_pricing.py +21 -30
  32. lucidicai/telemetry/utils/provider.py +77 -0
  33. lucidicai/utils/images.py +30 -14
  34. lucidicai/utils/queue.py +2 -2
  35. lucidicai/utils/serialization.py +27 -0
  36. {lucidicai-2.1.2.dist-info → lucidicai-3.0.0.dist-info}/METADATA +1 -1
  37. {lucidicai-2.1.2.dist-info → lucidicai-3.0.0.dist-info}/RECORD +39 -30
  38. {lucidicai-2.1.2.dist-info → lucidicai-3.0.0.dist-info}/WHEEL +0 -0
  39. {lucidicai-2.1.2.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
- 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")
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 _sdk_state
218
- if hasattr(_sdk_state, 'tracer_provider') and _sdk_state.tracer_provider:
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
- _sdk_state.tracer_provider.force_flush(timeout_millis=5000)
223
- _sdk_state.tracer_provider.shutdown()
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 (before event queue)
309
+ # Flush OpenTelemetry spans first
248
310
  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:
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
- _sdk_state.tracer_provider.force_flush(timeout_millis=3000)
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:
@@ -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
- pass
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
- pass
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