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.
- lucidicai/__init__.py +648 -351
- lucidicai/client.py +327 -37
- lucidicai/constants.py +7 -37
- lucidicai/context.py +144 -0
- lucidicai/dataset.py +112 -0
- lucidicai/decorators.py +96 -325
- lucidicai/errors.py +33 -0
- lucidicai/event.py +50 -59
- lucidicai/event_queue.py +466 -0
- lucidicai/feature_flag.py +336 -0
- lucidicai/model_pricing.py +11 -0
- lucidicai/session.py +9 -71
- lucidicai/singleton.py +20 -17
- lucidicai/streaming.py +15 -50
- lucidicai/telemetry/context_capture_processor.py +65 -0
- lucidicai/telemetry/extract.py +192 -0
- lucidicai/telemetry/litellm_bridge.py +80 -45
- lucidicai/telemetry/lucidic_exporter.py +139 -144
- lucidicai/telemetry/lucidic_span_processor.py +67 -49
- lucidicai/telemetry/otel_handlers.py +207 -59
- lucidicai/telemetry/otel_init.py +163 -51
- lucidicai/telemetry/otel_provider.py +15 -5
- lucidicai/telemetry/telemetry_init.py +189 -0
- lucidicai/telemetry/utils/universal_image_interceptor.py +89 -0
- {lucidicai-1.3.2.dist-info → lucidicai-2.0.1.dist-info}/METADATA +1 -1
- {lucidicai-1.3.2.dist-info → lucidicai-2.0.1.dist-info}/RECORD +28 -21
- {lucidicai-1.3.2.dist-info → lucidicai-2.0.1.dist-info}/WHEEL +0 -0
- {lucidicai-1.3.2.dist-info → lucidicai-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
"""Custom OpenTelemetry exporter for Lucidic
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
32
|
-
logger.debug("
|
|
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
|
-
"""
|
|
45
|
+
"""Convert a single LLM span into a typed, immutable event."""
|
|
45
46
|
try:
|
|
46
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
126
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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 .
|
|
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.
|
|
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
|
|
418
|
-
|
|
419
|
-
stored_text
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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]
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|