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,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
|