lucidicai 1.3.2__py3-none-any.whl → 2.0.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.
@@ -1,15 +1,21 @@
1
- """Custom OpenTelemetry exporter for Lucidic backend compatibility"""
1
+ """Custom OpenTelemetry exporter for Lucidic (Exporter-only mode).
2
+
3
+ Converts completed spans into immutable typed LLM events via Client.create_event(),
4
+ which enqueues non-blocking delivery through the EventQueue.
5
+ """
2
6
  import json
3
7
  import logging
4
8
  from typing import Sequence, Optional, Dict, Any, List
9
+ from datetime import datetime, timezone
5
10
  from opentelemetry.sdk.trace import ReadableSpan
6
11
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
7
12
  from opentelemetry.trace import StatusCode
8
13
  from opentelemetry.semconv_ai import SpanAttributes
9
14
 
10
15
  from lucidicai.client import Client
16
+ from lucidicai.context import current_session_id, current_parent_event_id
11
17
  from lucidicai.model_pricing import calculate_cost
12
- from lucidicai.image_upload import extract_base64_images
18
+ from .extract import detect_is_llm_span, extract_images, extract_prompts, extract_completions, extract_model
13
19
 
14
20
  logger = logging.getLogger("Lucidic")
15
21
  import os
@@ -19,72 +25,90 @@ VERBOSE = os.getenv("LUCIDIC_VERBOSE", "False") == "True"
19
25
 
20
26
 
21
27
  class LucidicSpanExporter(SpanExporter):
22
- """Custom exporter that converts OpenTelemetry spans to Lucidic events"""
23
-
24
- def __init__(self):
25
- self.pending_events = {} # Track events by span_id
26
-
28
+ """Exporter that creates immutable LLM events for completed spans."""
29
+
27
30
  def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
28
- """Export spans by converting them to Lucidic events"""
29
31
  try:
30
32
  client = Client()
31
- if not client.session:
32
- logger.debug("No active session, skipping span export")
33
- return SpanExportResult.SUCCESS
34
-
33
+ if DEBUG and spans:
34
+ logger.debug(f"[LucidicSpanExporter] Processing {len(spans)} spans")
35
35
  for span in spans:
36
36
  self._process_span(span, client)
37
-
37
+ if DEBUG and spans:
38
+ logger.debug(f"[LucidicSpanExporter] Successfully exported {len(spans)} spans")
38
39
  return SpanExportResult.SUCCESS
39
40
  except Exception as e:
40
41
  logger.error(f"Failed to export spans: {e}")
41
42
  return SpanExportResult.FAILURE
42
-
43
+
43
44
  def _process_span(self, span: ReadableSpan, client: Client) -> None:
44
- """Process a single span and convert to Lucidic event"""
45
+ """Convert a single LLM span into a typed, immutable event."""
45
46
  try:
46
- # Skip non-LLM spans
47
- if not self._is_llm_span(span):
47
+ if not detect_is_llm_span(span):
48
48
  return
49
-
50
- # Extract relevant attributes
49
+
51
50
  attributes = dict(span.attributes or {})
51
+
52
+ # Resolve session id
53
+ target_session_id = attributes.get('lucidic.session_id')
54
+ if not target_session_id:
55
+ try:
56
+ target_session_id = current_session_id.get(None)
57
+ except Exception:
58
+ 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
+ if not target_session_id:
62
+ return
63
+
64
+ # Parent nesting - get from span attributes (captured at span creation)
65
+ parent_id = attributes.get('lucidic.parent_event_id')
66
+ if not parent_id:
67
+ # Fallback to trying context (may work if same thread)
68
+ try:
69
+ parent_id = current_parent_event_id.get(None)
70
+ except Exception:
71
+ parent_id = None
72
+
73
+ # 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)
75
+ duration_seconds = ((span.end_time - span.start_time) / 1_000_000_000) if (span.start_time and span.end_time) else None
76
+
77
+ # Typed fields using extract utilities
78
+ model = extract_model(attributes) or 'unknown'
79
+ provider = self._detect_provider_name(attributes)
80
+ messages = extract_prompts(attributes) or []
81
+ params = self._extract_params(attributes)
82
+ output_text = extract_completions(span, attributes) or "Response received"
83
+ input_tokens = self._extract_prompt_tokens(attributes)
84
+ output_tokens = self._extract_completion_tokens(attributes)
85
+ cost = self._calculate_cost(attributes)
86
+ images = extract_images(attributes)
87
+
88
+ # Create immutable event via non-blocking queue
89
+ event_id = client.create_event(
90
+ type="llm_generation",
91
+ session_id=target_session_id,
92
+ parent_event_id=parent_id,
93
+ occurred_at=occurred_at,
94
+ duration=duration_seconds,
95
+ provider=provider,
96
+ model=model,
97
+ messages=messages,
98
+ params=params,
99
+ output=output_text,
100
+ input_tokens=input_tokens,
101
+ output_tokens=output_tokens,
102
+ cost=cost,
103
+ raw={"images": images} if images else None,
104
+ )
52
105
 
