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,404 @@
1
+ """CrewAI instrumentation for Prela.
2
+
3
+ This module provides automatic instrumentation for CrewAI (>=0.30.0),
4
+ capturing crew executions, agent actions, and task completions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import uuid
11
+ from datetime import datetime
12
+ from typing import Any, Optional
13
+
14
+ from prela.core.span import SpanType
15
+ from prela.core.tracer import Tracer, get_tracer
16
+ from prela.instrumentation.base import Instrumentor
17
+ from prela.instrumentation.multi_agent.models import (
18
+ AgentDefinition,
19
+ AgentMessage,
20
+ AgentRole,
21
+ CrewExecution,
22
+ MessageType,
23
+ TaskAssignment,
24
+ generate_agent_id,
25
+ )
26
+ from prela.license import require_tier
27
+
28
+
29
+ class CrewAIInstrumentor(Instrumentor):
30
+ """Instrumentor for CrewAI multi-agent framework."""
31
+
32
+ FRAMEWORK = "crewai"
33
+
34
+ @property
35
+ def is_instrumented(self) -> bool:
36
+ """Check if CrewAI is currently instrumented."""
37
+ return self._is_instrumented
38
+
39
+ def __init__(self):
40
+ super().__init__()
41
+ self._active_crews: dict[str, CrewExecution] = {}
42
+ self._is_instrumented = False
43
+ self._tracer: Optional[Tracer] = None
44
+
45
+ @require_tier("CrewAI instrumentation", "lunch-money")
46
+ def instrument(self, tracer: Optional[Tracer] = None) -> None:
47
+ """Patch CrewAI classes for tracing.
48
+
49
+ Args:
50
+ tracer: Optional tracer to use. If None, uses global tracer.
51
+ """
52
+ if self.is_instrumented:
53
+ return
54
+
55
+ try:
56
+ import crewai
57
+ except ImportError:
58
+ return # CrewAI not installed
59
+
60
+ self._tracer = tracer or get_tracer()
61
+
62
+ # Patch Crew.kickoff (main entry point)
63
+ self._patch_crew_kickoff(crewai.Crew)
64
+
65
+ # Patch Agent execution
66
+ self._patch_agent_execute(crewai.Agent)
67
+
68
+ # Patch Task execution
69
+ self._patch_task_execute(crewai.Task)
70
+
71
+ self._is_instrumented = True
72
+
73
+ def uninstrument(self) -> None:
74
+ """Restore original methods."""
75
+ if not self.is_instrumented:
76
+ return
77
+
78
+ try:
79
+ import crewai
80
+ except ImportError:
81
+ return
82
+
83
+ # Restore all patched methods
84
+ for module_name, obj_name, method_name in [
85
+ ("crewai", "Crew", "kickoff"),
86
+ ("crewai", "Agent", "execute_task"),
87
+ ("crewai", "Task", "execute"),
88
+ ]:
89
+ try:
90
+ module = __import__(module_name, fromlist=[obj_name])
91
+ cls = getattr(module, obj_name, None)
92
+ if cls and hasattr(cls, f"_prela_original_{method_name}"):
93
+ original = getattr(cls, f"_prela_original_{method_name}")
94
+ setattr(cls, method_name, original)
95
+ delattr(cls, f"_prela_original_{method_name}")
96
+ except (ImportError, AttributeError):
97
+ pass
98
+
99
+ self._is_instrumented = False
100
+ self._tracer = None
101
+
102
+ def _patch_crew_kickoff(self, crew_cls) -> None:
103
+ """Patch Crew.kickoff to create root span for crew execution."""
104
+ if hasattr(crew_cls, "_prela_original_kickoff"):
105
+ return
106
+
107
+ original = crew_cls.kickoff
108
+ crew_cls._prela_original_kickoff = original
109
+
110
+ instrumentor = self
111
+
112
+ @functools.wraps(original)
113
+ def wrapped_kickoff(crew_self, *args, **kwargs):
114
+ tracer = instrumentor._tracer
115
+ if not tracer:
116
+ return original(crew_self, *args, **kwargs)
117
+
118
+ execution_id = str(uuid.uuid4())
119
+
120
+ # Build agent definitions from crew
121
+ agents = []
122
+ for agent in crew_self.agents:
123
+ agent_def = AgentDefinition(
124
+ agent_id=generate_agent_id(
125
+ instrumentor.FRAMEWORK, agent.role
126
+ ),
127
+ name=agent.role,
128
+ role=instrumentor._map_crewai_role(agent),
129
+ framework=instrumentor.FRAMEWORK,
130
+ model=(
131
+ getattr(agent.llm, "model_name", None)
132
+ if hasattr(agent, "llm")
133
+ else None
134
+ ),
135
+ system_prompt=getattr(agent, "backstory", None),
136
+ tools=[t.name for t in getattr(agent, "tools", [])],
137
+ metadata={
138
+ "goal": getattr(agent, "goal", None),
139
+ "allow_delegation": getattr(agent, "allow_delegation", False),
140
+ },
141
+ )
142
+ agents.append(agent_def)
143
+
144
+ # Build task definitions
145
+ tasks = []
146
+ for task in crew_self.tasks:
147
+ task_def = TaskAssignment(
148
+ task_id=str(uuid.uuid4()),
149
+ assigner_id="crew_manager",
150
+ assignee_id=(
151
+ generate_agent_id(instrumentor.FRAMEWORK, task.agent.role)
152
+ if task.agent
153
+ else "unassigned"
154
+ ),
155
+ description=task.description,
156
+ expected_output=getattr(task, "expected_output", None),
157
+ )
158
+ tasks.append(task_def)
159
+
160
+ # Create crew execution record
161
+ crew_exec = CrewExecution(
162
+ execution_id=execution_id,
163
+ framework=instrumentor.FRAMEWORK,
164
+ agents=agents,
165
+ tasks=tasks,
166
+ started_at=datetime.utcnow(),
167
+ )
168
+ instrumentor._active_crews[execution_id] = crew_exec
169
+
170
+ # Create root span
171
+ crew_name = getattr(crew_self, "name", None) or "unnamed_crew"
172
+
173
+ crew_attributes = {
174
+ "crew.execution_id": execution_id,
175
+ "crew.framework": instrumentor.FRAMEWORK,
176
+ "crew.num_agents": len(agents),
177
+ "crew.num_tasks": len(tasks),
178
+ "crew.process": getattr(crew_self, "process", "sequential"),
179
+ "crew.agent_names": [a.name for a in agents],
180
+ }
181
+
182
+ # NEW: Replay capture if enabled
183
+ replay_capture = None
184
+ if tracer.capture_for_replay:
185
+ from prela.core.replay import ReplayCapture
186
+
187
+ replay_capture = ReplayCapture()
188
+ # Capture agent context
189
+ replay_capture.set_agent_context(
190
+ system_prompt=f"Crew: {crew_name}",
191
+ available_tools=[
192
+ {"name": t, "agent": a.name}
193
+ for a in agents
194
+ for t in a.tools
195
+ ],
196
+ memory={
197
+ "agents": [
198
+ {
199
+ "name": a.name,
200
+ "role": a.role.value if hasattr(a.role, "value") else str(a.role),
201
+ "model": a.model,
202
+ }
203
+ for a in agents
204
+ ],
205
+ "tasks": [
206
+ {
207
+ "description": t.description[:200],
208
+ "assignee": t.assignee_id,
209
+ }
210
+ for t in tasks
211
+ ],
212
+ },
213
+ config={
214
+ "framework": instrumentor.FRAMEWORK,
215
+ "execution_id": execution_id,
216
+ "num_agents": len(agents),
217
+ "num_tasks": len(tasks),
218
+ "process": getattr(crew_self, "process", "sequential"),
219
+ },
220
+ )
221
+
222
+ with tracer.span(
223
+ name=f"crewai.crew.{crew_name}",
224
+ span_type=SpanType.AGENT,
225
+ attributes=crew_attributes,
226
+ ) as span:
227
+ try:
228
+ result = original(crew_self, *args, **kwargs)
229
+ crew_exec.status = "completed"
230
+ crew_exec.completed_at = datetime.utcnow()
231
+ span.set_attribute(
232
+ "crew.result_length", len(str(result)) if result else 0
233
+ )
234
+ span.set_attribute("crew.total_llm_calls", crew_exec.total_llm_calls)
235
+ span.set_attribute("crew.total_tokens", crew_exec.total_tokens)
236
+ span.set_attribute("crew.total_cost_usd", crew_exec.total_cost_usd)
237
+
238
+ # NEW: Attach replay snapshot
239
+ if replay_capture:
240
+ try:
241
+ object.__setattr__(span, "replay_snapshot", replay_capture.build())
242
+ except Exception as e:
243
+ logger.debug(f"Failed to capture replay data: {e}")
244
+
245
+ return result
246
+ except Exception as e:
247
+ crew_exec.status = "failed"
248
+ crew_exec.completed_at = datetime.utcnow()
249
+ span.add_event(
250
+ "exception",
251
+ attributes={
252
+ "exception.type": type(e).__name__,
253
+ "exception.message": str(e),
254
+ },
255
+ )
256
+ raise
257
+ finally:
258
+ if execution_id in instrumentor._active_crews:
259
+ del instrumentor._active_crews[execution_id]
260
+
261
+ crew_cls.kickoff = wrapped_kickoff
262
+
263
+ def _patch_agent_execute(self, agent_cls) -> None:
264
+ """Patch Agent execution to create agent spans."""
265
+ method_name = "execute_task"
266
+ if not hasattr(agent_cls, method_name):
267
+ return
268
+
269
+ if hasattr(agent_cls, f"_prela_original_{method_name}"):
270
+ return
271
+
272
+ original = getattr(agent_cls, method_name)
273
+ setattr(agent_cls, f"_prela_original_{method_name}", original)
274
+
275
+ instrumentor = self
276
+
277
+ @functools.wraps(original)
278
+ def wrapped_execute(agent_self, task, *args, **kwargs):
279
+ tracer = instrumentor._tracer
280
+ if not tracer:
281
+ return original(agent_self, task, *args, **kwargs)
282
+
283
+ agent_id = generate_agent_id(instrumentor.FRAMEWORK, agent_self.role)
284
+
285
+ task_desc = (
286
+ task.description[:200] if hasattr(task, "description") else None
287
+ )
288
+
289
+ agent_attributes = {
290
+ "agent.id": agent_id,
291
+ "agent.name": agent_self.role,
292
+ "agent.framework": instrumentor.FRAMEWORK,
293
+ "agent.goal": getattr(agent_self, "goal", None),
294
+ "agent.tools": [t.name for t in getattr(agent_self, "tools", [])],
295
+ "task.description": task_desc,
296
+ }
297
+
298
+ with tracer.span(
299
+ name=f"crewai.agent.{agent_self.role}",
300
+ span_type=SpanType.AGENT,
301
+ attributes=agent_attributes,
302
+ ) as span:
303
+ try:
304
+ result = original(agent_self, task, *args, **kwargs)
305
+ span.set_attribute(
306
+ "agent.output_length", len(str(result)) if result else 0
307
+ )
308
+ return result
309
+ except Exception as e:
310
+ span.add_event(
311
+ "exception",
312
+ attributes={
313
+ "exception.type": type(e).__name__,
314
+ "exception.message": str(e),
315
+ },
316
+ )
317
+ raise
318
+
319
+ setattr(agent_cls, method_name, wrapped_execute)
320
+
321
+ def _patch_task_execute(self, task_cls) -> None:
322
+ """Patch Task execution to create task spans."""
323
+ # Try multiple possible method names
324
+ method_name = None
325
+ for candidate in ["execute", "execute_sync", "_execute", "run"]:
326
+ if hasattr(task_cls, candidate):
327
+ method_name = candidate
328
+ break
329
+
330
+ if not method_name:
331
+ return
332
+
333
+ if hasattr(task_cls, f"_prela_original_{method_name}"):
334
+ return
335
+
336
+ original = getattr(task_cls, method_name)
337
+ setattr(task_cls, f"_prela_original_{method_name}", original)
338
+
339
+ instrumentor = self
340
+
341
+ @functools.wraps(original)
342
+ def wrapped_execute(task_self, *args, **kwargs):
343
+ tracer = instrumentor._tracer
344
+ if not tracer:
345
+ return original(task_self, *args, **kwargs)
346
+
347
+ task_id = str(uuid.uuid4())
348
+ agent_name = (
349
+ task_self.agent.role if hasattr(task_self, "agent") and task_self.agent else "unassigned"
350
+ )
351
+
352
+ # Truncate description for span name
353
+ desc_preview = task_self.description[:50] if hasattr(task_self, "description") else "task"
354
+
355
+ task_attributes = {
356
+ "task.id": task_id,
357
+ "task.description": getattr(task_self, "description", None),
358
+ "task.expected_output": getattr(task_self, "expected_output", None),
359
+ "task.agent": agent_name,
360
+ }
361
+
362
+ with tracer.span(
363
+ name=f"crewai.task.{desc_preview}",
364
+ span_type=SpanType.AGENT,
365
+ attributes=task_attributes,
366
+ ) as span:
367
+ try:
368
+ result = original(task_self, *args, **kwargs)
369
+ # Truncate output to 1000 chars
370
+ span.set_attribute(
371
+ "task.output", str(result)[:1000] if result else None
372
+ )
373
+ span.set_attribute("task.status", "completed")
374
+ return result
375
+ except Exception as e:
376
+ span.set_attribute("task.status", "failed")
377
+ span.add_event(
378
+ "exception",
379
+ attributes={
380
+ "exception.type": type(e).__name__,
381
+ "exception.message": str(e),
382
+ },
383
+ )
384
+ raise
385
+
386
+ setattr(task_cls, method_name, wrapped_execute)
387
+
388
+ def _map_crewai_role(self, agent) -> AgentRole:
389
+ """Map CrewAI agent to standard role.
390
+
391
+ Args:
392
+ agent: CrewAI agent object
393
+
394
+ Returns:
395
+ Standardized AgentRole enum value
396
+ """
397
+ role_lower = agent.role.lower()
398
+ if "manager" in role_lower or "lead" in role_lower:
399
+ return AgentRole.MANAGER
400
+ elif "critic" in role_lower or "review" in role_lower:
401
+ return AgentRole.CRITIC
402
+ elif "specialist" in role_lower or "expert" in role_lower:
403
+ return AgentRole.SPECIALIST
404
+ return AgentRole.WORKER
@@ -0,0 +1,299 @@
1
+ """LangGraph instrumentation for Prela.
2
+
3
+ This module provides automatic instrumentation for LangGraph (>=0.0.20),
4
+ capturing stateful agent workflows with node and edge tracking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import uuid
11
+ from typing import Any, Callable, Optional
12
+
13
+ from prela.core.span import SpanStatus, SpanType
14
+ from prela.core.tracer import Tracer, get_tracer
15
+ from prela.instrumentation.base import Instrumentor
16
+ from prela.instrumentation.multi_agent.models import generate_agent_id
17
+ from prela.license import require_tier
18
+
19
+
20
+ class LangGraphInstrumentor(Instrumentor):
21
+ """Instrumentor for LangGraph stateful agent workflows."""
22
+
23
+ FRAMEWORK = "langgraph"
24
+
25
+ @property
26
+ def is_instrumented(self) -> bool:
27
+ """Check if LangGraph is currently instrumented."""
28
+ return self._is_instrumented
29
+
30
+ def __init__(self):
31
+ super().__init__()
32
+ self._original_methods: dict[tuple, Any] = {}
33
+ self._graph_metadata: dict[int, dict] = {}
34
+ self._is_instrumented = False
35
+ self._tracer: Optional[Tracer] = None
36
+
37
+ @require_tier("LangGraph instrumentation", "lunch-money")
38
+ def instrument(self, tracer: Optional[Tracer] = None) -> None:
39
+ """Patch LangGraph classes for tracing.
40
+
41
+ Args:
42
+ tracer: Optional tracer to use. If None, uses global tracer.
43
+ """
44
+ if self.is_instrumented:
45
+ return
46
+
47
+ try:
48
+ from langgraph.graph import StateGraph
49
+ except ImportError:
50
+ return # LangGraph not installed
51
+
52
+ self._tracer = tracer or get_tracer()
53
+
54
+ self._patch_state_graph(StateGraph)
55
+
56
+ try:
57
+ from langgraph.prebuilt import create_react_agent
58
+
59
+ self._patch_create_react_agent(create_react_agent)
60
+ except ImportError:
61
+ pass
62
+
63
+ self._is_instrumented = True
64
+
65
+ def uninstrument(self) -> None:
66
+ """Restore original methods."""
67
+ if not self.is_instrumented:
68
+ return
69
+
70
+ for (cls, method_name), original in list(self._original_methods.items()):
71
+ if callable(cls):
72
+ # Function-level patching (e.g., create_react_agent)
73
+ try:
74
+ import langgraph.prebuilt as prebuilt
75
+
76
+ setattr(prebuilt, method_name, original)
77
+ except ImportError:
78
+ pass
79
+ else:
80
+ # Class method patching
81
+ setattr(cls, method_name, original)
82
+
83
+ self._original_methods.clear()
84
+ self._graph_metadata.clear()
85
+ self._is_instrumented = False
86
+ self._tracer = None
87
+
88
+ def _patch_state_graph(self, graph_cls) -> None:
89
+ """Patch StateGraph for node and edge tracking."""
90
+ if (graph_cls, "compile") in self._original_methods:
91
+ return
92
+
93
+ original_compile = graph_cls.compile
94
+ self._original_methods[(graph_cls, "compile")] = original_compile
95
+
96
+ instrumentor = self
97
+
98
+ @functools.wraps(original_compile)
99
+ def wrapped_compile(graph_self, *args, **kwargs):
100
+ compiled = original_compile(graph_self, *args, **kwargs)
101
+
102
+ graph_id = str(uuid.uuid4())
103
+ instrumentor._graph_metadata[id(compiled)] = {
104
+ "graph_id": graph_id,
105
+ "nodes": (
106
+ list(graph_self.nodes.keys())
107
+ if hasattr(graph_self, "nodes")
108
+ else []
109
+ ),
110
+ }
111
+
112
+ instrumentor._patch_compiled_graph(compiled, graph_id)
113
+ return compiled
114
+
115
+ graph_cls.compile = wrapped_compile
116
+
117
+ # Patch add_node
118
+ if (graph_cls, "add_node") not in self._original_methods:
119
+ original_add_node = graph_cls.add_node
120
+ self._original_methods[(graph_cls, "add_node")] = original_add_node
121
+
122
+ @functools.wraps(original_add_node)
123
+ def wrapped_add_node(
124
+ graph_self, node_name: str, action: Callable, *args, **kwargs
125
+ ):
126
+ wrapped_action = instrumentor._wrap_node_action(node_name, action)
127
+ return original_add_node(
128
+ graph_self, node_name, wrapped_action, *args, **kwargs
129
+ )
130
+
131
+ graph_cls.add_node = wrapped_add_node
132
+
133
+ def _patch_compiled_graph(self, compiled_graph, graph_id: str) -> None:
134
+ """Patch a compiled graph's invoke and stream methods."""
135
+ # Patch invoke
136
+ if hasattr(compiled_graph, "invoke"):
137
+ original_invoke = compiled_graph.invoke
138
+ instrumentor = self
139
+
140
+ @functools.wraps(original_invoke)
141
+ def wrapped_invoke(state, *args, **kwargs):
142
+ tracer = instrumentor._tracer
143
+ if not tracer:
144
+ return original_invoke(state, *args, **kwargs)
145
+
146
+ metadata = instrumentor._graph_metadata.get(id(compiled_graph), {})
147
+
148
+ # NEW: Replay capture if enabled
149
+ replay_capture = None
150
+ if tracer.capture_for_replay:
151
+ from prela.core.replay import ReplayCapture
152
+
153
+ replay_capture = ReplayCapture()
154
+ # Capture agent context
155
+ replay_capture.set_agent_context(
156
+ config={
157
+ "framework": instrumentor.FRAMEWORK,
158
+ "graph_id": graph_id,
159
+ "nodes": metadata.get("nodes", []),
160
+ },
161
+ memory={"input_state": state if isinstance(state, dict) else str(state)[:500]},
162
+ )
163
+
164
+ with tracer.span(
165
+ name="langgraph.graph.invoke",
166
+ span_type=SpanType.AGENT,
167
+ attributes={
168
+ "graph.id": graph_id,
169
+ "graph.framework": instrumentor.FRAMEWORK,
170
+ "graph.nodes": metadata.get("nodes", []),
171
+ "graph.input_keys": (
172
+ list(state.keys()) if isinstance(state, dict) else None
173
+ ),
174
+ },
175
+ ) as span:
176
+ try:
177
+ result = original_invoke(state, *args, **kwargs)
178
+ if isinstance(result, dict):
179
+ span.set_attribute("graph.output_keys", list(result.keys()))
180
+ span.set_status(SpanStatus.SUCCESS)
181
+
182
+ # NEW: Attach replay snapshot
183
+ if replay_capture:
184
+ try:
185
+ object.__setattr__(span, "replay_snapshot", replay_capture.build())
186
+ except Exception as e:
187
+ import logging
188
+ logger = logging.getLogger(__name__)
189
+ logger.debug(f"Failed to capture replay data: {e}")
190
+
191
+ return result
192
+ except Exception as e:
193
+ span.set_attribute("error.type", type(e).__name__)
194
+ span.set_attribute("error.message", str(e))
195
+ raise
196
+
197
+ compiled_graph.invoke = wrapped_invoke
198
+
199
+ # Patch stream
200
+ if hasattr(compiled_graph, "stream"):
201
+ original_stream = compiled_graph.stream
202
+ instrumentor = self
203
+
204
+ @functools.wraps(original_stream)
205
+ def wrapped_stream(state, *args, **kwargs):
206
+ tracer = instrumentor._tracer
207
+ if not tracer:
208
+ yield from original_stream(state, *args, **kwargs)
209
+ return
210
+
211
+ with tracer.span(
212
+ name="langgraph.graph.stream",
213
+ span_type=SpanType.AGENT,
214
+ attributes={
215
+ "graph.id": graph_id,
216
+ "graph.framework": instrumentor.FRAMEWORK,
217
+ "graph.streaming": True,
218
+ },
219
+ ) as span:
220
+ step_count = 0
221
+ try:
222
+ for step in original_stream(state, *args, **kwargs):
223
+ step_count += 1
224
+ span.add_event(
225
+ "graph.step", {"step.number": step_count}
226
+ )
227
+ yield step
228
+ span.set_attribute("graph.total_steps", step_count)
229
+ span.set_status(SpanStatus.SUCCESS)
230
+ except Exception as e:
231
+ span.set_attribute("error.type", type(e).__name__)
232
+ span.set_attribute("error.message", str(e))
233
+ raise
234
+
235
+ compiled_graph.stream = wrapped_stream
236
+
237
+ def _wrap_node_action(self, node_name: str, action: Callable) -> Callable:
238
+ """Wrap a node action to add tracing."""
239
+ instrumentor = self
240
+
241
+ @functools.wraps(action)
242
+ def wrapped_action(state, *args, **kwargs):
243
+ tracer = instrumentor._tracer
244
+ if not tracer:
245
+ return action(state, *args, **kwargs)
246
+
247
+ with tracer.span(
248
+ name=f"langgraph.node.{node_name}",
249
+ span_type=SpanType.CUSTOM,
250
+ attributes={
251
+ "node.name": node_name,
252
+ "node.framework": instrumentor.FRAMEWORK,
253
+ },
254
+ ) as span:
255
+ try:
256
+ result = action(state, *args, **kwargs)
257
+ if isinstance(state, dict) and isinstance(result, dict):
258
+ changed = [
259
+ k
260
+ for k in result
261
+ if k not in state or state.get(k) != result.get(k)
262
+ ]
263
+ span.set_attribute("node.changed_keys", changed)
264
+ span.set_status(SpanStatus.SUCCESS)
265
+ return result
266
+ except Exception as e:
267
+ span.set_attribute("error.type", type(e).__name__)
268
+ span.set_attribute("error.message", str(e))
269
+ raise
270
+
271
+ return wrapped_action
272
+
273
+ def _patch_create_react_agent(self, create_func: Callable) -> None:
274
+ """Patch the prebuilt create_react_agent function."""
275
+ import langgraph.prebuilt as prebuilt
276
+
277
+ if (create_func, "create_react_agent") in self._original_methods:
278
+ return
279
+
280
+ self._original_methods[(create_func, "create_react_agent")] = create_func
281
+
282
+ instrumentor = self
283
+
284
+ @functools.wraps(create_func)
285
+ def wrapped_create_react_agent(model, tools, *args, **kwargs):
286
+ agent = create_func(model, tools, *args, **kwargs)
287
+ agent_id = str(uuid.uuid4())
288
+ # Update existing metadata or create new entry
289
+ existing_metadata = instrumentor._graph_metadata.get(id(agent), {})
290
+ existing_metadata.update({
291
+ "agent_id": agent_id,
292
+ "agent_type": "react",
293
+ "tools": [t.name if hasattr(t, "name") else str(t) for t in tools],
294
+ })
295
+ instrumentor._graph_metadata[id(agent)] = existing_metadata
296
+ # Don't call _patch_compiled_graph again as it was already called by compile()
297
+ return agent
298
+
299
+ prebuilt.create_react_agent = wrapped_create_react_agent