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