53
- # Create or update event based on span lifecycle
54
- span_id = format(span.context.span_id, '016x')
55
-
56
- if span_id not in self.pending_events:
57
- # New span - create event
58
- event_id = self._create_event_from_span(span, attributes, client)
59
- if event_id:
60
- self.pending_events[span_id] = {
61
- 'event_id': event_id,
62
- 'start_time': span.start_time
63
- }
64
- else:
65
- # Span ended - update event
66
- event_info = self.pending_events.pop(span_id)
67
- self._update_event_from_span(span, attributes, event_info['event_id'], client)
68
-
106
+ if DEBUG:
107
+ logger.debug(f"[LucidicSpanExporter] Created LLM event {event_id} for session {target_session_id[:8]}...")
108
+
69
109
  except Exception as e:
70
110
  logger.error(f"Failed to process span {span.name}: {e}")
71
111
 
72
- def _is_llm_span(self, span: ReadableSpan) -> bool:
73
- """Check if this is an LLM-related span"""
74
- # Check span name patterns
75
- llm_patterns = ['openai', 'anthropic', 'chat', 'completion', 'embedding', 'llm']
76
- span_name_lower = span.name.lower()
77
-
78
- if any(pattern in span_name_lower for pattern in llm_patterns):
79
- return True
80
-
81
- # Check for LLM attributes
82
- if span.attributes:
83
- for key in span.attributes:
84
- if key.startswith('gen_ai.') or key.startswith('llm.'):
85
- return True
86
-
87
- return False
88
112
 
89
113
  def _create_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any], client: Client) -> Optional[str]:
90
114
  """Create a Lucidic event from span start"""
@@ -100,6 +124,19 @@ class LucidicSpanExporter(SpanExporter):
100
124
  attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or \
101
125
  attributes.get('gen_ai.request.model') or 'unknown'
102
126
 
127
+ # Resolve target session id for this span
128
+ target_session_id = attributes.get('lucidic.session_id')
129
+ if not target_session_id:
130
+ try:
131
+ target_session_id = current_session_id.get(None)
132
+ except Exception:
133
+ target_session_id = None
134
+ 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
137
+ if not target_session_id:
138
+ return None
139
+
103
140
  # Create event
