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,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for n8n workflow telemetry.
|
|
3
|
+
|
|
4
|
+
These models represent n8n workflow executions, node executions, and AI-specific
|
|
5
|
+
node executions with comprehensive telemetry data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Literal, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field, field_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class N8nSpanType(str, Enum):
|
|
18
|
+
"""Span types specific to n8n workflow executions."""
|
|
19
|
+
|
|
20
|
+
WORKFLOW = "n8n.workflow"
|
|
21
|
+
NODE = "n8n.node"
|
|
22
|
+
AI_AGENT = "n8n.ai_agent"
|
|
23
|
+
LLM = "n8n.llm"
|
|
24
|
+
TOOL = "n8n.tool"
|
|
25
|
+
RETRIEVAL = "n8n.retrieval"
|
|
26
|
+
MEMORY = "n8n.memory"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class N8nWorkflowExecution(BaseModel):
|
|
30
|
+
"""
|
|
31
|
+
Represents a complete n8n workflow execution.
|
|
32
|
+
|
|
33
|
+
Captures high-level metadata about a workflow run, including timing,
|
|
34
|
+
status, node counts, and aggregate token/cost metrics.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
workflow_id: str = Field(..., description="Unique identifier for the workflow")
|
|
38
|
+
workflow_name: str = Field(..., description="Human-readable workflow name")
|
|
39
|
+
execution_id: str = Field(
|
|
40
|
+
..., description="Unique identifier for this execution instance"
|
|
41
|
+
)
|
|
42
|
+
trigger_type: str = Field(
|
|
43
|
+
...,
|
|
44
|
+
description="How the workflow was triggered (webhook, cron, manual, etc.)",
|
|
45
|
+
)
|
|
46
|
+
started_at: datetime = Field(..., description="When the workflow execution began")
|
|
47
|
+
completed_at: Optional[datetime] = Field(
|
|
48
|
+
None, description="When the workflow execution completed"
|
|
49
|
+
)
|
|
50
|
+
status: Literal["running", "success", "error", "waiting"] = Field(
|
|
51
|
+
..., description="Current execution status"
|
|
52
|
+
)
|
|
53
|
+
node_count: int = Field(
|
|
54
|
+
..., ge=0, description="Total number of nodes executed"
|
|
55
|
+
)
|
|
56
|
+
ai_node_count: int = Field(
|
|
57
|
+
0, ge=0, description="Number of AI-related nodes executed"
|
|
58
|
+
)
|
|
59
|
+
total_tokens: int = Field(
|
|
60
|
+
0, ge=0, description="Total tokens consumed across all AI nodes"
|
|
61
|
+
)
|
|
62
|
+
total_cost_usd: float = Field(
|
|
63
|
+
0.0, ge=0.0, description="Total estimated cost in USD"
|
|
64
|
+
)
|
|
65
|
+
error_message: Optional[str] = Field(
|
|
66
|
+
None, description="Error message if status is 'error'"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@field_validator("node_count", "ai_node_count", "total_tokens")
|
|
70
|
+
@classmethod
|
|
71
|
+
def validate_non_negative(cls, v: int) -> int:
|
|
72
|
+
"""Ensure counts are non-negative."""
|
|
73
|
+
if v < 0:
|
|
74
|
+
raise ValueError("Count must be non-negative")
|
|
75
|
+
return v
|
|
76
|
+
|
|
77
|
+
@field_validator("total_cost_usd")
|
|
78
|
+
@classmethod
|
|
79
|
+
def validate_cost(cls, v: float) -> float:
|
|
80
|
+
"""Ensure cost is non-negative."""
|
|
81
|
+
if v < 0.0:
|
|
82
|
+
raise ValueError("Cost must be non-negative")
|
|
83
|
+
return v
|
|
84
|
+
|
|
85
|
+
@field_validator("completed_at")
|
|
86
|
+
@classmethod
|
|
87
|
+
def validate_completed_at(
|
|
88
|
+
cls, v: Optional[datetime], info
|
|
89
|
+
) -> Optional[datetime]:
|
|
90
|
+
"""Ensure completed_at is after started_at if both exist."""
|
|
91
|
+
if v is not None and "started_at" in info.data:
|
|
92
|
+
started_at = info.data["started_at"]
|
|
93
|
+
if v < started_at:
|
|
94
|
+
raise ValueError("completed_at must be after started_at")
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
def duration_ms(self) -> Optional[float]:
|
|
98
|
+
"""Calculate execution duration in milliseconds."""
|
|
99
|
+
if self.completed_at is None:
|
|
100
|
+
return None
|
|
101
|
+
delta = self.completed_at - self.started_at
|
|
102
|
+
return delta.total_seconds() * 1000
|
|
103
|
+
|
|
104
|
+
def to_span_attributes(self) -> dict:
|
|
105
|
+
"""Convert to Prela span attributes."""
|
|
106
|
+
attrs = {
|
|
107
|
+
"n8n.workflow_id": self.workflow_id,
|
|
108
|
+
"n8n.workflow_name": self.workflow_name,
|
|
109
|
+
"n8n.execution_id": self.execution_id,
|
|
110
|
+
"n8n.trigger_type": self.trigger_type,
|
|
111
|
+
"n8n.status": self.status,
|
|
112
|
+
"n8n.node_count": self.node_count,
|
|
113
|
+
"n8n.ai_node_count": self.ai_node_count,
|
|
114
|
+
"n8n.total_tokens": self.total_tokens,
|
|
115
|
+
"n8n.total_cost_usd": self.total_cost_usd,
|
|
116
|
+
}
|
|
117
|
+
if self.error_message:
|
|
118
|
+
attrs["n8n.error_message"] = self.error_message
|
|
119
|
+
duration = self.duration_ms()
|
|
120
|
+
if duration is not None:
|
|
121
|
+
attrs["n8n.duration_ms"] = duration
|
|
122
|
+
return attrs
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class N8nNodeExecution(BaseModel):
|
|
126
|
+
"""
|
|
127
|
+
Represents execution of a single node within a workflow.
|
|
128
|
+
|
|
129
|
+
Captures node-level telemetry including input/output data, timing,
|
|
130
|
+
status, and error information.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
node_id: str = Field(..., description="Unique identifier for the node")
|
|
134
|
+
node_name: str = Field(..., description="Human-readable node name")
|
|
135
|
+
node_type: str = Field(
|
|
136
|
+
...,
|
|
137
|
+
description="Node type identifier (e.g., 'n8n-nodes-langchain.agent')",
|
|
138
|
+
)
|
|
139
|
+
execution_index: int = Field(
|
|
140
|
+
..., ge=0, description="Order of execution within workflow"
|
|
141
|
+
)
|
|
142
|
+
started_at: datetime = Field(..., description="When node execution began")
|
|
143
|
+
completed_at: Optional[datetime] = Field(
|
|
144
|
+
None, description="When node execution completed"
|
|
145
|
+
)
|
|
146
|
+
status: Literal["success", "error"] = Field(
|
|
147
|
+
..., description="Node execution status"
|
|
148
|
+
)
|
|
149
|
+
input_data: Optional[dict] = Field(
|
|
150
|
+
None, description="JSON representation of input items"
|
|
151
|
+
)
|
|
152
|
+
output_data: Optional[dict] = Field(
|
|
153
|
+
None, description="JSON representation of output items"
|
|
154
|
+
)
|
|
155
|
+
error_message: Optional[str] = Field(
|
|
156
|
+
None, description="Error message if status is 'error'"
|
|
157
|
+
)
|
|
158
|
+
retry_count: int = Field(0, ge=0, description="Number of retry attempts")
|
|
159
|
+
|
|
160
|
+
@field_validator("execution_index", "retry_count")
|
|
161
|
+
@classmethod
|
|
162
|
+
def validate_non_negative(cls, v: int) -> int:
|
|
163
|
+
"""Ensure counts are non-negative."""
|
|
164
|
+
if v < 0:
|
|
165
|
+
raise ValueError("Value must be non-negative")
|
|
166
|
+
return v
|
|
167
|
+
|
|
168
|
+
@field_validator("completed_at")
|
|
169
|
+
@classmethod
|
|
170
|
+
def validate_completed_at(
|
|
171
|
+
cls, v: Optional[datetime], info
|
|
172
|
+
) -> Optional[datetime]:
|
|
173
|
+
"""Ensure completed_at is after started_at if both exist."""
|
|
174
|
+
if v is not None and "started_at" in info.data:
|
|
175
|
+
started_at = info.data["started_at"]
|
|
176
|
+
if v < started_at:
|
|
177
|
+
raise ValueError("completed_at must be after started_at")
|
|
178
|
+
return v
|
|
179
|
+
|
|
180
|
+
def duration_ms(self) -> Optional[float]:
|
|
181
|
+
"""Calculate node execution duration in milliseconds."""
|
|
182
|
+
if self.completed_at is None:
|
|
183
|
+
return None
|
|
184
|
+
delta = self.completed_at - self.started_at
|
|
185
|
+
return delta.total_seconds() * 1000
|
|
186
|
+
|
|
187
|
+
def to_span_attributes(self) -> dict:
|
|
188
|
+
"""Convert to Prela span attributes."""
|
|
189
|
+
attrs = {
|
|
190
|
+
"n8n.node_id": self.node_id,
|
|
191
|
+
"n8n.node_name": self.node_name,
|
|
192
|
+
"n8n.node_type": self.node_type,
|
|
193
|
+
"n8n.execution_index": self.execution_index,
|
|
194
|
+
"n8n.status": self.status,
|
|
195
|
+
"n8n.retry_count": self.retry_count,
|
|
196
|
+
}
|
|
197
|
+
if self.error_message:
|
|
198
|
+
attrs["n8n.error_message"] = self.error_message
|
|
199
|
+
if self.input_data:
|
|
200
|
+
attrs["n8n.input_data"] = str(self.input_data)[:500] # Truncate
|
|
201
|
+
if self.output_data:
|
|
202
|
+
attrs["n8n.output_data"] = str(self.output_data)[:500] # Truncate
|
|
203
|
+
duration = self.duration_ms()
|
|
204
|
+
if duration is not None:
|
|
205
|
+
attrs["n8n.duration_ms"] = duration
|
|
206
|
+
return attrs
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class N8nAINodeExecution(N8nNodeExecution):
|
|
210
|
+
"""
|
|
211
|
+
Represents execution of an AI-specific node (LLM, agent, vector store, etc.).
|
|
212
|
+
|
|
213
|
+
Extends N8nNodeExecution with AI-specific telemetry including model info,
|
|
214
|
+
token usage, costs, prompts, and retrieval data.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
model: Optional[str] = Field(
|
|
218
|
+
None, description="Model identifier (e.g., 'gpt-4', 'claude-3-opus')"
|
|
219
|
+
)
|
|
220
|
+
provider: Optional[str] = Field(
|
|
221
|
+
None, description="AI provider (openai, anthropic, ollama, etc.)"
|
|
222
|
+
)
|
|
223
|
+
prompt_tokens: int = Field(0, ge=0, description="Tokens in the prompt")
|
|
224
|
+
completion_tokens: int = Field(0, ge=0, description="Tokens in the completion")
|
|
225
|
+
total_tokens: int = Field(0, ge=0, description="Total tokens consumed")
|
|
226
|
+
cost_usd: float = Field(0.0, ge=0.0, description="Estimated cost in USD")
|
|
227
|
+
temperature: Optional[float] = Field(
|
|
228
|
+
None, ge=0.0, le=2.0, description="Temperature parameter"
|
|
229
|
+
)
|
|
230
|
+
system_prompt: Optional[str] = Field(None, description="System prompt text")
|
|
231
|
+
user_prompt: Optional[str] = Field(None, description="User prompt text")
|
|
232
|
+
response_content: Optional[str] = Field(None, description="LLM response content")
|
|
233
|
+
tool_calls: Optional[list[dict]] = Field(
|
|
234
|
+
None, description="List of tool calls made by the LLM"
|
|
235
|
+
)
|
|
236
|
+
retrieval_query: Optional[str] = Field(
|
|
237
|
+
None, description="Query used for vector store retrieval"
|
|
238
|
+
)
|
|
239
|
+
retrieved_documents: Optional[list[dict]] = Field(
|
|
240
|
+
None, description="Documents retrieved from vector store"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@field_validator("prompt_tokens", "completion_tokens", "total_tokens")
|
|
244
|
+
@classmethod
|
|
245
|
+
def validate_non_negative_tokens(cls, v: int) -> int:
|
|
246
|
+
"""Ensure token counts are non-negative."""
|
|
247
|
+
if v < 0:
|
|
248
|
+
raise ValueError("Token count must be non-negative")
|
|
249
|
+
return v
|
|
250
|
+
|
|
251
|
+
@field_validator("cost_usd")
|
|
252
|
+
@classmethod
|
|
253
|
+
def validate_cost(cls, v: float) -> float:
|
|
254
|
+
"""Ensure cost is non-negative."""
|
|
255
|
+
if v < 0.0:
|
|
256
|
+
raise ValueError("Cost must be non-negative")
|
|
257
|
+
return v
|
|
258
|
+
|
|
259
|
+
@field_validator("temperature")
|
|
260
|
+
@classmethod
|
|
261
|
+
def validate_temperature(cls, v: Optional[float]) -> Optional[float]:
|
|
262
|
+
"""Ensure temperature is in valid range."""
|
|
263
|
+
if v is not None and (v < 0.0 or v > 2.0):
|
|
264
|
+
raise ValueError("Temperature must be between 0.0 and 2.0")
|
|
265
|
+
return v
|
|
266
|
+
|
|
267
|
+
def to_span_attributes(self) -> dict:
|
|
268
|
+
"""Convert to Prela span attributes with AI-specific fields."""
|
|
269
|
+
# Start with base node attributes
|
|
270
|
+
attrs = super().to_span_attributes()
|
|
271
|
+
|
|
272
|
+
# Add AI-specific attributes
|
|
273
|
+
if self.model:
|
|
274
|
+
attrs["llm.model"] = self.model
|
|
275
|
+
if self.provider:
|
|
276
|
+
attrs["llm.provider"] = self.provider
|
|
277
|
+
if self.prompt_tokens:
|
|
278
|
+
attrs["llm.prompt_tokens"] = self.prompt_tokens
|
|
279
|
+
if self.completion_tokens:
|
|
280
|
+
attrs["llm.completion_tokens"] = self.completion_tokens
|
|
281
|
+
if self.total_tokens:
|
|
282
|
+
attrs["llm.total_tokens"] = self.total_tokens
|
|
283
|
+
if self.cost_usd:
|
|
284
|
+
attrs["llm.cost_usd"] = self.cost_usd
|
|
285
|
+
if self.temperature is not None:
|
|
286
|
+
attrs["llm.temperature"] = self.temperature
|
|
287
|
+
if self.system_prompt:
|
|
288
|
+
attrs["llm.system_prompt"] = self.system_prompt[:500] # Truncate
|
|
289
|
+
if self.user_prompt:
|
|
290
|
+
attrs["llm.user_prompt"] = self.user_prompt[:500] # Truncate
|
|
291
|
+
if self.response_content:
|
|
292
|
+
attrs["llm.response_content"] = self.response_content[:500] # Truncate
|
|
293
|
+
if self.tool_calls:
|
|
294
|
+
attrs["llm.tool_calls_count"] = len(self.tool_calls)
|
|
295
|
+
attrs["llm.tool_calls"] = str(self.tool_calls)[:500] # Truncate
|
|
296
|
+
if self.retrieval_query:
|
|
297
|
+
attrs["retrieval.query"] = self.retrieval_query[:200] # Truncate
|
|
298
|
+
if self.retrieved_documents:
|
|
299
|
+
attrs["retrieval.document_count"] = len(self.retrieved_documents)
|
|
300
|
+
attrs["retrieval.documents"] = str(self.retrieved_documents)[
|
|
301
|
+
:500
|
|
302
|
+
] # Truncate
|
|
303
|
+
|
|
304
|
+
return attrs
|
|
305
|
+
|
|
306
|
+
def infer_span_type(self) -> N8nSpanType:
|
|
307
|
+
"""
|
|
308
|
+
Infer the appropriate span type based on node characteristics.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
N8nSpanType appropriate for this node's function
|
|
312
|
+
"""
|
|
313
|
+
node_type_lower = self.node_type.lower()
|
|
314
|
+
|
|
315
|
+
# Check for specific node types
|
|
316
|
+
if "agent" in node_type_lower:
|
|
317
|
+
return N8nSpanType.AI_AGENT
|
|
318
|
+
elif any(
|
|
319
|
+
x in node_type_lower
|
|
320
|
+
for x in ["chat", "llm", "openai", "anthropic", "ollama"]
|
|
321
|
+
):
|
|
322
|
+
return N8nSpanType.LLM
|
|
323
|
+
elif any(
|
|
324
|
+
x in node_type_lower for x in ["tool", "function", "code"]
|
|
325
|
+
):
|
|
326
|
+
return N8nSpanType.TOOL
|
|
327
|
+
elif any(
|
|
328
|
+
x in node_type_lower
|
|
329
|
+
for x in ["vector", "retrieval", "search", "pinecone", "qdrant"]
|
|
330
|
+
):
|
|
331
|
+
return N8nSpanType.RETRIEVAL
|
|
332
|
+
elif any(x in node_type_lower for x in ["memory", "buffer", "history"]):
|
|
333
|
+
return N8nSpanType.MEMORY
|
|
334
|
+
else:
|
|
335
|
+
# Default to generic node type
|
|
336
|
+
return N8nSpanType.NODE
|