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.
Files changed (71) hide show
  1. prela/__init__.py +394 -0
  2. prela/_version.py +3 -0
  3. prela/contrib/CLI.md +431 -0
  4. prela/contrib/README.md +118 -0
  5. prela/contrib/__init__.py +5 -0
  6. prela/contrib/cli.py +1063 -0
  7. prela/contrib/explorer.py +571 -0
  8. prela/core/__init__.py +64 -0
  9. prela/core/clock.py +98 -0
  10. prela/core/context.py +228 -0
  11. prela/core/replay.py +403 -0
  12. prela/core/sampler.py +178 -0
  13. prela/core/span.py +295 -0
  14. prela/core/tracer.py +498 -0
  15. prela/evals/__init__.py +94 -0
  16. prela/evals/assertions/README.md +484 -0
  17. prela/evals/assertions/__init__.py +78 -0
  18. prela/evals/assertions/base.py +90 -0
  19. prela/evals/assertions/multi_agent.py +625 -0
  20. prela/evals/assertions/semantic.py +223 -0
  21. prela/evals/assertions/structural.py +443 -0
  22. prela/evals/assertions/tool.py +380 -0
  23. prela/evals/case.py +370 -0
  24. prela/evals/n8n/__init__.py +69 -0
  25. prela/evals/n8n/assertions.py +450 -0
  26. prela/evals/n8n/runner.py +497 -0
  27. prela/evals/reporters/README.md +184 -0
  28. prela/evals/reporters/__init__.py +32 -0
  29. prela/evals/reporters/console.py +251 -0
  30. prela/evals/reporters/json.py +176 -0
  31. prela/evals/reporters/junit.py +278 -0
  32. prela/evals/runner.py +525 -0
  33. prela/evals/suite.py +316 -0
  34. prela/exporters/__init__.py +27 -0
  35. prela/exporters/base.py +189 -0
  36. prela/exporters/console.py +443 -0
  37. prela/exporters/file.py +322 -0
  38. prela/exporters/http.py +394 -0
  39. prela/exporters/multi.py +154 -0
  40. prela/exporters/otlp.py +388 -0
  41. prela/instrumentation/ANTHROPIC.md +297 -0
  42. prela/instrumentation/LANGCHAIN.md +480 -0
  43. prela/instrumentation/OPENAI.md +59 -0
  44. prela/instrumentation/__init__.py +49 -0
  45. prela/instrumentation/anthropic.py +1436 -0
  46. prela/instrumentation/auto.py +129 -0
  47. prela/instrumentation/base.py +436 -0
  48. prela/instrumentation/langchain.py +959 -0
  49. prela/instrumentation/llamaindex.py +719 -0
  50. prela/instrumentation/multi_agent/__init__.py +48 -0
  51. prela/instrumentation/multi_agent/autogen.py +357 -0
  52. prela/instrumentation/multi_agent/crewai.py +404 -0
  53. prela/instrumentation/multi_agent/langgraph.py +299 -0
  54. prela/instrumentation/multi_agent/models.py +203 -0
  55. prela/instrumentation/multi_agent/swarm.py +231 -0
  56. prela/instrumentation/n8n/__init__.py +68 -0
  57. prela/instrumentation/n8n/code_node.py +534 -0
  58. prela/instrumentation/n8n/models.py +336 -0
  59. prela/instrumentation/n8n/webhook.py +489 -0
  60. prela/instrumentation/openai.py +1198 -0
  61. prela/license.py +245 -0
  62. prela/replay/__init__.py +31 -0
  63. prela/replay/comparison.py +390 -0
  64. prela/replay/engine.py +1227 -0
  65. prela/replay/loader.py +231 -0
  66. prela/replay/result.py +196 -0
  67. prela-0.1.0.dist-info/METADATA +399 -0
  68. prela-0.1.0.dist-info/RECORD +71 -0
  69. prela-0.1.0.dist-info/WHEEL +4 -0
  70. prela-0.1.0.dist-info/entry_points.txt +2 -0
  71. 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