104
141
  event_kwargs = {
105
142
  'description': description,
@@ -110,43 +147,15 @@ class LucidicSpanExporter(SpanExporter):
110
147
  if images:
111
148
  event_kwargs['screenshots'] = images
112
149
 
113
- # Check if we have a specific step_id in span attributes
114
- step_id = attributes.get('lucidic.step_id')
115
- if step_id:
116
- event_kwargs['step_id'] = step_id
117
-
118
- return client.session.create_event(**event_kwargs)
150
+ return client.create_event_for_session(target_session_id, **event_kwargs)
119
151
 
120
152
  except Exception as e:
121
153
  logger.error(f"Failed to create event from span: {e}")
122
154
  return None
123
155
 
124
156
  def _update_event_from_span(self, span: ReadableSpan, attributes: Dict[str, Any], event_id: str, client: Client) -> None:
125
- """Update a Lucidic event from span end"""
126
- try:
127
- # Extract response/result
128
- result = self._extract_result(span, attributes)
129
-
130
- # Calculate cost if we have token usage
131
- cost = self._calculate_cost(attributes)
132
-
133
- # Determine success
134
- is_successful = span.status.status_code != StatusCode.ERROR
135
-
136
- update_kwargs = {
137
- 'event_id': event_id,
138
- 'result': result,
139
- 'is_finished': True,
140
- 'is_successful': is_successful
141
- }
142
-
143
- if cost is not None:
144
- update_kwargs['cost_added'] = cost
145
-
146
- client.session.update_event(**update_kwargs)
147
-
148
- except Exception as e:
149
- logger.error(f"Failed to update event from span: {e}")
157
+ """Deprecated: events are immutable; no updates performed."""
158
+ return
150
159
 
151
160
  def _extract_description(self, span: ReadableSpan, attributes: Dict[str, Any]) -> str:
152
161
  """Extract description from span attributes"""
@@ -186,74 +195,60 @@ class LucidicSpanExporter(SpanExporter):
186
195
 
187
196
  return "Response received"
188
197
 
189
- def _extract_images(self, attributes: Dict[str, Any]) -> List[str]:
190
- """Extract base64 images from attributes"""
191
- images = []
192
-
193
- # Check prompts for multimodal content
194
- prompts = attributes.get(SpanAttributes.LLM_PROMPTS) or \
195
- attributes.get('gen_ai.prompt')
196
-
197
- if isinstance(prompts, list):
198
- for prompt in prompts:
199
- if isinstance(prompt, dict) and 'content' in prompt:
200
- content = prompt['content']
201
- if isinstance(content, list):
202
- for item in content:
203
- if isinstance(item, dict) and item.get('type') == 'image_url':
204
- image_url = item.get('image_url', {})
205
- if isinstance(image_url, dict) and 'url' in image_url:
206
- url = image_url['url']
207
- if url.startswith('data:image'):
208
- images.append(url)
209
-
210
- return images
198
+ def _detect_provider_name(self, attributes: Dict[str, Any]) -> str:
199
+ name = attributes.get('gen_ai.system') or attributes.get('service.name')
200
+ if name:
201
+ return str(name)
202
+ return "openai" if 'openai' in (str(attributes.get('service.name', '')).lower()) else "unknown"
211
203
 
212
- def _format_messages(self, messages: List[Any]) -> str:
213
- """Format message list into description"""
214
- formatted = []
215
-
216
- for msg in messages:
217
- if isinstance(msg, dict):
218
- role = msg.get('role', 'unknown')
219
- content = msg.get('content', '')
220
-
221
- if isinstance(content, str):
222
- formatted.append(f"{role}: {content}")
223
- elif isinstance(content, list):
224
- # Handle multimodal content
225
- text_parts = []
226
- for item in content:
227
- if isinstance(item, dict) and item.get('type') == 'text':
228
- text_parts.append(item.get('text', ''))
229
- if text_parts:
230
- formatted.append(f"{role}: {' '.join(text_parts)}")
231
-
232
- return '\n'.join(formatted) if formatted else "Model request"
204
+
205
+ def _extract_params(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
206
+ return {
207
+ "temperature": attributes.get('gen_ai.request.temperature'),
208
+ "max_tokens": attributes.get('gen_ai.request.max_tokens'),
209
+ "top_p": attributes.get('gen_ai.request.top_p'),
210
+ }
211
+
212
+ 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
+ )
218
+
219
+ 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
+ )
233
225
 
234
226
  def _calculate_cost(self, attributes: Dict[str, Any]) -> Optional[float]:
235
- """Calculate cost from token usage"""
236
- prompt_tokens = attributes.get(SpanAttributes.LLM_USAGE_PROMPT_TOKENS) or \
237
- attributes.get('gen_ai.usage.prompt_tokens') or 0
238
- completion_tokens = attributes.get(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS) or \
239
- attributes.get('gen_ai.usage.completion_tokens') or 0
240
-
241
- if prompt_tokens or completion_tokens:
242
- model = attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or \
243
- attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or \
244
- attributes.get('gen_ai.request.model')
245
-
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)
238
+ if total_tokens > 0:
239
+ model = (
240
+ attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) or
241
+ attributes.get(SpanAttributes.LLM_REQUEST_MODEL) or
242
+ attributes.get('gen_ai.response.model') or
243
+ attributes.get('gen_ai.request.model')
244
+ )
246
245
  if model:
