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,203 @@
1
+ """Shared data models for multi-agent instrumentation.
2
+
3
+ This module provides common data structures for tracing multi-agent systems
4
+ across different frameworks (CrewAI, AutoGen, LangGraph, Swarm).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from enum import Enum
12
+ from typing import Any, Literal, Optional
13
+ import hashlib
14
+
15
+
16
+ class AgentRole(str, Enum):
17
+ """Standard agent roles across frameworks."""
18
+
19
+ MANAGER = "manager"
20
+ WORKER = "worker"
21
+ SPECIALIST = "specialist"
22
+ CRITIC = "critic"
23
+ USER_PROXY = "user_proxy"
24
+ ASSISTANT = "assistant"
25
+ CUSTOM = "custom"
26
+
27
+
28
+ class MessageType(str, Enum):
29
+ """Types of inter-agent messages."""
30
+
31
+ TASK_ASSIGNMENT = "task_assignment"
32
+ TASK_RESULT = "task_result"
33
+ QUESTION = "question"
34
+ ANSWER = "answer"
35
+ FEEDBACK = "feedback"
36
+ DELEGATION = "delegation"
37
+ HANDOFF = "handoff"
38
+ SYSTEM = "system"
39
+
40
+
41
+ @dataclass
42
+ class AgentDefinition:
43
+ """Represents an agent in a multi-agent system."""
44
+
45
+ agent_id: str
46
+ name: str
47
+ role: AgentRole
48
+ framework: str # "crewai", "autogen", "langgraph", "swarm"
49
+
50
+ # Agent configuration
51
+ model: Optional[str] = None
52
+ system_prompt: Optional[str] = None
53
+ tools: list[str] = field(default_factory=list)
54
+
55
+ # Framework-specific metadata
56
+ metadata: dict[str, Any] = field(default_factory=dict)
57
+
58
+ def to_span_attributes(self) -> dict[str, Any]:
59
+ """Convert to span attributes for tracing."""
60
+ return {
61
+ "agent.id": self.agent_id,
62
+ "agent.name": self.name,
63
+ "agent.role": self.role.value,
64
+ "agent.framework": self.framework,
65
+ "agent.model": self.model,
66
+ "agent.tools": self.tools,
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class AgentMessage:
72
+ """A message between agents."""
73
+
74
+ message_id: str
75
+ sender_id: str
76
+ receiver_id: Optional[str] # None for broadcast
77
+ message_type: MessageType
78
+ content: str
79
+ timestamp: datetime
80
+
81
+ # Optional structured data
82
+ tool_calls: Optional[list[dict]] = None
83
+ tool_results: Optional[list[dict]] = None
84
+ metadata: dict[str, Any] = field(default_factory=dict)
85
+
86
+ def to_span_event(self) -> dict[str, Any]:
87
+ """Convert to span event for tracing."""
88
+ return {
89
+ "name": f"agent.message.{self.message_type.value}",
90
+ "attributes": {
91
+ "message.id": self.message_id,
92
+ "message.sender": self.sender_id,
93
+ "message.receiver": self.receiver_id,
94
+ "message.type": self.message_type.value,
95
+ "message.content_length": len(self.content),
96
+ },
97
+ }
98
+
99
+
100
+ @dataclass
101
+ class TaskAssignment:
102
+ """A task assigned from one agent to another."""
103
+
104
+ task_id: str
105
+ assigner_id: str
106
+ assignee_id: str
107
+ description: str
108
+ expected_output: Optional[str] = None
109
+
110
+ # Execution tracking
111
+ status: Literal["pending", "in_progress", "completed", "failed"] = "pending"
112
+ started_at: Optional[datetime] = None
113
+ completed_at: Optional[datetime] = None
114
+ result: Optional[str] = None
115
+ error: Optional[str] = None
116
+
117
+ # Token/cost tracking
118
+ total_tokens: int = 0
119
+ total_cost_usd: float = 0.0
120
+
121
+
122
+ @dataclass
123
+ class CrewExecution:
124
+ """Top-level execution of a multi-agent crew/team."""
125
+
126
+ execution_id: str
127
+ framework: str
128
+
129
+ # Crew configuration
130
+ agents: list[AgentDefinition] = field(default_factory=list)
131
+ tasks: list[TaskAssignment] = field(default_factory=list)
132
+
133
+ # Execution tracking
134
+ started_at: Optional[datetime] = None
135
+ completed_at: Optional[datetime] = None
136
+ status: Literal["running", "completed", "failed"] = "running"
137
+
138
+ # Aggregated metrics
139
+ total_llm_calls: int = 0
140
+ total_tokens: int = 0
141
+ total_cost_usd: float = 0.0
142
+ total_tool_calls: int = 0
143
+ total_agent_messages: int = 0
144
+
145
+
146
+ @dataclass
147
+ class ConversationTurn:
148
+ """A single turn in a multi-agent conversation (for AutoGen style)."""
149
+
150
+ turn_id: str
151
+ turn_number: int
152
+ speaker_id: str
153
+ content: str
154
+ timestamp: datetime
155
+
156
+ # If this turn triggered an LLM call
157
+ llm_call_id: Optional[str] = None
158
+ tokens_used: int = 0
159
+
160
+ # If this turn triggered tool use
161
+ tool_calls: list[dict] = field(default_factory=list)
162
+
163
+
164
+ # Helper functions
165
+
166
+
167
+ def generate_agent_id(framework: str, name: str) -> str:
168
+ """Generate a consistent agent ID.
169
+
170
+ Args:
171
+ framework: The multi-agent framework (e.g., "crewai", "autogen")
172
+ name: The agent name
173
+
174
+ Returns:
175
+ A deterministic 12-character hash based on framework and name
176
+ """
177
+ return hashlib.sha256(f"{framework}:{name}".encode()).hexdigest()[:12]
178
+
179
+
180
+ def extract_agent_graph(
181
+ agents: list[AgentDefinition], messages: list[AgentMessage]
182
+ ) -> dict:
183
+ """Extract agent communication graph for visualization.
184
+
185
+ Args:
186
+ agents: List of agent definitions
187
+ messages: List of messages exchanged between agents
188
+
189
+ Returns:
190
+ A dictionary with "nodes" and "edges" for graph visualization
191
+ """
192
+ nodes = {a.agent_id: {"name": a.name, "role": a.role.value} for a in agents}
193
+ edges = []
194
+ for msg in messages:
195
+ if msg.receiver_id:
196
+ edges.append(
197
+ {
198
+ "from": msg.sender_id,
199
+ "to": msg.receiver_id,
200
+ "type": msg.message_type.value,
201
+ }
202
+ )
203
+ return {"nodes": nodes, "edges": edges}
@@ -0,0 +1,231 @@
1
+ """Swarm instrumentation for Prela.
2
+
3
+ This module provides automatic instrumentation for OpenAI Swarm multi-agent framework,
4
+ capturing agent orchestration, handoffs, and execution flow.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import uuid
11
+ from typing import Any, Optional
12
+
13
+ from prela.core.span import 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 SwarmInstrumentor(Instrumentor):
21
+ """Instrumentor for OpenAI Swarm multi-agent framework."""
22
+
23
+ FRAMEWORK = "swarm"
24
+
25
+ @property
26
+ def is_instrumented(self) -> bool:
27
+ """Check if Swarm is currently instrumented."""
28
+ return self._is_instrumented
29
+
30
+ def __init__(self):
31
+ super().__init__()
32
+ self._active_swarms: dict[str, dict] = {}
33
+ self._is_instrumented = False
34
+ self._tracer: Optional[Tracer] = None
35
+
36
+ @require_tier("Swarm instrumentation", "lunch-money")
37
+ def instrument(self, tracer: Optional[Tracer] = None) -> None:
38
+ """Patch Swarm classes for tracing.
39
+
40
+ Args:
41
+ tracer: Optional tracer to use. If None, uses global tracer.
42
+ """
43
+ if self.is_instrumented:
44
+ return
45
+
46
+ try:
47
+ from swarm import Agent, Swarm
48
+ except ImportError:
49
+ return # Swarm not installed
50
+
51
+ self._tracer = tracer or get_tracer()
52
+
53
+ # Patch Swarm.run (main execution entry point)
54
+ if hasattr(Swarm, "run"):
55
+ self._patch_swarm_run(Swarm)
56
+
57
+ # Patch Agent.__init__ to track agents
58
+ if hasattr(Agent, "__init__"):
59
+ self._patch_agent_init(Agent)
60
+
61
+ self._is_instrumented = True
62
+
63
+ def uninstrument(self) -> None:
64
+ """Restore original methods."""
65
+ if not self.is_instrumented:
66
+ return
67
+
68
+ try:
69
+ from swarm import Agent, Swarm
70
+ except ImportError:
71
+ return
72
+
73
+ # Restore all patched methods
74
+ for cls, method_name in [
75
+ (Swarm, "run"),
76
+ (Agent, "__init__"),
77
+ ]:
78
+ if hasattr(cls, f"_prela_original_{method_name}"):
79
+ original = getattr(cls, f"_prela_original_{method_name}")
80
+ setattr(cls, method_name, original)
81
+ delattr(cls, f"_prela_original_{method_name}")
82
+
83
+ self._is_instrumented = False
84
+ self._tracer = None
85
+
86
+ def _patch_swarm_run(self, swarm_cls) -> None:
87
+ """Patch Swarm.run to create execution spans."""
88
+ if hasattr(swarm_cls, "_prela_original_run"):
89
+ return
90
+
91
+ original_run = swarm_cls.run
92
+ swarm_cls._prela_original_run = original_run
93
+
94
+ instrumentor = self
95
+
96
+ @functools.wraps(original_run)
97
+ def wrapped_run(swarm_self, agent, messages, *args, **kwargs):
98
+ tracer = instrumentor._tracer
99
+ if not tracer:
100
+ return original_run(swarm_self, agent, messages, *args, **kwargs)
101
+
102
+ execution_id = str(uuid.uuid4())
103
+ instrumentor._active_swarms[execution_id] = {
104
+ "agents_seen": set(),
105
+ "handoffs": [],
106
+ "total_tokens": 0,
107
+ }
108
+
109
+ # Get initial agent name
110
+ initial_agent_name = agent.name if hasattr(agent, "name") else "unnamed"
111
+
112
+ # Extract context variables
113
+ context_vars = kwargs.get("context_variables", {})
114
+ context_keys = list(context_vars.keys()) if context_vars else []
115
+
116
+ swarm_attributes = {
117
+ "swarm.execution_id": execution_id,
118
+ "swarm.framework": instrumentor.FRAMEWORK,
119
+ "swarm.initial_agent": initial_agent_name,
120
+ "swarm.num_messages": len(messages) if messages else 0,
121
+ "swarm.context_variables": context_keys,
122
+ }
123
+
124
+ # NEW: Replay capture if enabled
125
+ replay_capture = None
126
+ if tracer.capture_for_replay:
127
+ from prela.core.replay import ReplayCapture
128
+
129
+ replay_capture = ReplayCapture()
130
+ # Capture agent context
131
+ replay_capture.set_agent_context(
132
+ system_prompt=getattr(agent, "instructions", None),
133
+ available_tools=[
134
+ {"name": func.__name__, "description": func.__doc__ or ""}
135
+ for func in getattr(agent, "functions", [])
136
+ ],
137
+ config={
138
+ "framework": instrumentor.FRAMEWORK,
139
+ "execution_id": execution_id,
140
+ "agent_name": initial_agent_name,
141
+ },
142
+ memory={"context_variables": context_keys},
143
+ )
144
+
145
+ with tracer.span(
146
+ name=f"swarm.run.{initial_agent_name}",
147
+ span_type=SpanType.AGENT,
148
+ attributes=swarm_attributes,
149
+ ) as span:
150
+ try:
151
+ result = original_run(swarm_self, agent, messages, *args, **kwargs)
152
+
153
+ # Add execution statistics
154
+ swarm_state = instrumentor._active_swarms.get(execution_id, {})
155
+ agents_used = list(swarm_state.get("agents_seen", set()))
156
+ if agents_used:
157
+ span.set_attribute("swarm.agents_used", agents_used)
158
+
159
+ handoffs = swarm_state.get("handoffs", [])
160
+ span.set_attribute("swarm.num_handoffs", len(handoffs))
161
+
162
+ # Capture final agent
163
+ if hasattr(result, "agent"):
164
+ final_agent = (
165
+ result.agent.name
166
+ if hasattr(result.agent, "name")
167
+ else "unnamed"
168
+ )
169
+ span.set_attribute("swarm.final_agent", final_agent)
170
+
171
+ # Capture response messages
172
+ if hasattr(result, "messages"):
173
+ span.set_attribute(
174
+ "swarm.response_messages", len(result.messages)
175
+ )
176
+
177
+ # Capture context updates
178
+ if hasattr(result, "context_variables"):
179
+ updated_keys = list(result.context_variables.keys())
180
+ span.set_attribute("swarm.updated_context", updated_keys)
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.add_event(
194
+ "exception",
195
+ attributes={
196
+ "exception.type": type(e).__name__,
197
+ "exception.message": str(e),
198
+ },
199
+ )
200
+ raise
201
+ finally:
202
+ if execution_id in instrumentor._active_swarms:
203
+ del instrumentor._active_swarms[execution_id]
204
+
205
+ swarm_cls.run = wrapped_run
206
+
207
+ def _patch_agent_init(self, agent_cls) -> None:
208
+ """Patch Agent.__init__ to track agent creation."""
209
+ if hasattr(agent_cls, "_prela_original___init__"):
210
+ return
211
+
212
+ original_init = agent_cls.__init__
213
+ agent_cls._prela_original___init__ = original_init
214
+
215
+ instrumentor = self
216
+
217
+ @functools.wraps(original_init)
218
+ def wrapped_init(agent_self, *args, **kwargs):
219
+ result = original_init(agent_self, *args, **kwargs)
220
+
221
+ # Generate agent ID for tracking
222
+ agent_name = (
223
+ agent_self.name if hasattr(agent_self, "name") else str(id(agent_self))
224
+ )
225
+ agent_self._prela_agent_id = generate_agent_id(
226
+ instrumentor.FRAMEWORK, agent_name
227
+ )
228
+
229
+ return result
230
+
231
+ agent_cls.__init__ = wrapped_init
@@ -0,0 +1,68 @@
1
+ """
2
+ n8n workflow automation platform instrumentation.
3
+
4
+ This module provides automatic tracing for n8n workflows, capturing:
5
+ - Workflow executions with status and timing
6
+ - Individual node executions (AI and non-AI)
7
+ - LLM calls with token usage and costs
8
+ - Tool invocations and retrievals
9
+ - Error tracking and retries
10
+
11
+ Usage:
12
+ import prela
13
+ from prela.instrumentation.n8n import N8nInstrumentor
14
+
15
+ # Initialize Prela
16
+ tracer = prela.init(service_name="n8n-workflows")
17
+
18
+ # Instrument n8n
19
+ N8nInstrumentor().instrument(tracer)
20
+
21
+ # All n8n workflow executions are now automatically traced
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ # Check tier on module import
27
+ from prela.license import check_tier
28
+
29
+ if not check_tier("n8n instrumentation", "lunch-money", silent=False):
30
+ raise ImportError(
31
+ "n8n instrumentation requires 'lunch-money' subscription or higher. "
32
+ "Upgrade at https://prela.dev/pricing"
33
+ )
34
+
35
+ from prela.instrumentation.n8n.code_node import (
36
+ PrelaN8nContext,
37
+ prela_n8n_traced,
38
+ trace_n8n_code,
39
+ )
40
+ from prela.instrumentation.n8n.models import (
41
+ N8nAINodeExecution,
42
+ N8nNodeExecution,
43
+ N8nSpanType,
44
+ N8nWorkflowExecution,
45
+ )
46
+ from prela.instrumentation.n8n.webhook import (
47
+ N8N_AI_NODE_TYPES,
48
+ N8nWebhookHandler,
49
+ N8nWebhookPayload,
50
+ parse_n8n_webhook,
51
+ )
52
+
53
+ __all__ = [
54
+ # Models
55
+ "N8nWorkflowExecution",
56
+ "N8nNodeExecution",
57
+ "N8nAINodeExecution",
58
+ "N8nSpanType",
59
+ # Webhook
60
+ "N8nWebhookHandler",
61
+ "N8nWebhookPayload",
62
+ "parse_n8n_webhook",
63
+ "N8N_AI_NODE_TYPES",
64
+ # Code Node Helpers
65
+ "PrelaN8nContext",
66
+ "trace_n8n_code",
67
+ "prela_n8n_traced",
68
+ ]