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
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
|
+
)
|