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
prela/core/span.py ADDED
@@ -0,0 +1,295 @@
1
+ """Span implementation for distributed tracing of AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+
12
+ class SpanType(Enum):
13
+ """Type of span in the trace."""
14
+
15
+ AGENT = "agent"
16
+ LLM = "llm"
17
+ TOOL = "tool"
18
+ RETRIEVAL = "retrieval"
19
+ EMBEDDING = "embedding"
20
+ CUSTOM = "custom"
21
+
22
+
23
+ class SpanStatus(Enum):
24
+ """Status of a span."""
25
+
26
+ PENDING = "pending"
27
+ SUCCESS = "success"
28
+ ERROR = "error"
29
+
30
+
31
+ @dataclass
32
+ class SpanEvent:
33
+ """An event that occurred during a span's execution."""
34
+
35
+ __slots__ = ("timestamp", "name", "attributes")
36
+
37
+ timestamp: datetime
38
+ name: str
39
+ attributes: dict[str, Any]
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ """Convert event to dictionary representation."""
43
+ return {
44
+ "timestamp": self.timestamp.isoformat(),
45
+ "name": self.name,
46
+ "attributes": self.attributes,
47
+ }
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict[str, Any]) -> SpanEvent:
51
+ """Create event from dictionary representation."""
52
+ return cls(
53
+ timestamp=datetime.fromisoformat(data["timestamp"]),
54
+ name=data["name"],
55
+ attributes=data["attributes"],
56
+ )
57
+
58
+
59
+ class Span:
60
+ """A span represents a unit of work in a distributed trace.
61
+
62
+ Spans are immutable after being ended. Any attempt to modify an ended span
63
+ will raise a RuntimeError.
64
+ """
65
+
66
+ __slots__ = (
67
+ "span_id",
68
+ "trace_id",
69
+ "parent_span_id",
70
+ "name",
71
+ "span_type",
72
+ "started_at",
73
+ "ended_at",
74
+ "status",
75
+ "status_message",
76
+ "attributes",
77
+ "events",
78
+ "_ended",
79
+ "replay_snapshot",
80
+ "_tracer",
81
+ "_context_token",
82
+ "_sampled",
83
+ )
84
+
85
+ def __init__(
86
+ self,
87
+ span_id: str | None = None,
88
+ trace_id: str | None = None,
89
+ parent_span_id: str | None = None,
90
+ name: str = "",
91
+ span_type: SpanType = SpanType.CUSTOM,
92
+ started_at: datetime | None = None,
93
+ ended_at: datetime | None = None,
94
+ status: SpanStatus = SpanStatus.PENDING,
95
+ status_message: str | None = None,
96
+ attributes: dict[str, Any] | None = None,
97
+ events: list[SpanEvent] | None = None,
98
+ _ended: bool = False,
99
+ replay_snapshot: Any = None,
100
+ ) -> None:
101
+ """Initialize a new span.
102
+
103
+ Args:
104
+ span_id: Unique identifier for this span (generates UUID if not provided)
105
+ trace_id: Trace ID this span belongs to (generates UUID if not provided)
106
+ parent_span_id: Parent span ID if this is a child span
107
+ name: Human-readable name for this span
108
+ span_type: Type of operation this span represents
109
+ started_at: When the span started (uses current time if not provided)
110
+ ended_at: When the span ended (None if still running)
111
+ status: Current status of the span
112
+ status_message: Optional message describing the status
113
+ attributes: Key-value pairs of metadata
114
+ events: List of events that occurred during span execution
115
+ _ended: Internal flag for immutability (do not set manually)
116
+ replay_snapshot: Optional replay data for deterministic re-execution
117
+ """
118
+ object.__setattr__(self, "span_id", span_id or str(uuid.uuid4()))
119
+ object.__setattr__(self, "trace_id", trace_id or str(uuid.uuid4()))
120
+ object.__setattr__(self, "parent_span_id", parent_span_id)
121
+ object.__setattr__(self, "name", name)
122
+ object.__setattr__(self, "span_type", span_type)
123
+ object.__setattr__(self, "started_at", started_at or datetime.now(timezone.utc))
124
+ object.__setattr__(self, "ended_at", ended_at)
125
+ object.__setattr__(self, "status", status)
126
+ object.__setattr__(self, "status_message", status_message)
127
+ object.__setattr__(self, "attributes", attributes or {})
128
+ object.__setattr__(self, "events", events or [])
129
+ object.__setattr__(self, "_ended", _ended)
130
+ object.__setattr__(self, "replay_snapshot", replay_snapshot)
131
+
132
+ def _check_ended(self) -> None:
133
+ """Raise error if span has been ended."""
134
+ if self._ended: # type: ignore[has-type]
135
+ raise RuntimeError(
136
+ f"Cannot modify span '{self.name}' (ID: {self.span_id}) after it has ended"
137
+ )
138
+
139
+ def set_attribute(self, key: str, value: Any) -> None:
140
+ """Set an attribute on the span.
141
+
142
+ Args:
143
+ key: Attribute key
144
+ value: Attribute value
145
+
146
+ Raises:
147
+ RuntimeError: If the span has already ended
148
+ """
149
+ self._check_ended()
150
+ self.attributes[key] = value # type: ignore[has-type]
151
+
152
+ def add_event(self, name: str, attributes: dict[str, Any] | None = None) -> None:
153
+ """Add an event to the span.
154
+
155
+ Args:
156
+ name: Event name
157
+ attributes: Optional event attributes
158
+
159
+ Raises:
160
+ RuntimeError: If the span has already ended
161
+ """
162
+ self._check_ended()
163
+ event = SpanEvent(
164
+ timestamp=datetime.now(timezone.utc),
165
+ name=name,
166
+ attributes=attributes or {},
167
+ )
168
+ self.events.append(event) # type: ignore[has-type]
169
+
170
+ def set_status(self, status: SpanStatus, message: str | None = None) -> None:
171
+ """Set the status of the span.
172
+
173
+ Args:
174
+ status: Span status
175
+ message: Optional status message
176
+
177
+ Raises:
178
+ RuntimeError: If the span has already ended
179
+ """
180
+ self._check_ended()
181
+ object.__setattr__(self, "status", status)
182
+ object.__setattr__(self, "status_message", message)
183
+
184
+ def end(self, end_time: datetime | None = None) -> None:
185
+ """End the span.
186
+
187
+ Args:
188
+ end_time: When the span ended (uses current time if not provided)
189
+
190
+ Raises:
191
+ RuntimeError: If the span has already ended
192
+ """
193
+ self._check_ended()
194
+ object.__setattr__(self, "ended_at", end_time or datetime.now(timezone.utc))
195
+ object.__setattr__(self, "_ended", True)
196
+
197
+ # Set status to SUCCESS if still PENDING
198
+ if self.status == SpanStatus.PENDING: # type: ignore[has-type]
199
+ object.__setattr__(self, "status", SpanStatus.SUCCESS)
200
+
201
+ # Handle cleanup for spans created via start_span()
202
+ if hasattr(self, "_tracer"):
203
+ from prela.core.context import get_current_context, reset_context
204
+
205
+ ctx = get_current_context()
206
+ if ctx:
207
+ # Pop span from context
208
+ ctx.pop_span()
209
+
210
+ # Add to completed spans collection
211
+ ctx.add_completed_span(self)
212
+
213
+ # Export if sampled and this is a root span
214
+ tracer = getattr(self, "_tracer")
215
+ sampled = getattr(self, "_sampled", False)
216
+ if sampled and self.parent_span_id is None and tracer.exporter: # type: ignore[has-type]
217
+ # Export ALL spans in the trace, not just the root
218
+ tracer.exporter.export(ctx.all_spans)
219
+
220
+ # Reset context if we created it
221
+ token = getattr(self, "_context_token", None)
222
+ if token is not None:
223
+ reset_context(token)
224
+
225
+ @property
226
+ def duration_ms(self) -> float | None:
227
+ """Get the duration of the span in milliseconds.
228
+
229
+ Returns:
230
+ Duration in milliseconds, or None if span hasn't ended
231
+ """
232
+ if self.ended_at is None: # type: ignore[has-type]
233
+ return None
234
+ delta = self.ended_at - self.started_at # type: ignore[has-type]
235
+ return delta.total_seconds() * 1000
236
+
237
+ def to_dict(self) -> dict[str, Any]:
238
+ """Convert span to dictionary representation.
239
+
240
+ Returns:
241
+ Dictionary containing all span data
242
+ """
243
+ result = {
244
+ "span_id": self.span_id, # type: ignore[has-type]
245
+ "trace_id": self.trace_id, # type: ignore[has-type]
246
+ "parent_span_id": self.parent_span_id, # type: ignore[has-type]
247
+ "name": self.name, # type: ignore[has-type]
248
+ "span_type": self.span_type.value, # type: ignore[has-type]
249
+ "started_at": self.started_at.isoformat(), # type: ignore[has-type]
250
+ "ended_at": self.ended_at.isoformat() if self.ended_at else None, # type: ignore[has-type]
251
+ "status": self.status.value, # type: ignore[has-type]
252
+ "status_message": self.status_message, # type: ignore[has-type]
253
+ "attributes": self.attributes, # type: ignore[has-type]
254
+ "events": [event.to_dict() for event in self.events], # type: ignore[has-type]
255
+ "duration_ms": self.duration_ms,
256
+ }
257
+
258
+ # Include replay data if present
259
+ if self.replay_snapshot is not None: # type: ignore[has-type]
260
+ result["replay_snapshot"] = self.replay_snapshot.to_dict()
261
+
262
+ return result
263
+
264
+ @classmethod
265
+ def from_dict(cls, data: dict[str, Any]) -> Span:
266
+ """Create span from dictionary representation.
267
+
268
+ Args:
269
+ data: Dictionary containing span data
270
+
271
+ Returns:
272
+ Reconstructed Span instance
273
+ """
274
+ # Deserialize replay snapshot if present
275
+ replay_snapshot = None
276
+ if "replay_snapshot" in data:
277
+ from prela.core.replay import ReplaySnapshot
278
+
279
+ replay_snapshot = ReplaySnapshot.from_dict(data["replay_snapshot"])
280
+
281
+ return cls(
282
+ span_id=data["span_id"],
283
+ trace_id=data["trace_id"],
284
+ parent_span_id=data.get("parent_span_id"),
285
+ name=data["name"],
286
+ span_type=SpanType(data["span_type"]),
287
+ started_at=datetime.fromisoformat(data["started_at"]),
288
+ ended_at=(datetime.fromisoformat(data["ended_at"]) if data.get("ended_at") else None),
289
+ status=SpanStatus(data.get("status", "pending")),
290
+ status_message=data.get("status_message"),
291
+ attributes=data.get("attributes", {}),
292
+ events=[SpanEvent.from_dict(e) for e in data.get("events", [])],
293
+ _ended=data.get("ended_at") is not None,
294
+ replay_snapshot=replay_snapshot,
295
+ )