247
- return calculate_cost(prompt_tokens, completion_tokens, model)
248
-
246
+ usage = {"prompt_tokens": prompt_tokens or 0, "completion_tokens": completion_tokens or 0, "total_tokens": total_tokens}
247
+ return calculate_cost(model, usage)
249
248
  return None
250
249
 
251
250
  def shutdown(self) -> None:
252
- """Shutdown the exporter"""
253
- # Process any remaining pending events
254
- if self.pending_events:
255
- logger.warning(f"Shutting down with {len(self.pending_events)} pending events")
256
-
251
+ return None
252
+
257
253
  def force_flush(self, timeout_millis: int = 30000) -> bool:
258
- """Force flush any pending spans"""
259
254
  return True
@@ -1,4 +1,9 @@
1
- """Custom span processor for real-time Lucidic event handling"""
1
+ """Custom span processor for real-time Lucidic event handling
2
+
3
+ Updated to stamp spans with the correct session id from async-safe
4
+ context, and to create events for that session without mutating the
5
+ global client session.
6
+ """
2
7
  import os
3
8
  import logging
4
9
  import json
@@ -10,7 +15,8 @@ from opentelemetry.semconv_ai import SpanAttributes
10
15
 
11
16
  from lucidicai.client import Client
12
17
  from lucidicai.model_pricing import calculate_cost
13
- from .utils.image_storage import get_stored_images, clear_stored_images
18
+ from lucidicai.context import current_session_id
19
+ from .utils.image_storage import get_stored_images, clear_stored_images, get_image_by_placeholder
14
20
  from .utils.text_storage import get_stored_text, clear_stored_texts
15
21
 
16
22
  logger = logging.getLogger("Lucidic")
@@ -35,11 +41,15 @@ class LucidicSpanProcessor(SpanProcessor):
35
41
  logger.info(f"[SpanProcessor] on_start called for span: {span.name}")
36
42
  # logger.info(f"[SpanProcessor] Span attributes at start: {dict(span.attributes or {})}")
37
43
 
44
+ # Stamp session id from contextvars if available
45
+ try:
46
+ sid = current_session_id.get(None)
47
+ if sid:
48
+ span.set_attribute('lucidic.session_id', sid)
49
+ except Exception:
50
+ pass
51
+
38
52
  client = Client()
39
- if not client.session:
40
- logger.debug("No active session, skipping span tracking")
41
- return
42
-
43
53
  # Only process LLM spans
44
54
  if not self._is_llm_span(span):
45
55
  if DEBUG:
@@ -88,9 +98,6 @@ class LucidicSpanProcessor(SpanProcessor):
88
98
  return
89
99
 
90
100
  client = Client()
91
- if not client.session:
92
- return
93
-
94
101
  span_context = self.span_contexts.pop(span_id, {})
95
102
 
96
103
  # Create event with all the attributes now available
@@ -127,7 +134,7 @@ class LucidicSpanProcessor(SpanProcessor):
127
134
 
128
135
  # Check span name
129
136
  span_name_lower = span.name.lower()
130
- llm_patterns = ['openai', 'anthropic', 'chat', 'completion', 'embedding', 'gemini', 'claude']
137
+ llm_patterns = ['openai', 'anthropic', 'chat', 'completion', 'embedding', 'gemini', 'claude', 'bedrock', 'vertex', 'cohere', 'groq']
131
138
 
132
139
  if any(pattern in span_name_lower for pattern in llm_patterns):
133
140
  return True
@@ -248,6 +255,22 @@ class LucidicSpanProcessor(SpanProcessor):
248
255
  # Check success
249
256
  is_successful = span.status.status_code != StatusCode.ERROR
250
257
 
