lucidicai 2.0.2__py3-none-any.whl → 2.1.1__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 (38) hide show
  1. lucidicai/__init__.py +367 -899
  2. lucidicai/api/__init__.py +1 -0
  3. lucidicai/api/client.py +218 -0
  4. lucidicai/api/resources/__init__.py +1 -0
  5. lucidicai/api/resources/dataset.py +192 -0
  6. lucidicai/api/resources/event.py +88 -0
  7. lucidicai/api/resources/session.py +126 -0
  8. lucidicai/core/__init__.py +1 -0
  9. lucidicai/core/config.py +223 -0
  10. lucidicai/core/errors.py +60 -0
  11. lucidicai/core/types.py +35 -0
  12. lucidicai/sdk/__init__.py +1 -0
  13. lucidicai/sdk/context.py +231 -0
  14. lucidicai/sdk/decorators.py +187 -0
  15. lucidicai/sdk/error_boundary.py +299 -0
  16. lucidicai/sdk/event.py +126 -0
  17. lucidicai/sdk/event_builder.py +304 -0
  18. lucidicai/sdk/features/__init__.py +1 -0
  19. lucidicai/sdk/features/dataset.py +605 -0
  20. lucidicai/sdk/features/feature_flag.py +383 -0
  21. lucidicai/sdk/init.py +361 -0
  22. lucidicai/sdk/shutdown_manager.py +302 -0
  23. lucidicai/telemetry/context_bridge.py +82 -0
  24. lucidicai/telemetry/context_capture_processor.py +25 -9
  25. lucidicai/telemetry/litellm_bridge.py +20 -24
  26. lucidicai/telemetry/lucidic_exporter.py +99 -60
  27. lucidicai/telemetry/openai_patch.py +295 -0
  28. lucidicai/telemetry/openai_uninstrument.py +87 -0
  29. lucidicai/telemetry/telemetry_init.py +16 -1
  30. lucidicai/telemetry/utils/model_pricing.py +278 -0
  31. lucidicai/utils/__init__.py +1 -0
  32. lucidicai/utils/images.py +337 -0
  33. lucidicai/utils/logger.py +168 -0
  34. lucidicai/utils/queue.py +393 -0
  35. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/METADATA +1 -1
  36. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/RECORD +38 -9
  37. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/WHEEL +0 -0
  38. {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/top_level.txt +0 -0
@@ -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
- # Capture session ID from context
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
- logger.debug(f"[ContextCapture] Captured parent_event_id {parent_event_id[:8]}... for span {span.name}")
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
- logger.debug(f"[ContextCapture] Failed to capture context: {e}")
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.client import Client
18
- from lucidicai.model_pricing import calculate_cost
19
- from lucidicai.context import current_parent_event_id
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
- client = Client()
80
- if not client.session:
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
- client = Client()
113
- if not client.session:
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
- # Create LLM_GENERATION typed event
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,11 +143,13 @@ 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
- client.create_event(
149
+ # Create event with correct field names
150
+ create_event(
157
151
  type="llm_generation",
152
+ session_id=session_id, # Pass session_id explicitly
158
153
  provider=provider,
159
154
  model=model,
160
155
  messages=messages,
@@ -162,7 +157,7 @@ class LucidicLiteLLMCallback(CustomLogger):
162
157
  input_tokens=(usage or {}).get("prompt_tokens", 0),
163
158
  output_tokens=(usage or {}).get("completion_tokens", 0),
164
159
  cost=cost,
165
- parent_event_id=parent_id,
160
+ parent_event_id=parent_id, # This will be normalized by EventBuilder
166
161
  occurred_at=occ_dt,
167
162
  duration=duration_secs,
168
163
  )
@@ -185,8 +180,8 @@ class LucidicLiteLLMCallback(CustomLogger):
185
180
  self._register_callback(callback_id)
186
181
 
187
182
  try:
188
- client = Client()
189
- if not client.session:
183
+ session_id = get_session_id()
184
+ if not session_id:
190
185
  self._complete_callback(callback_id)
191
186
  return
192
187
 
@@ -211,14 +206,15 @@ class LucidicLiteLLMCallback(CustomLogger):
211
206
  parent_id = current_parent_event_id.get(None)
212
207
  except Exception:
213
208
  parent_id = None
214
- occ_dt = start_time if isinstance(start_time, datetime) else None
209
+ occ_dt = start_time.isoformat() if isinstance(start_time, datetime) else None
215
210
  duration_secs = (end_time - start_time).total_seconds() if isinstance(start_time, datetime) and isinstance(end_time, datetime) else None
216
211
 
217
- client.create_event(
212
+ create_event(
218
213
  type="error_traceback",
214
+ session_id=session_id, # Pass session_id explicitly
219
215
  error=error_msg,
220
216
  traceback="",
221
- parent_event_id=parent_id,
217
+ parent_event_id=parent_id, # This will be normalized by EventBuilder
222
218
  occurred_at=occ_dt,
223
219
  duration=duration_secs,
224
220
  metadata={"provider": provider, "litellm": True}
@@ -4,7 +4,6 @@ Converts completed spans into immutable typed LLM events via Client.create_event
4
4
  which enqueues non-blocking delivery through the EventQueue.
5
5
  """
6
6
  import json
7
- import logging
8
7
  from typing import Sequence, Optional, Dict, Any, List
9
8
  from datetime import datetime, timezone
10
9
  from opentelemetry.sdk.trace import ReadableSpan
@@ -12,16 +11,12 @@ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
12
11
  from opentelemetry.trace import StatusCode
13
12
  from opentelemetry.semconv_ai import SpanAttributes
14
13
 
15
- from lucidicai.client import Client
16
- from lucidicai.context import current_session_id, current_parent_event_id
17
- from lucidicai.model_pricing import calculate_cost
14
+ from ..sdk.event import create_event
15
+ from ..sdk.init import get_session_id
16
+ from ..sdk.context import current_session_id, current_parent_event_id
17
+ from ..telemetry.utils.model_pricing import calculate_cost
18
18
  from .extract import detect_is_llm_span, extract_images, extract_prompts, extract_completions, extract_model
19
-
20
- logger = logging.getLogger("Lucidic")
21
- import os
22
-
23
- DEBUG = os.getenv("LUCIDIC_DEBUG", "False") == "True"
24
- VERBOSE = os.getenv("LUCIDIC_VERBOSE", "False") == "True"
19
+ from ..utils.logger import debug, info, warning, error, verbose, truncate_id
25
20
 
26
21
 
27
22
  class LucidicSpanExporter(SpanExporter):
@@ -29,26 +24,35 @@ class LucidicSpanExporter(SpanExporter):
29
24
 
30
25
  def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
31
26
  try:
32
- client = Client()
33
- if DEBUG and spans:
34
- logger.debug(f"[LucidicSpanExporter] Processing {len(spans)} spans")
27
+ if spans:
28
+ debug(f"[Telemetry] Processing {len(spans)} OpenTelemetry spans")
35
29
  for span in spans:
36
- self._process_span(span, client)
37
- if DEBUG and spans:
38
- logger.debug(f"[LucidicSpanExporter] Successfully exported {len(spans)} spans")
30
+ self._process_span(span)
31
+ if spans:
32
+ debug(f"[Telemetry] Successfully exported {len(spans)} spans")
39
33
  return SpanExportResult.SUCCESS
40
34
  except Exception as e:
41
- logger.error(f"Failed to export spans: {e}")
35
+ error(f"[Telemetry] Failed to export spans: {e}")
42
36
  return SpanExportResult.FAILURE
43
37
 
44
- def _process_span(self, span: ReadableSpan, client: Client) -> None:
38
+ def _process_span(self, span: ReadableSpan) -> None:
45
39
  """Convert a single LLM span into a typed, immutable event."""
46
40
  try:
47
41
  if not detect_is_llm_span(span):
42
+ verbose(f"[Telemetry] Skipping non-LLM span: {span.name}")
48
43
  return
49
44
 
45
+ debug(f"[Telemetry] Processing LLM span: {span.name}")
46
+
50
47
  attributes = dict(span.attributes or {})
51
48
 
49
+ # Skip spans that are likely duplicates or incomplete
50
+ # Check if this is a responses.parse span that was already handled
51
+ if span.name == "openai.responses.create" and not attributes.get("lucidic.instrumented"):
52
+ # This might be from incorrect standard instrumentation
53
+ verbose(f"[Telemetry] Skipping potentially duplicate responses span without our marker")
54
+ return
55
+
52
56
  # Resolve session id
53
57
  target_session_id = attributes.get('lucidic.session_id')
54
58
  if not target_session_id:
@@ -56,22 +60,30 @@ class LucidicSpanExporter(SpanExporter):
56
60
  target_session_id = current_session_id.get(None)
57
61
  except Exception:
58
62
  target_session_id = None
59
- if not target_session_id and getattr(client, 'session', None) and getattr(client.session, 'session_id', None):
60
- target_session_id = client.session.session_id
61
63
  if not target_session_id:
64
+ target_session_id = get_session_id()
65
+ if not target_session_id:
66
+ debug(f"[Telemetry] No session ID for span {span.name}, skipping")
62
67
  return
63
68
 
64
69
  # Parent nesting - get from span attributes (captured at span creation)
65
70
  parent_id = attributes.get('lucidic.parent_event_id')
71
+ debug(f"[Telemetry] Span {span.name} has parent_id from attributes: {truncate_id(parent_id)}")
66
72
  if not parent_id:
67
73
  # Fallback to trying context (may work if same thread)
68
74
  try:
69
75
  parent_id = current_parent_event_id.get(None)
76
+ if parent_id:
77
+ debug(f"[Telemetry] Got parent_id from context for span {span.name}: {truncate_id(parent_id)}")
70
78
  except Exception:
71
79
  parent_id = None
80
+
81
+ if not parent_id:
82
+ debug(f"[Telemetry] No parent_id available for span {span.name}")
72
83
 
73
84
  # Timing
74
- occurred_at = datetime.fromtimestamp(span.start_time / 1_000_000_000, tz=timezone.utc) if span.start_time else datetime.now(tz=timezone.utc)
85
+ occurred_at_dt = datetime.fromtimestamp(span.start_time / 1_000_000_000, tz=timezone.utc) if span.start_time else datetime.now(tz=timezone.utc)
86
+ occurred_at = occurred_at_dt.isoformat() # Convert to ISO string for JSON serialization
75
87
  duration_seconds = ((span.end_time - span.start_time) / 1_000_000_000) if (span.start_time and span.end_time) else None
76
88
 
77
89
  # Typed fields using extract utilities
@@ -79,17 +91,36 @@ class LucidicSpanExporter(SpanExporter):
79
91
  provider = self._detect_provider_name(attributes)
80
92
  messages = extract_prompts(attributes) or []
81
93
  params = self._extract_params(attributes)
82
- output_text = extract_completions(span, attributes) or "Response received"
94
+ output_text = extract_completions(span, attributes)
95
+
96
+ # Skip spans with no meaningful output (likely incomplete or duplicate instrumentation)
97
+ if not output_text or output_text == "Response received":
98
+ # Only use "Response received" if we have other meaningful data
99
+ if not messages and not attributes.get("lucidic.instrumented"):
100
+ verbose(f"[Telemetry] Skipping span {span.name} with no meaningful content")
101
+ return
102
+ # Use a more descriptive default if we must
103
+ if not output_text:
104
+ output_text = "Response received"
105
+
83
106
  input_tokens = self._extract_prompt_tokens(attributes)
84
107
  output_tokens = self._extract_completion_tokens(attributes)
85
108
  cost = self._calculate_cost(attributes)
86
109
  images = extract_images(attributes)
87
110
 
88
- # Create immutable event via non-blocking queue
89
- event_id = client.create_event(
111
+ # Set context for parent if needed
112
+ from ..sdk.context import current_parent_event_id as parent_context
113
+ if parent_id:
114
+ token = parent_context.set(parent_id)
115
+ else:
116
+ token = None
117
+
118
+ try:
119
+ # Create immutable event via non-blocking queue
120
+ debug(f"[Telemetry] Creating LLM event with parent_id: {truncate_id(parent_id)}, session_id: {truncate_id(target_session_id)}")
121
+ event_id = create_event(
90
122
  type="llm_generation",
91
- session_id=target_session_id,
92
- parent_event_id=parent_id,
123
+ session_id=target_session_id, # Pass the session_id explicitly
93
124
  occurred_at=occurred_at,
94
125
  duration=duration_seconds,
95
126
  provider=provider,
@@ -101,16 +132,20 @@ class LucidicSpanExporter(SpanExporter):
101
132
  output_tokens=output_tokens,
102
133
  cost=cost,
103
134
  raw={"images": images} if images else None,
135
+ parent_event_id=parent_id, # Pass the parent_id explicitly
104
136
  )
137
+ finally:
138
+ # Reset parent context
139
+ if token:
140
+ parent_context.reset(token)
105
141
 
106
- if DEBUG:
107
- logger.debug(f"[LucidicSpanExporter] Created LLM event {event_id} for session {target_session_id[:8]}...")
142
+ debug(f"[Telemetry] Created LLM event {truncate_id(event_id)} from span {span.name} for session {truncate_id(target_session_id)}")
108
143
 
109
144
  except Exception as e:
110
- logger.error(f"Failed to process span {span.name}: {e}")
145
+ error(f"[Telemetry] Failed to process span {span.name}: {e}")
111
146
 
112
147
 
113
- def _create_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any], client: Client) -> Optional[str]:
148
+ def _create_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any]) -> Optional[str]:
114
149
  """Create a Lucidic event from span start"""
115
150
  try:
116
151
  # Extract description from prompts/messages
@@ -132,28 +167,29 @@ class LucidicSpanExporter(SpanExporter):
132
167
  except Exception:
133
168
  target_session_id = None
134
169
  if not target_session_id:
135
- if getattr(client, 'session', None) and getattr(client.session, 'session_id', None):
136
- target_session_id = client.session.session_id
170
+ target_session_id = get_session_id()
137
171
  if not target_session_id:
172
+ debug(f"[Telemetry] No session ID for span {span.name}, skipping")
138
173
  return None
139
174
 
140
175
  # Create event
141
176
  event_kwargs = {
177
+ 'session_id': target_session_id, # Pass session_id explicitly
142
178
  'description': description,
143
179
  'result': "Processing...", # Will be updated when span ends
144
180
  'model': model
145
181
  }
146
-
182
+
147
183
  if images:
148
184
  event_kwargs['screenshots'] = images
149
-
150
- return client.create_event_for_session(target_session_id, **event_kwargs)
185
+
186
+ return create_event(**event_kwargs)
151
187
 
152
188
  except Exception as e:
153
- logger.error(f"Failed to create event from span: {e}")
189
+ error(f"[Telemetry] Failed to create event from span: {e}")
154
190
  return None
155
191
 
156
- def _update_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any], event_id: str, client: Client) -> None:
192
+ def _update_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any], event_id: str) -> None:
157
193
  """Deprecated: events are immutable; no updates performed."""
158
194
  return
159
195
 
@@ -163,8 +199,7 @@ class LucidicSpanExporter(SpanExporter):
163
199
  prompts = attributes.get(SpanAttributes.LLM_PROMPTS) or \
164
200
  attributes.get('gen_ai.prompt')
165
201
 
166
- if VERBOSE:
167
- logger.info(f"[SpaneExporter -- DEBUG] Extracting Description attributes: {attributes}, prompts: {prompts}")
202
+ verbose(f"[Telemetry] Extracting description from attributes: {attributes}, prompts: {prompts}")
168
203
 
169
204
  if prompts:
170
205
  if isinstance(prompts, list) and prompts:
@@ -210,31 +245,35 @@ class LucidicSpanExporter(SpanExporter):
210
245
  }
211
246
 
212
247
  def _extract_prompt_tokens(self, attributes: Dict[str, Any]) -> int:
213
- return (
214
- attributes.get(SpanAttributes.LLM_USAGE_PROMPT_TOKENS) or
215
- attributes.get('gen_ai.usage.prompt_tokens') or
216
- attributes.get('gen_ai.usage.input_tokens') or 0
217
- )
248
+ # Check each attribute and return the first non-None value
249
+ value = attributes.get(SpanAttributes.LLM_USAGE_PROMPT_TOKENS)
250
+ if value is not None:
251
+ return value
252
+ value = attributes.get('gen_ai.usage.prompt_tokens')
253
+ if value is not None:
254
+ return value
255
+ value = attributes.get('gen_ai.usage.input_tokens')
256
+ if value is not None:
257
+ return value
258
+ return 0
218
259
 
219
260
  def _extract_completion_tokens(self, attributes: Dict[str, Any]) -> int:
220
- return (
221
- attributes.get(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS) or
222
- attributes.get('gen_ai.usage.completion_tokens') or
223
- attributes.get('gen_ai.usage.output_tokens') or 0
224
- )
261
+ # Check each attribute and return the first non-None value
262
+ value = attributes.get(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS)
263
+ if value is not None:
264
+ return value
265
+ value = attributes.get('gen_ai.usage.completion_tokens')
266
+ if value is not None:
267
+ return value
268
+ value = attributes.get('gen_ai.usage.output_tokens')
269
+ if value is not None:
270
+ return value
271
+ return 0
225
272
 
226
273
  def _calculate_cost(self, attributes: Dict[str, Any]) -> Optional[float]:
227
- prompt_tokens = (
228
- attributes.get(SpanAttributes.LLM_USAGE_PROMPT_TOKENS) or
229
- attributes.get('gen_ai.usage.prompt_tokens') or
230
- attributes.get('gen_ai.usage.input_tokens') or 0
231
- )
232
- completion_tokens = (
233
- attributes.get(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS) or
234
- attributes.get('gen_ai.usage.completion_tokens') or
235
- attributes.get('gen_ai.usage.output_tokens') or 0
236
- )
237
- total_tokens = (prompt_tokens or 0) + (completion_tokens or 0)
274
+ prompt_tokens = self._extract_prompt_tokens(attributes)
275
+ completion_tokens = self._extract_completion_tokens(attributes)
276
+ total_tokens = prompt_tokens + completion_tokens
238
277
  if total_tokens > 0:
239
278
  model = (
240
279
  attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or