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/clock.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Clock utilities for consistent time handling.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for working with timestamps and durations
|
|
4
|
+
in a consistent way across the SDK. All timestamps are in UTC.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def now() -> datetime:
|
|
14
|
+
"""Get the current UTC time with microsecond precision.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Current datetime in UTC with microsecond precision
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
>>> timestamp = now()
|
|
21
|
+
>>> timestamp.tzinfo == timezone.utc
|
|
22
|
+
True
|
|
23
|
+
"""
|
|
24
|
+
return datetime.now(timezone.utc)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def monotonic_ns() -> int:
|
|
28
|
+
"""Get monotonic time in nanoseconds.
|
|
29
|
+
|
|
30
|
+
This is useful for measuring durations as it's not affected by
|
|
31
|
+
system clock adjustments.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Monotonic time in nanoseconds
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> start = monotonic_ns()
|
|
38
|
+
>>> # ... do work ...
|
|
39
|
+
>>> end = monotonic_ns()
|
|
40
|
+
>>> elapsed_ms = duration_ms(start, end)
|
|
41
|
+
"""
|
|
42
|
+
return time.perf_counter_ns()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def duration_ms(start_ns: int, end_ns: int) -> float:
|
|
46
|
+
"""Calculate duration in milliseconds from nanosecond timestamps.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
start_ns: Start time in nanoseconds (from monotonic_ns)
|
|
50
|
+
end_ns: End time in nanoseconds (from monotonic_ns)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Duration in milliseconds
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> start = monotonic_ns()
|
|
57
|
+
>>> end = start + 1_500_000 # 1.5ms later
|
|
58
|
+
>>> duration_ms(start, end)
|
|
59
|
+
1.5
|
|
60
|
+
"""
|
|
61
|
+
return (end_ns - start_ns) / 1_000_000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def format_timestamp(dt: datetime) -> str:
|
|
65
|
+
"""Format a datetime as ISO 8601 string.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
dt: Datetime to format
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
ISO 8601 formatted string
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
>>> dt = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc)
|
|
75
|
+
>>> format_timestamp(dt)
|
|
76
|
+
'2024-01-15T12:30:45.123456+00:00'
|
|
77
|
+
"""
|
|
78
|
+
return dt.isoformat()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_timestamp(s: str) -> datetime:
|
|
82
|
+
"""Parse an ISO 8601 timestamp string.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
s: ISO 8601 formatted timestamp string
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Parsed datetime object
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If the string is not a valid ISO 8601 timestamp
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
>>> dt = parse_timestamp('2024-01-15T12:30:45.123456+00:00')
|
|
95
|
+
>>> dt.year
|
|
96
|
+
2024
|
|
97
|
+
"""
|
|
98
|
+
return datetime.fromisoformat(s)
|
prela/core/context.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Context propagation for distributed tracing.
|
|
2
|
+
|
|
3
|
+
This module provides thread-safe and async-safe context management using
|
|
4
|
+
Python's contextvars module. It allows for proper trace context propagation
|
|
5
|
+
across async boundaries and thread pools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from contextvars import ContextVar, Token, copy_context
|
|
14
|
+
from functools import wraps
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from prela.core.span import Span
|
|
18
|
+
|
|
19
|
+
# Context variable for thread-safe and async-safe storage
|
|
20
|
+
_current_context: ContextVar[TraceContext | None] = ContextVar("_current_context", default=None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TraceContext:
|
|
24
|
+
"""A trace context manages the current trace and span stack.
|
|
25
|
+
|
|
26
|
+
This class maintains the active trace ID, a stack of active spans,
|
|
27
|
+
and baggage (inherited metadata) that propagates through the trace.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("trace_id", "span_stack", "baggage", "sampled", "all_spans")
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
trace_id: str | None = None,
|
|
35
|
+
sampled: bool = True,
|
|
36
|
+
baggage: dict[str, str] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize a new trace context.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
trace_id: Unique identifier for this trace (generates UUID if not provided)
|
|
42
|
+
sampled: Whether this trace should be sampled/recorded
|
|
43
|
+
baggage: Initial baggage metadata to propagate
|
|
44
|
+
"""
|
|
45
|
+
self.trace_id = trace_id or str(uuid.uuid4())
|
|
46
|
+
self.span_stack: list[Span] = []
|
|
47
|
+
self.baggage: dict[str, str] = baggage or {}
|
|
48
|
+
self.sampled = sampled
|
|
49
|
+
self.all_spans: list[Span] = []
|
|
50
|
+
|
|
51
|
+
def current_span(self) -> Span | None:
|
|
52
|
+
"""Get the currently active span.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The span at the top of the stack, or None if stack is empty
|
|
56
|
+
"""
|
|
57
|
+
return self.span_stack[-1] if self.span_stack else None
|
|
58
|
+
|
|
59
|
+
def push_span(self, span: Span) -> None:
|
|
60
|
+
"""Push a span onto the stack.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
span: The span to make active
|
|
64
|
+
"""
|
|
65
|
+
self.span_stack.append(span)
|
|
66
|
+
|
|
67
|
+
def pop_span(self) -> Span | None:
|
|
68
|
+
"""Pop the current span from the stack.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The popped span, or None if stack was empty
|
|
72
|
+
"""
|
|
73
|
+
return self.span_stack.pop() if self.span_stack else None
|
|
74
|
+
|
|
75
|
+
def add_completed_span(self, span: Span) -> None:
|
|
76
|
+
"""Add a completed span to the collection.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
span: The completed span to add to the trace
|
|
80
|
+
"""
|
|
81
|
+
self.all_spans.append(span)
|
|
82
|
+
|
|
83
|
+
def set_baggage(self, key: str, value: str) -> None:
|
|
84
|
+
"""Set a baggage item that propagates through the trace.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: Baggage key
|
|
88
|
+
value: Baggage value
|
|
89
|
+
"""
|
|
90
|
+
self.baggage[key] = value
|
|
91
|
+
|
|
92
|
+
def get_baggage(self, key: str) -> str | None:
|
|
93
|
+
"""Get a baggage item.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
key: Baggage key
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Baggage value, or None if not found
|
|
100
|
+
"""
|
|
101
|
+
return self.baggage.get(key)
|
|
102
|
+
|
|
103
|
+
def clear_baggage(self) -> None:
|
|
104
|
+
"""Clear all baggage items."""
|
|
105
|
+
self.baggage.clear()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_current_context() -> TraceContext | None:
|
|
109
|
+
"""Get the current trace context.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The active trace context, or None if no context is active
|
|
113
|
+
"""
|
|
114
|
+
return _current_context.get()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_current_span() -> Span | None:
|
|
118
|
+
"""Get the currently active span.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The active span, or None if no context or no active span
|
|
122
|
+
"""
|
|
123
|
+
ctx = get_current_context()
|
|
124
|
+
return ctx.current_span() if ctx else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_current_trace_id() -> str | None:
|
|
128
|
+
"""Get the current trace ID.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The active trace ID, or None if no context is active
|
|
132
|
+
"""
|
|
133
|
+
ctx = get_current_context()
|
|
134
|
+
return ctx.trace_id if ctx else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def set_context(ctx: TraceContext) -> Token[TraceContext | None]:
|
|
138
|
+
"""Set the current trace context.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
ctx: The trace context to set as active
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A token that can be used to reset the context
|
|
145
|
+
"""
|
|
146
|
+
return _current_context.set(ctx)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def reset_context(token: Token[TraceContext | None]) -> None:
|
|
150
|
+
"""Reset the context to its previous value.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
token: The token returned by set_context
|
|
154
|
+
"""
|
|
155
|
+
_current_context.reset(token)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@contextmanager
|
|
159
|
+
def new_trace_context(
|
|
160
|
+
trace_id: str | None = None, sampled: bool = True, baggage: dict[str, str] | None = None
|
|
161
|
+
) -> Iterator[TraceContext]:
|
|
162
|
+
"""Create a new trace context for the duration of the context manager.
|
|
163
|
+
|
|
164
|
+
This context manager creates a new trace context, sets it as active,
|
|
165
|
+
yields it for use, and automatically resets it on exit.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
trace_id: Unique identifier for this trace (generates UUID if not provided)
|
|
169
|
+
sampled: Whether this trace should be sampled/recorded
|
|
170
|
+
baggage: Initial baggage metadata to propagate
|
|
171
|
+
|
|
172
|
+
Yields:
|
|
173
|
+
The newly created trace context
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
>>> with new_trace_context() as ctx:
|
|
177
|
+
... span = Span(name="operation", trace_id=ctx.trace_id)
|
|
178
|
+
... ctx.push_span(span)
|
|
179
|
+
... # Do work
|
|
180
|
+
... ctx.pop_span()
|
|
181
|
+
"""
|
|
182
|
+
ctx = TraceContext(trace_id=trace_id, sampled=sampled, baggage=baggage)
|
|
183
|
+
token = set_context(ctx)
|
|
184
|
+
try:
|
|
185
|
+
yield ctx
|
|
186
|
+
finally:
|
|
187
|
+
reset_context(token)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def copy_context_to_thread(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
191
|
+
"""Create a wrapper that copies the current context to a new thread.
|
|
192
|
+
|
|
193
|
+
This function captures the current contextvars context at call time
|
|
194
|
+
and creates a wrapper that will run the function in that context.
|
|
195
|
+
This is essential for maintaining trace continuity when using thread pools.
|
|
196
|
+
|
|
197
|
+
IMPORTANT: Call this function INSIDE the context you want to propagate,
|
|
198
|
+
BEFORE submitting to the thread pool.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
func: The function to wrap
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Wrapped function that will run in the captured context
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
>>> def background_task():
|
|
208
|
+
... span = get_current_span()
|
|
209
|
+
... print(f"Span: {span}")
|
|
210
|
+
>>>
|
|
211
|
+
>>> with new_trace_context() as ctx:
|
|
212
|
+
... # Capture context NOW, before submitting to pool
|
|
213
|
+
... wrapped = copy_context_to_thread(background_task)
|
|
214
|
+
... with ThreadPoolExecutor() as executor:
|
|
215
|
+
... future = executor.submit(wrapped)
|
|
216
|
+
... future.result()
|
|
217
|
+
"""
|
|
218
|
+
# Capture the full context (including all contextvars) NOW
|
|
219
|
+
# This happens in the calling thread
|
|
220
|
+
ctx = copy_context()
|
|
221
|
+
|
|
222
|
+
@wraps(func)
|
|
223
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
224
|
+
# Run the function in the captured context
|
|
225
|
+
# This ensures all contextvars are available in the worker thread
|
|
226
|
+
return ctx.run(func, *args, **kwargs)
|
|
227
|
+
|
|
228
|
+
return wrapper
|