prela 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prela/__init__.py +394 -0
- prela/_version.py +3 -0
- prela/contrib/CLI.md +431 -0
- prela/contrib/README.md +118 -0
- prela/contrib/__init__.py +5 -0
- prela/contrib/cli.py +1063 -0
- prela/contrib/explorer.py +571 -0
- prela/core/__init__.py +64 -0
- prela/core/clock.py +98 -0
- prela/core/context.py +228 -0
- prela/core/replay.py +403 -0
- prela/core/sampler.py +178 -0
- prela/core/span.py +295 -0
- prela/core/tracer.py +498 -0
- prela/evals/__init__.py +94 -0
- prela/evals/assertions/README.md +484 -0
- prela/evals/assertions/__init__.py +78 -0
- prela/evals/assertions/base.py +90 -0
- prela/evals/assertions/multi_agent.py +625 -0
- prela/evals/assertions/semantic.py +223 -0
- prela/evals/assertions/structural.py +443 -0
- prela/evals/assertions/tool.py +380 -0
- prela/evals/case.py +370 -0
- prela/evals/n8n/__init__.py +69 -0
- prela/evals/n8n/assertions.py +450 -0
- prela/evals/n8n/runner.py +497 -0
- prela/evals/reporters/README.md +184 -0
- prela/evals/reporters/__init__.py +32 -0
- prela/evals/reporters/console.py +251 -0
- prela/evals/reporters/json.py +176 -0
- prela/evals/reporters/junit.py +278 -0
- prela/evals/runner.py +525 -0
- prela/evals/suite.py +316 -0
- prela/exporters/__init__.py +27 -0
- prela/exporters/base.py +189 -0
- prela/exporters/console.py +443 -0
- prela/exporters/file.py +322 -0
- prela/exporters/http.py +394 -0
- prela/exporters/multi.py +154 -0
- prela/exporters/otlp.py +388 -0
- prela/instrumentation/ANTHROPIC.md +297 -0
- prela/instrumentation/LANGCHAIN.md +480 -0
- prela/instrumentation/OPENAI.md +59 -0
- prela/instrumentation/__init__.py +49 -0
- prela/instrumentation/anthropic.py +1436 -0
- prela/instrumentation/auto.py +129 -0
- prela/instrumentation/base.py +436 -0
- prela/instrumentation/langchain.py +959 -0
- prela/instrumentation/llamaindex.py +719 -0
- prela/instrumentation/multi_agent/__init__.py +48 -0
- prela/instrumentation/multi_agent/autogen.py +357 -0
- prela/instrumentation/multi_agent/crewai.py +404 -0
- prela/instrumentation/multi_agent/langgraph.py +299 -0
- prela/instrumentation/multi_agent/models.py +203 -0
- prela/instrumentation/multi_agent/swarm.py +231 -0
- prela/instrumentation/n8n/__init__.py +68 -0
- prela/instrumentation/n8n/code_node.py +534 -0
- prela/instrumentation/n8n/models.py +336 -0
- prela/instrumentation/n8n/webhook.py +489 -0
- prela/instrumentation/openai.py +1198 -0
- prela/license.py +245 -0
- prela/replay/__init__.py +31 -0
- prela/replay/comparison.py +390 -0
- prela/replay/engine.py +1227 -0
- prela/replay/loader.py +231 -0
- prela/replay/result.py +196 -0
- prela-0.1.0.dist-info/METADATA +399 -0
- prela-0.1.0.dist-info/RECORD +71 -0
- prela-0.1.0.dist-info/WHEEL +4 -0
- prela-0.1.0.dist-info/entry_points.txt +2 -0
- prela-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""Instrumentation for LlamaIndex (llama-index-core>=0.10.0).
|
|
2
|
+
|
|
3
|
+
This module provides automatic tracing for LlamaIndex operations via callbacks:
|
|
4
|
+
- LLM calls (OpenAI, Anthropic, etc. through LlamaIndex)
|
|
5
|
+
- Embeddings generation
|
|
6
|
+
- Query engine operations
|
|
7
|
+
- Retrieval operations with node scores
|
|
8
|
+
- Synthesis operations
|
|
9
|
+
|
|
10
|
+
The instrumentation works by injecting a PrelaHandler into LlamaIndex's
|
|
11
|
+
callback manager, which automatically captures all executions.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
```python
|
|
15
|
+
from prela.instrumentation.llamaindex import LlamaIndexInstrumentor
|
|
16
|
+
from prela.core.tracer import Tracer
|
|
17
|
+
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
|
|
18
|
+
|
|
19
|
+
# Setup instrumentation
|
|
20
|
+
tracer = Tracer()
|
|
21
|
+
instrumentor = LlamaIndexInstrumentor()
|
|
22
|
+
instrumentor.instrument(tracer)
|
|
23
|
+
|
|
24
|
+
# Now all LlamaIndex operations are automatically traced
|
|
25
|
+
documents = SimpleDirectoryReader("data").load_data()
|
|
26
|
+
index = VectorStoreIndex.from_documents(documents)
|
|
27
|
+
query_engine = index.as_query_engine()
|
|
28
|
+
response = query_engine.query("What is the main topic?")
|
|
29
|
+
```
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
36
|
+
from uuid import UUID
|
|
37
|
+
|
|
38
|
+
from prela.core.clock import now
|
|
39
|
+
from prela.core.span import Span, SpanStatus, SpanType
|
|
40
|
+
from prela.instrumentation.base import Instrumentor
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from prela.core.tracer import Tracer
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Maximum content length for truncation
|
|
48
|
+
_MAX_CONTENT_LEN = 500
|
|
49
|
+
_MAX_NODE_TEXT_LEN = 200
|
|
50
|
+
_MAX_ITEMS = 5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PrelaHandler:
|
|
54
|
+
"""LlamaIndex callback handler that creates Prela spans.
|
|
55
|
+
|
|
56
|
+
This handler implements LlamaIndex's BaseCallbackHandler interface and
|
|
57
|
+
creates spans for all major LlamaIndex operations. It maintains a mapping
|
|
58
|
+
from event_id to span to properly handle concurrent executions and nested
|
|
59
|
+
operations.
|
|
60
|
+
|
|
61
|
+
The handler tracks:
|
|
62
|
+
- LLM calls: Model invocations with prompts and responses
|
|
63
|
+
- Embeddings: Vector generation operations
|
|
64
|
+
- Retrieval: Document retrieval with similarity scores
|
|
65
|
+
- Query: Query engine operations
|
|
66
|
+
- Synthesis: Response synthesis from retrieved documents
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, tracer: Tracer) -> None:
|
|
70
|
+
"""Initialize the callback handler.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
tracer: The tracer to use for creating spans
|
|
74
|
+
"""
|
|
75
|
+
self._tracer = tracer
|
|
76
|
+
# Map event_id -> span for tracking concurrent operations
|
|
77
|
+
self._spans: dict[str, Span] = {}
|
|
78
|
+
# Map event_id -> context manager for proper cleanup
|
|
79
|
+
self._contexts: dict[str, Any] = {}
|
|
80
|
+
# Map event_id -> ReplayCapture for replay data
|
|
81
|
+
self._replay_captures: dict[str, Any] = {}
|
|
82
|
+
|
|
83
|
+
# Required attributes for LlamaIndex callback interface
|
|
84
|
+
self.event_starts_to_ignore: list[str] = []
|
|
85
|
+
self.event_ends_to_ignore: list[str] = []
|
|
86
|
+
|
|
87
|
+
def on_event_start(
|
|
88
|
+
self,
|
|
89
|
+
event_type: str,
|
|
90
|
+
payload: Optional[dict[str, Any]] = None,
|
|
91
|
+
event_id: str = "",
|
|
92
|
+
parent_id: str = "",
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Called when an event starts.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
event_type: Type of event (LLM, EMBEDDING, RETRIEVE, etc.)
|
|
99
|
+
payload: Event-specific data
|
|
100
|
+
event_id: Unique identifier for this event
|
|
101
|
+
parent_id: ID of parent event (if nested)
|
|
102
|
+
**kwargs: Additional arguments
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The event_id for tracking
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
# Import here to avoid requiring llama-index at module level
|
|
109
|
+
try:
|
|
110
|
+
from llama_index.core.callbacks.schema import CBEventType
|
|
111
|
+
except ImportError:
|
|
112
|
+
logger.debug("llama_index.core not available, skipping event")
|
|
113
|
+
return event_id
|
|
114
|
+
|
|
115
|
+
# Map event type to span type
|
|
116
|
+
span_type = self._map_event_to_span_type(event_type)
|
|
117
|
+
span_name = self._get_span_name(event_type, payload)
|
|
118
|
+
|
|
119
|
+
# Start the span
|
|
120
|
+
ctx = self._tracer.span(
|
|
121
|
+
name=span_name,
|
|
122
|
+
span_type=span_type,
|
|
123
|
+
)
|
|
124
|
+
span = ctx.__enter__()
|
|
125
|
+
|
|
126
|
+
# Store span and context for later retrieval
|
|
127
|
+
self._spans[event_id] = span
|
|
128
|
+
self._contexts[event_id] = ctx
|
|
129
|
+
|
|
130
|
+
# Capture event-specific attributes
|
|
131
|
+
self._capture_start_attributes(span, event_type, payload)
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
# Never break user code due to instrumentation errors
|
|
135
|
+
logger.debug(f"Error in on_event_start: {e}")
|
|
136
|
+
|
|
137
|
+
return event_id
|
|
138
|
+
|
|
139
|
+
def on_event_end(
|
|
140
|
+
self,
|
|
141
|
+
event_type: str,
|
|
142
|
+
payload: Optional[dict[str, Any]] = None,
|
|
143
|
+
event_id: str = "",
|
|
144
|
+
**kwargs: Any,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Called when an event ends.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
event_type: Type of event (LLM, EMBEDDING, RETRIEVE, etc.)
|
|
150
|
+
payload: Event-specific response data
|
|
151
|
+
event_id: Unique identifier for this event
|
|
152
|
+
**kwargs: Additional arguments
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
# Retrieve the span for this event
|
|
156
|
+
span = self._spans.get(event_id)
|
|
157
|
+
ctx = self._contexts.get(event_id)
|
|
158
|
+
|
|
159
|
+
if not span or not ctx:
|
|
160
|
+
logger.debug(f"No span found for event_id: {event_id}")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Capture end attributes
|
|
164
|
+
self._capture_end_attributes(span, event_type, payload)
|
|
165
|
+
|
|
166
|
+
# End the span
|
|
167
|
+
ctx.__exit__(None, None, None)
|
|
168
|
+
|
|
169
|
+
# Clean up tracking dictionaries
|
|
170
|
+
self._spans.pop(event_id, None)
|
|
171
|
+
self._contexts.pop(event_id, None)
|
|
172
|
+
|
|
173
|
+
# Clean up any remaining replay captures for this span
|
|
174
|
+
span_id = str(id(span))
|
|
175
|
+
if span_id in self._replay_captures:
|
|
176
|
+
del self._replay_captures[span_id]
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
# Never break user code due to instrumentation errors
|
|
180
|
+
logger.debug(f"Error in on_event_end: {e}")
|
|
181
|
+
|
|
182
|
+
def start_trace(self, trace_id: Optional[str] = None) -> None:
|
|
183
|
+
"""Called when a trace starts.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
trace_id: Optional trace identifier
|
|
187
|
+
"""
|
|
188
|
+
# LlamaIndex specific - not used for our purposes
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
def end_trace(
|
|
192
|
+
self,
|
|
193
|
+
trace_id: Optional[str] = None,
|
|
194
|
+
trace_map: Optional[dict[str, list[str]]] = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Called when a trace ends.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
trace_id: Optional trace identifier
|
|
200
|
+
trace_map: Optional trace mapping
|
|
201
|
+
"""
|
|
202
|
+
# LlamaIndex specific - not used for our purposes
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
def _map_event_to_span_type(self, event_type: str) -> SpanType:
|
|
206
|
+
"""Map LlamaIndex event type to Prela span type.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
event_type: LlamaIndex CBEventType string
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Corresponding SpanType
|
|
213
|
+
"""
|
|
214
|
+
# Import here to avoid circular dependency
|
|
215
|
+
try:
|
|
216
|
+
from llama_index.core.callbacks.schema import CBEventType
|
|
217
|
+
|
|
218
|
+
event_type_map = {
|
|
219
|
+
CBEventType.LLM: SpanType.LLM,
|
|
220
|
+
CBEventType.EMBEDDING: SpanType.EMBEDDING,
|
|
221
|
+
CBEventType.RETRIEVE: SpanType.RETRIEVAL,
|
|
222
|
+
CBEventType.QUERY: SpanType.AGENT,
|
|
223
|
+
CBEventType.SYNTHESIZE: SpanType.AGENT,
|
|
224
|
+
CBEventType.TREE: SpanType.AGENT,
|
|
225
|
+
CBEventType.SUB_QUESTION: SpanType.AGENT,
|
|
226
|
+
CBEventType.CHUNKING: SpanType.CUSTOM,
|
|
227
|
+
CBEventType.NODE_PARSING: SpanType.CUSTOM,
|
|
228
|
+
CBEventType.TEMPLATING: SpanType.CUSTOM,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return event_type_map.get(event_type, SpanType.CUSTOM)
|
|
232
|
+
|
|
233
|
+
except (ImportError, AttributeError):
|
|
234
|
+
# If CBEventType not available, default to CUSTOM
|
|
235
|
+
return SpanType.CUSTOM
|
|
236
|
+
|
|
237
|
+
def _get_span_name(
|
|
238
|
+
self, event_type: str, payload: Optional[dict[str, Any]]
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Generate a descriptive span name.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
event_type: LlamaIndex event type
|
|
244
|
+
payload: Event payload
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Human-readable span name
|
|
248
|
+
"""
|
|
249
|
+
base_name = f"llamaindex.{event_type.lower()}"
|
|
250
|
+
|
|
251
|
+
# Add more specific info if available
|
|
252
|
+
if payload:
|
|
253
|
+
if event_type == "LLM" and "serialized" in payload:
|
|
254
|
+
model = payload.get("serialized", {}).get("model", "")
|
|
255
|
+
if model:
|
|
256
|
+
return f"{base_name}.{model}"
|
|
257
|
+
|
|
258
|
+
if event_type == "RETRIEVE" and "retriever_type" in payload:
|
|
259
|
+
retriever = payload.get("retriever_type", "")
|
|
260
|
+
if retriever:
|
|
261
|
+
return f"{base_name}.{retriever}"
|
|
262
|
+
|
|
263
|
+
return base_name
|
|
264
|
+
|
|
265
|
+
def _capture_start_attributes(
|
|
266
|
+
self, span: Span, event_type: str, payload: Optional[dict[str, Any]]
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Capture event-specific attributes at start.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
span: The span to add attributes to
|
|
272
|
+
event_type: LlamaIndex event type
|
|
273
|
+
payload: Event payload
|
|
274
|
+
"""
|
|
275
|
+
if not payload:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
# Common attributes
|
|
280
|
+
span.set_attribute("llamaindex.event_type", event_type)
|
|
281
|
+
|
|
282
|
+
# LLM-specific attributes
|
|
283
|
+
if event_type == "LLM":
|
|
284
|
+
self._capture_llm_start(span, payload)
|
|
285
|
+
|
|
286
|
+
# Embedding-specific attributes
|
|
287
|
+
elif event_type == "EMBEDDING":
|
|
288
|
+
self._capture_embedding_start(span, payload)
|
|
289
|
+
|
|
290
|
+
# Retrieval-specific attributes
|
|
291
|
+
elif event_type == "RETRIEVE":
|
|
292
|
+
self._capture_retrieve_start(span, payload)
|
|
293
|
+
|
|
294
|
+
# Query-specific attributes
|
|
295
|
+
elif event_type == "QUERY":
|
|
296
|
+
self._capture_query_start(span, payload)
|
|
297
|
+
|
|
298
|
+
# Synthesis-specific attributes
|
|
299
|
+
elif event_type == "SYNTHESIZE":
|
|
300
|
+
self._capture_synthesize_start(span, payload)
|
|
301
|
+
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.debug(f"Error capturing start attributes: {e}")
|
|
304
|
+
|
|
305
|
+
def _capture_end_attributes(
|
|
306
|
+
self, span: Span, event_type: str, payload: Optional[dict[str, Any]]
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Capture event-specific attributes at end.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
span: The span to add attributes to
|
|
312
|
+
event_type: LlamaIndex event type
|
|
313
|
+
payload: Event payload
|
|
314
|
+
"""
|
|
315
|
+
if not payload:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
# LLM-specific response
|
|
320
|
+
if event_type == "LLM":
|
|
321
|
+
self._capture_llm_end(span, payload)
|
|
322
|
+
|
|
323
|
+
# Embedding-specific response
|
|
324
|
+
elif event_type == "EMBEDDING":
|
|
325
|
+
self._capture_embedding_end(span, payload)
|
|
326
|
+
|
|
327
|
+
# Retrieval-specific response
|
|
328
|
+
elif event_type == "RETRIEVE":
|
|
329
|
+
self._capture_retrieve_end(span, payload)
|
|
330
|
+
|
|
331
|
+
# Query-specific response
|
|
332
|
+
elif event_type == "QUERY":
|
|
333
|
+
self._capture_query_end(span, payload)
|
|
334
|
+
|
|
335
|
+
# Synthesis-specific response
|
|
336
|
+
elif event_type == "SYNTHESIZE":
|
|
337
|
+
self._capture_synthesize_end(span, payload)
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
logger.debug(f"Error capturing end attributes: {e}")
|
|
341
|
+
|
|
342
|
+
def _capture_llm_start(self, span: Span, payload: dict[str, Any]) -> None:
|
|
343
|
+
"""Capture LLM start attributes."""
|
|
344
|
+
# Model info
|
|
345
|
+
model = None
|
|
346
|
+
temperature = None
|
|
347
|
+
max_tokens = None
|
|
348
|
+
|
|
349
|
+
if "serialized" in payload:
|
|
350
|
+
serialized = payload["serialized"]
|
|
351
|
+
if "model" in serialized:
|
|
352
|
+
model = serialized["model"]
|
|
353
|
+
span.set_attribute("llm.model", model)
|
|
354
|
+
if "temperature" in serialized:
|
|
355
|
+
temperature = serialized["temperature"]
|
|
356
|
+
span.set_attribute("llm.temperature", temperature)
|
|
357
|
+
if "max_tokens" in serialized:
|
|
358
|
+
max_tokens = serialized["max_tokens"]
|
|
359
|
+
span.set_attribute("llm.max_tokens", max_tokens)
|
|
360
|
+
|
|
361
|
+
# Prompts
|
|
362
|
+
prompt = None
|
|
363
|
+
if "messages" in payload:
|
|
364
|
+
messages = payload["messages"]
|
|
365
|
+
if messages and len(messages) > 0:
|
|
366
|
+
# Truncate for display
|
|
367
|
+
msg_str = str(messages[0])[:_MAX_CONTENT_LEN]
|
|
368
|
+
prompt = msg_str
|
|
369
|
+
span.set_attribute("llm.prompt", msg_str)
|
|
370
|
+
span.set_attribute("llm.prompt_count", len(messages))
|
|
371
|
+
|
|
372
|
+
if "formatted_prompt" in payload:
|
|
373
|
+
prompt = payload["formatted_prompt"][:_MAX_CONTENT_LEN]
|
|
374
|
+
span.set_attribute("llm.formatted_prompt", prompt)
|
|
375
|
+
|
|
376
|
+
# NEW: Initialize replay capture if enabled
|
|
377
|
+
if self._tracer.capture_for_replay:
|
|
378
|
+
try:
|
|
379
|
+
from prela.core.replay import ReplayCapture
|
|
380
|
+
|
|
381
|
+
replay_capture = ReplayCapture()
|
|
382
|
+
replay_capture.set_llm_request(
|
|
383
|
+
model=model,
|
|
384
|
+
prompt=prompt,
|
|
385
|
+
temperature=temperature,
|
|
386
|
+
max_tokens=max_tokens,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Store using span's id as key
|
|
390
|
+
event_id = id(span)
|
|
391
|
+
self._replay_captures[str(event_id)] = replay_capture
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.debug(f"Failed to initialize LLM replay capture: {e}")
|
|
394
|
+
|
|
395
|
+
def _capture_llm_end(self, span: Span, payload: dict[str, Any]) -> None:
|
|
396
|
+
"""Capture LLM end attributes."""
|
|
397
|
+
# Response text
|
|
398
|
+
response_text = None
|
|
399
|
+
prompt_tokens = None
|
|
400
|
+
completion_tokens = None
|
|
401
|
+
|
|
402
|
+
if "response" in payload:
|
|
403
|
+
response = payload["response"]
|
|
404
|
+
if hasattr(response, "text"):
|
|
405
|
+
text = response.text[:_MAX_CONTENT_LEN]
|
|
406
|
+
response_text = text
|
|
407
|
+
span.set_attribute("llm.response", text)
|
|
408
|
+
|
|
409
|
+
# Token usage
|
|
410
|
+
if hasattr(response, "raw") and response.raw:
|
|
411
|
+
raw = response.raw
|
|
412
|
+
if hasattr(raw, "usage"):
|
|
413
|
+
usage = raw.usage
|
|
414
|
+
if hasattr(usage, "prompt_tokens"):
|
|
415
|
+
prompt_tokens = usage.prompt_tokens
|
|
416
|
+
span.set_attribute("llm.input_tokens", usage.prompt_tokens)
|
|
417
|
+
if hasattr(usage, "completion_tokens"):
|
|
418
|
+
completion_tokens = usage.completion_tokens
|
|
419
|
+
span.set_attribute("llm.output_tokens", usage.completion_tokens)
|
|
420
|
+
if hasattr(usage, "total_tokens"):
|
|
421
|
+
span.set_attribute("llm.total_tokens", usage.total_tokens)
|
|
422
|
+
|
|
423
|
+
# NEW: Complete replay capture if enabled
|
|
424
|
+
event_id = str(id(span))
|
|
425
|
+
if self._tracer.capture_for_replay and event_id in self._replay_captures:
|
|
426
|
+
try:
|
|
427
|
+
replay_capture = self._replay_captures[event_id]
|
|
428
|
+
replay_capture.set_llm_response(
|
|
429
|
+
text=response_text,
|
|
430
|
+
prompt_tokens=prompt_tokens,
|
|
431
|
+
completion_tokens=completion_tokens,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Attach to span
|
|
435
|
+
object.__setattr__(span, "replay_snapshot", replay_capture.build())
|
|
436
|
+
|
|
437
|
+
# Clean up
|
|
438
|
+
del self._replay_captures[event_id]
|
|
439
|
+
except Exception as e:
|
|
440
|
+
logger.debug(f"Failed to capture LLM replay data: {e}")
|
|
441
|
+
|
|
442
|
+
def _capture_embedding_start(
|
|
443
|
+
self, span: Span, payload: dict[str, Any]
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Capture embedding start attributes."""
|
|
446
|
+
# Model info
|
|
447
|
+
if "serialized" in payload:
|
|
448
|
+
serialized = payload["serialized"]
|
|
449
|
+
if "model_name" in serialized:
|
|
450
|
+
span.set_attribute("embedding.model", serialized["model_name"])
|
|
451
|
+
|
|
452
|
+
# Input chunks
|
|
453
|
+
if "chunks" in payload:
|
|
454
|
+
chunks = payload["chunks"]
|
|
455
|
+
span.set_attribute("embedding.input_count", len(chunks))
|
|
456
|
+
# Show first chunk as sample
|
|
457
|
+
if chunks:
|
|
458
|
+
sample = str(chunks[0])[:_MAX_CONTENT_LEN]
|
|
459
|
+
span.set_attribute("embedding.input_sample", sample)
|
|
460
|
+
|
|
461
|
+
def _capture_embedding_end(self, span: Span, payload: dict[str, Any]) -> None:
|
|
462
|
+
"""Capture embedding end attributes."""
|
|
463
|
+
# Embeddings
|
|
464
|
+
if "chunks" in payload:
|
|
465
|
+
chunks = payload["chunks"]
|
|
466
|
+
span.set_attribute("embedding.output_count", len(chunks))
|
|
467
|
+
|
|
468
|
+
# Capture dimensions from first embedding
|
|
469
|
+
if chunks and len(chunks[0]) > 0:
|
|
470
|
+
span.set_attribute("embedding.dimensions", len(chunks[0]))
|
|
471
|
+
|
|
472
|
+
def _capture_retrieve_start(
|
|
473
|
+
self, span: Span, payload: dict[str, Any]
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Capture retrieval start attributes."""
|
|
476
|
+
# Query string
|
|
477
|
+
query = None
|
|
478
|
+
if "query_str" in payload:
|
|
479
|
+
query = payload["query_str"][:_MAX_CONTENT_LEN]
|
|
480
|
+
span.set_attribute("retrieval.query", query)
|
|
481
|
+
|
|
482
|
+
# Retriever configuration
|
|
483
|
+
retriever_type = None
|
|
484
|
+
similarity_top_k = None
|
|
485
|
+
if "retriever_type" in payload:
|
|
486
|
+
retriever_type = payload["retriever_type"]
|
|
487
|
+
span.set_attribute("retrieval.type", retriever_type)
|
|
488
|
+
|
|
489
|
+
if "similarity_top_k" in payload:
|
|
490
|
+
similarity_top_k = payload["similarity_top_k"]
|
|
491
|
+
span.set_attribute("retrieval.top_k", similarity_top_k)
|
|
492
|
+
|
|
493
|
+
# NEW: Initialize replay capture if enabled
|
|
494
|
+
if self._tracer.capture_for_replay:
|
|
495
|
+
try:
|
|
496
|
+
from prela.core.replay import ReplayCapture
|
|
497
|
+
|
|
498
|
+
replay_capture = ReplayCapture()
|
|
499
|
+
|
|
500
|
+
# Store for completion in _capture_retrieve_end
|
|
501
|
+
event_id = id(span)
|
|
502
|
+
self._replay_captures[str(event_id)] = {
|
|
503
|
+
"capture": replay_capture,
|
|
504
|
+
"query": query,
|
|
505
|
+
"retriever_type": retriever_type,
|
|
506
|
+
"metadata": {"similarity_top_k": similarity_top_k} if similarity_top_k else {},
|
|
507
|
+
}
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.debug(f"Failed to initialize retrieval replay capture: {e}")
|
|
510
|
+
|
|
511
|
+
def _capture_retrieve_end(self, span: Span, payload: dict[str, Any]) -> None:
|
|
512
|
+
"""Capture retrieval end attributes."""
|
|
513
|
+
# Retrieved nodes
|
|
514
|
+
if "nodes" in payload:
|
|
515
|
+
nodes = payload["nodes"]
|
|
516
|
+
span.set_attribute("retrieval.node_count", len(nodes))
|
|
517
|
+
|
|
518
|
+
# Capture node details (limited)
|
|
519
|
+
for i, node in enumerate(nodes[:_MAX_ITEMS]):
|
|
520
|
+
prefix = f"retrieval.node.{i}"
|
|
521
|
+
|
|
522
|
+
# Node score
|
|
523
|
+
if hasattr(node, "score") and node.score is not None:
|
|
524
|
+
span.set_attribute(f"{prefix}.score", node.score)
|
|
525
|
+
|
|
526
|
+
# Node text (truncated)
|
|
527
|
+
if hasattr(node, "node") and hasattr(node.node, "text"):
|
|
528
|
+
text = node.node.text[:_MAX_NODE_TEXT_LEN]
|
|
529
|
+
span.set_attribute(f"{prefix}.text", text)
|
|
530
|
+
|
|
531
|
+
# Node metadata
|
|
532
|
+
if hasattr(node, "node") and hasattr(node.node, "metadata"):
|
|
533
|
+
metadata = node.node.metadata
|
|
534
|
+
if metadata:
|
|
535
|
+
# Capture a few key metadata fields
|
|
536
|
+
for key in ["file_name", "file_path", "page_label"]:
|
|
537
|
+
if key in metadata:
|
|
538
|
+
span.set_attribute(f"{prefix}.{key}", metadata[key])
|
|
539
|
+
|
|
540
|
+
# NEW: Complete retrieval replay capture if enabled
|
|
541
|
+
event_id = str(id(span))
|
|
542
|
+
if self._tracer.capture_for_replay and event_id in self._replay_captures:
|
|
543
|
+
try:
|
|
544
|
+
replay_data = self._replay_captures[event_id]
|
|
545
|
+
replay_capture = replay_data["capture"]
|
|
546
|
+
|
|
547
|
+
# Extract document data from nodes
|
|
548
|
+
docs = []
|
|
549
|
+
scores = []
|
|
550
|
+
if "nodes" in payload:
|
|
551
|
+
for node in payload["nodes"][:_MAX_ITEMS]:
|
|
552
|
+
doc_dict = {}
|
|
553
|
+
if hasattr(node, "node") and hasattr(node.node, "text"):
|
|
554
|
+
doc_dict["content"] = node.node.text[:_MAX_NODE_TEXT_LEN]
|
|
555
|
+
if hasattr(node, "node") and hasattr(node.node, "metadata"):
|
|
556
|
+
doc_dict["metadata"] = node.node.metadata
|
|
557
|
+
docs.append(doc_dict)
|
|
558
|
+
|
|
559
|
+
# Extract score if available
|
|
560
|
+
if hasattr(node, "score") and node.score is not None:
|
|
561
|
+
scores.append(node.score)
|
|
562
|
+
|
|
563
|
+
# Capture retrieval
|
|
564
|
+
replay_capture.set_retrieval(
|
|
565
|
+
query=replay_data["query"],
|
|
566
|
+
documents=docs,
|
|
567
|
+
scores=scores if scores else None,
|
|
568
|
+
metadata=replay_data["metadata"],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Attach to span
|
|
572
|
+
object.__setattr__(span, "replay_snapshot", replay_capture.build())
|
|
573
|
+
|
|
574
|
+
# Clean up
|
|
575
|
+
del self._replay_captures[event_id]
|
|
576
|
+
except Exception as e:
|
|
577
|
+
logger.debug(f"Failed to capture retrieval replay data: {e}")
|
|
578
|
+
|
|
579
|
+
def _capture_query_start(self, span: Span, payload: dict[str, Any]) -> None:
|
|
580
|
+
"""Capture query start attributes."""
|
|
581
|
+
# Query string
|
|
582
|
+
if "query_str" in payload:
|
|
583
|
+
query = payload["query_str"][:_MAX_CONTENT_LEN]
|
|
584
|
+
span.set_attribute("query.input", query)
|
|
585
|
+
|
|
586
|
+
# Query mode
|
|
587
|
+
if "query_mode" in payload:
|
|
588
|
+
span.set_attribute("query.mode", payload["query_mode"])
|
|
589
|
+
|
|
590
|
+
def _capture_query_end(self, span: Span, payload: dict[str, Any]) -> None:
|
|
591
|
+
"""Capture query end attributes."""
|
|
592
|
+
# Response
|
|
593
|
+
if "response" in payload:
|
|
594
|
+
response = payload["response"]
|
|
595
|
+
if hasattr(response, "response"):
|
|
596
|
+
text = response.response[:_MAX_CONTENT_LEN]
|
|
597
|
+
span.set_attribute("query.output", text)
|
|
598
|
+
|
|
599
|
+
# Source nodes used
|
|
600
|
+
if hasattr(response, "source_nodes"):
|
|
601
|
+
source_count = len(response.source_nodes)
|
|
602
|
+
span.set_attribute("query.source_count", source_count)
|
|
603
|
+
|
|
604
|
+
def _capture_synthesize_start(
|
|
605
|
+
self, span: Span, payload: dict[str, Any]
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Capture synthesis start attributes."""
|
|
608
|
+
# Query
|
|
609
|
+
if "query_str" in payload:
|
|
610
|
+
query = payload["query_str"][:_MAX_CONTENT_LEN]
|
|
611
|
+
span.set_attribute("synthesis.query", query)
|
|
612
|
+
|
|
613
|
+
# Node count
|
|
614
|
+
if "nodes" in payload:
|
|
615
|
+
span.set_attribute("synthesis.node_count", len(payload["nodes"]))
|
|
616
|
+
|
|
617
|
+
def _capture_synthesize_end(self, span: Span, payload: dict[str, Any]) -> None:
|
|
618
|
+
"""Capture synthesis end attributes."""
|
|
619
|
+
# Response
|
|
620
|
+
if "response" in payload:
|
|
621
|
+
response = payload["response"]
|
|
622
|
+
if hasattr(response, "response"):
|
|
623
|
+
text = response.response[:_MAX_CONTENT_LEN]
|
|
624
|
+
span.set_attribute("synthesis.output", text)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class LlamaIndexInstrumentor(Instrumentor):
|
|
628
|
+
"""Instrumentor for LlamaIndex framework.
|
|
629
|
+
|
|
630
|
+
This instrumentor adds automatic tracing to LlamaIndex operations by
|
|
631
|
+
injecting a callback handler into the global callback manager.
|
|
632
|
+
|
|
633
|
+
Example:
|
|
634
|
+
```python
|
|
635
|
+
from prela.instrumentation.llamaindex import LlamaIndexInstrumentor
|
|
636
|
+
from prela.core.tracer import Tracer
|
|
637
|
+
|
|
638
|
+
tracer = Tracer()
|
|
639
|
+
instrumentor = LlamaIndexInstrumentor()
|
|
640
|
+
instrumentor.instrument(tracer)
|
|
641
|
+
|
|
642
|
+
# All LlamaIndex operations now traced
|
|
643
|
+
```
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
def __init__(self) -> None:
|
|
647
|
+
"""Initialize the instrumentor."""
|
|
648
|
+
self._handler: Optional[PrelaHandler] = None
|
|
649
|
+
self._instrumented = False
|
|
650
|
+
|
|
651
|
+
def instrument(self, tracer: Tracer) -> None:
|
|
652
|
+
"""Enable instrumentation for LlamaIndex.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
tracer: The tracer to use for creating spans
|
|
656
|
+
|
|
657
|
+
Raises:
|
|
658
|
+
RuntimeError: If llama-index-core is not installed
|
|
659
|
+
"""
|
|
660
|
+
if self._instrumented:
|
|
661
|
+
logger.debug("LlamaIndex already instrumented, skipping")
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
try:
|
|
665
|
+
from llama_index.core import Settings
|
|
666
|
+
from llama_index.core.callbacks import CallbackManager
|
|
667
|
+
except ImportError as e:
|
|
668
|
+
raise RuntimeError(
|
|
669
|
+
"llama-index-core is not installed. "
|
|
670
|
+
"Install it with: pip install llama-index-core"
|
|
671
|
+
) from e
|
|
672
|
+
|
|
673
|
+
# Create handler
|
|
674
|
+
self._handler = PrelaHandler(tracer)
|
|
675
|
+
|
|
676
|
+
# Inject into global callback manager
|
|
677
|
+
if Settings.callback_manager is None:
|
|
678
|
+
Settings.callback_manager = CallbackManager([self._handler])
|
|
679
|
+
else:
|
|
680
|
+
Settings.callback_manager.add_handler(self._handler)
|
|
681
|
+
|
|
682
|
+
self._instrumented = True
|
|
683
|
+
logger.debug("LlamaIndex instrumentation enabled")
|
|
684
|
+
|
|
685
|
+
def uninstrument(self) -> None:
|
|
686
|
+
"""Disable instrumentation and remove callback handler."""
|
|
687
|
+
if not self._instrumented:
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
from llama_index.core import Settings
|
|
692
|
+
|
|
693
|
+
if Settings.callback_manager and self._handler:
|
|
694
|
+
Settings.callback_manager.remove_handler(self._handler)
|
|
695
|
+
|
|
696
|
+
self._handler = None
|
|
697
|
+
self._instrumented = False
|
|
698
|
+
logger.debug("LlamaIndex instrumentation disabled")
|
|
699
|
+
|
|
700
|
+
except ImportError:
|
|
701
|
+
# LlamaIndex not available, nothing to uninstrument
|
|
702
|
+
pass
|
|
703
|
+
|
|
704
|
+
@property
|
|
705
|
+
def is_instrumented(self) -> bool:
|
|
706
|
+
"""Check if LlamaIndex is currently instrumented.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
True if instrumented, False otherwise
|
|
710
|
+
"""
|
|
711
|
+
return self._instrumented
|
|
712
|
+
|
|
713
|
+
def get_handler(self) -> Optional[PrelaHandler]:
|
|
714
|
+
"""Get the callback handler instance.
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
The PrelaHandler instance if instrumented, None otherwise
|
|
718
|
+
"""
|
|
719
|
+
return self._handler
|