258
+ # Resolve target session id for this span
259
+ target_session_id = attributes.get('lucidic.session_id')
260
+ if not target_session_id:
261
+ try:
262
+ target_session_id = current_session_id.get(None)
263
+ except Exception:
264
+ target_session_id = None
265
+ if not target_session_id:
266
+ # Fallback to global client session if set
267
+ if getattr(client, 'session', None) and getattr(client.session, 'session_id', None):
268
+ target_session_id = client.session.session_id
269
+ if not target_session_id:
270
+ if DEBUG:
271
+ logger.info("[SpanProcessor] No session id found for span; skipping event creation")
272
+ return None
273
+
251
274
  # Create event with all data
252
275
  event_kwargs = {
253
276
  'description': description,
@@ -268,8 +291,8 @@ class LucidicSpanProcessor(SpanProcessor):
268
291
  if step_id:
269
292
  event_kwargs['step_id'] = step_id
270
293
 
271
- # Create the event (already completed)
272
- event_id = client.session.create_event(**event_kwargs)
294
+ # Create the event (already completed) for the resolved session id
295
+ event_id = client.create_event_for_session(target_session_id, **event_kwargs)
273
296
 
274
297
  return event_id
275
298
 
@@ -397,53 +420,48 @@ class LucidicSpanProcessor(SpanProcessor):
397
420
  while True:
398
421
  prefix = f"gen_ai.prompt.{i}"
399
422
  role = attributes.get(f"{prefix}.role")
400
-
401
- if not role:
402
- break
403
-
404
- message = {"role": role}
405
-
406
- # Get content
407
423
  content = attributes.get(f"{prefix}.content")
424
+
425
+ # Check if any attributes exist for this index
426
+ attr_has_any = False
427
+ for key in attributes.keys():
428
+ if isinstance(key, str) and key.startswith(f"{prefix}."):
429
+ attr_has_any = True
430
+ break
431
+
432
+ stored_text = get_stored_text(i)
433
+ stored_images = get_stored_images()
434
+
435
+ # Break if no indexed attrs and not the first synthetic message case
436
+ if not attr_has_any and not (i == 0 and (stored_text or stored_images)):
437
+ break
438
+
439
+ message = {"role": role or "user"}
440
+
408
441
  if content:
409
442
  # Try to parse JSON content (for multimodal)
410
443
  try:
411
444
  import json
412
445
  parsed_content = json.loads(content)
413
446
  message["content"] = parsed_content
414
- except:
447
+ except Exception:
415
448
  message["content"] = content
416
449
  else:
417
- # Content might be missing for multimodal messages due to size limits
418
- # Check if we have stored text and/or images in thread-local storage
419
- stored_text = get_stored_text(i)
420
- stored_images = get_stored_images()
421
-
422
- if stored_text or stored_images:
450
+ # Content missing: synthesize from stored text/images
451
+ synthetic_content = []
452
+ if stored_text and i == 0:
453
+ synthetic_content.append({"type": "text", "text": stored_text})
454
+ if stored_images and i == 0:
455
+ for img in stored_images:
456
+ synthetic_content.append({"type": "image_url", "image_url": {"url": img}})
457
+ if synthetic_content:
423
458
  if DEBUG:
424
- logger.info(f"[SpanProcessor] No content for message {i}, but found stored text/images")
425
-
426
- # Create synthetic content with both text and images
427
- synthetic_content = []
428
-
429
- # Add text if available
430
- if stored_text:
431
- synthetic_content.append({
432
- "type": "text",
433
- "text": stored_text
434
- })
435
-
436
- # Add images if available
437
- if stored_images and i == 0: # Assume first message might have images
438
- for idx, img in enumerate(stored_images):
439
- synthetic_content.append({
440
- "type": "image_url",
441
- "image_url": {"url": img}
442
- })
443
-
444
- if synthetic_content:
445
- message["content"] = synthetic_content
446
-
459
+ logger.info(f"[SpanProcessor] Using stored text/images for message {i}")
460
+ message["content"] = synthetic_content
461
+ elif not attr_has_any:
462
+ # No real attributes and nothing stored to synthesize -> stop
463
+ break
464
+
447
465
  messages.append(message)
448
466
  i += 1
449
467