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.
- lucidicai/__init__.py +367 -899
- lucidicai/api/__init__.py +1 -0
- lucidicai/api/client.py +218 -0
- lucidicai/api/resources/__init__.py +1 -0
- lucidicai/api/resources/dataset.py +192 -0
- lucidicai/api/resources/event.py +88 -0
- lucidicai/api/resources/session.py +126 -0
- lucidicai/core/__init__.py +1 -0
- lucidicai/core/config.py +223 -0
- lucidicai/core/errors.py +60 -0
- lucidicai/core/types.py +35 -0
- lucidicai/sdk/__init__.py +1 -0
- lucidicai/sdk/context.py +231 -0
- lucidicai/sdk/decorators.py +187 -0
- lucidicai/sdk/error_boundary.py +299 -0
- lucidicai/sdk/event.py +126 -0
- lucidicai/sdk/event_builder.py +304 -0
- lucidicai/sdk/features/__init__.py +1 -0
- lucidicai/sdk/features/dataset.py +605 -0
- lucidicai/sdk/features/feature_flag.py +383 -0
- lucidicai/sdk/init.py +361 -0
- lucidicai/sdk/shutdown_manager.py +302 -0
- lucidicai/telemetry/context_bridge.py +82 -0
- lucidicai/telemetry/context_capture_processor.py +25 -9
- lucidicai/telemetry/litellm_bridge.py +20 -24
- lucidicai/telemetry/lucidic_exporter.py +99 -60
- lucidicai/telemetry/openai_patch.py +295 -0
- lucidicai/telemetry/openai_uninstrument.py +87 -0
- lucidicai/telemetry/telemetry_init.py +16 -1
- lucidicai/telemetry/utils/model_pricing.py +278 -0
- lucidicai/utils/__init__.py +1 -0
- lucidicai/utils/images.py +337 -0
- lucidicai/utils/logger.py +168 -0
- lucidicai/utils/queue.py +393 -0
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/METADATA +1 -1
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/RECORD +38 -9
- {lucidicai-2.0.2.dist-info → lucidicai-2.1.1.dist-info}/WHEEL +0 -0
- {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
|
-
#
|
|
28
|
+
# Try to get from contextvars first
|
|
30
29
|
session_id = None
|
|
30
|
+
parent_event_id = None
|
|
31
|
+
|
|
31
32
|
try:
|
|
32
33
|
session_id = current_session_id.get(None)
|
|
33
34
|
except Exception:
|
|
34
35
|
pass
|
|
35
36
|
|
|
36
|
-
# Capture parent event ID from context
|
|
37
|
-
parent_event_id = None
|
|
38
37
|
try:
|
|
39
38
|
parent_event_id = current_parent_event_id.get(None)
|
|
40
39
|
except Exception:
|
|
41
40
|
pass
|
|
42
41
|
|
|
42
|
+
# If not found in contextvars, try OpenTelemetry baggage
|
|
43
|
+
# This handles cases where spans are created in different threads
|
|
44
|
+
if not session_id or not parent_event_id:
|
|
45
|
+
baggage_session, baggage_parent = extract_lucidic_context(parent_context)
|
|
46
|
+
if not session_id and baggage_session:
|
|
47
|
+
session_id = baggage_session
|
|
48
|
+
debug(f"[ContextCapture] Got session_id from OTel baggage for span {span.name}")
|
|
49
|
+
if not parent_event_id and baggage_parent:
|
|
50
|
+
parent_event_id = baggage_parent
|
|
51
|
+
debug(f"[ContextCapture] Got parent_event_id from OTel baggage for span {span.name}")
|
|
52
|
+
|
|
53
|
+
# Add debug logging to understand context propagation
|
|
54
|
+
debug(f"[ContextCapture] Processing span '{span.name}' - session: {truncate_id(session_id)}, parent: {truncate_id(parent_event_id)}")
|
|
55
|
+
|
|
43
56
|
# Store in span attributes for later retrieval
|
|
44
57
|
if session_id:
|
|
45
58
|
span.set_attribute("lucidic.session_id", session_id)
|
|
59
|
+
debug(f"[ContextCapture] Set session_id attribute for span {span.name}")
|
|
46
60
|
|
|
47
61
|
if parent_event_id:
|
|
48
62
|
span.set_attribute("lucidic.parent_event_id", parent_event_id)
|
|
49
|
-
|
|
63
|
+
debug(f"[ContextCapture] Captured parent_event_id {truncate_id(parent_event_id)} for span {span.name}")
|
|
64
|
+
else:
|
|
65
|
+
debug(f"[ContextCapture] No parent_event_id available for span {span.name}")
|
|
50
66
|
|
|
51
67
|
except Exception as e:
|
|
52
68
|
# Never fail span creation due to context capture
|
|
53
|
-
|
|
69
|
+
verbose(f"[ContextCapture] Failed to capture context: {e}")
|
|
54
70
|
|
|
55
71
|
def on_end(self, span: ReadableSpan) -> None:
|
|
56
72
|
"""Called when a span ends - no action needed."""
|
|
@@ -14,9 +14,10 @@ except ImportError:
|
|
|
14
14
|
def __init__(self, **kwargs):
|
|
15
15
|
pass
|
|
16
16
|
|
|
17
|
-
from lucidicai.
|
|
18
|
-
from lucidicai.
|
|
19
|
-
from lucidicai.
|
|
17
|
+
from lucidicai.sdk.event import create_event
|
|
18
|
+
from lucidicai.sdk.init import get_session_id
|
|
19
|
+
from lucidicai.telemetry.utils.model_pricing import calculate_cost
|
|
20
|
+
from lucidicai.sdk.context import current_parent_event_id
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger("Lucidic")
|
|
22
23
|
DEBUG = os.getenv("LUCIDIC_DEBUG", "False") == "True"
|
|
@@ -76,16 +77,12 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
76
77
|
def log_pre_api_call(self, model, messages, kwargs):
|
|
77
78
|
"""Called before the LLM API call"""
|
|
78
79
|
try:
|
|
79
|
-
|
|
80
|
-
if not
|
|
80
|
+
session_id = get_session_id()
|
|
81
|
+
if not session_id:
|
|
81
82
|
return
|
|
82
83
|
|
|
83
84
|
# Extract description from messages
|
|
84
85
|
description = self._format_messages(messages)
|
|
85
|
-
|
|
86
|
-
# Apply masking if configured
|
|
87
|
-
if hasattr(client, 'mask') and callable(client.mask):
|
|
88
|
-
description = client.mask(description)
|
|
89
86
|
|
|
90
87
|
# Store pre-call info for later use
|
|
91
88
|
call_id = kwargs.get("litellm_call_id", str(time.time())) if kwargs else str(time.time())
|
|
@@ -109,8 +106,8 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
109
106
|
self._register_callback(callback_id)
|
|
110
107
|
|
|
111
108
|
try:
|
|
112
|
-
|
|
113
|
-
if not
|
|
109
|
+
session_id = get_session_id()
|
|
110
|
+
if not session_id:
|
|
114
111
|
self._complete_callback(callback_id)
|
|
115
112
|
return
|
|
116
113
|
|
|
@@ -129,10 +126,6 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
129
126
|
# Extract response content
|
|
130
127
|
result = self._extract_response_content(response_obj)
|
|
131
128
|
|
|
132
|
-
# Apply masking to result if configured
|
|
133
|
-
if hasattr(client, 'mask') and callable(client.mask):
|
|
134
|
-
result = client.mask(result)
|
|
135
|
-
|
|
136
129
|
# Calculate cost if usage info is available
|
|
137
130
|
usage = self._extract_usage(response_obj)
|
|
138
131
|
cost = None
|
|
@@ -142,7 +135,7 @@ class LucidicLiteLLMCallback(CustomLogger):
|
|
|
142
135
|
# Extract any images from multimodal requests
|
|
143
136
|
images = self._extract_images_from_messages(messages)
|
|
144
137
|
|
|
145
|
-
#
|
|
138
|
+
# Get parent event ID from context
|
|
146
139
|
parent_id = None
|
|
147
140
|
try:
|
|
148
141
|
parent_id = current_parent_event_id.get(None)
|
|
@@ -150,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
|
-
|
|
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
|
-
|
|
189
|
-
if not
|
|
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
|
-
|
|
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
|
|
16
|
-
from
|
|
17
|
-
from
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
37
|
-
if
|
|
38
|
-
|
|
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
|
-
|
|
35
|
+
error(f"[Telemetry] Failed to export spans: {e}")
|
|
42
36
|
return SpanExportResult.FAILURE
|
|
43
37
|
|
|
44
|
-
def _process_span(self, span: ReadableSpan
|
|
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
|
-
|
|
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)
|
|
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
|
-
#
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
185
|
+
|
|
186
|
+
return create_event(**event_kwargs)
|
|
151
187
|
|
|
152
188
|
except Exception as e:
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|