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