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,394 @@
1
+ """HTTP exporter for sending spans to remote observability backends.
2
+
3
+ This module provides an HTTP-based exporter that sends spans to remote
4
+ Prela backends (e.g., Ingest Gateway on Railway) using JSON over HTTP.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import gzip
10
+ import json
11
+ import logging
12
+ from typing import Any, Optional
13
+
14
+ from prela.core.span import Span
15
+ from prela.exporters.base import BatchExporter, ExportResult
16
+ from prela.license import set_tier
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class HTTPExporter(BatchExporter):
22
+ """
23
+ Export spans to a remote HTTP endpoint using JSON.
24
+
25
+ Features:
26
+ - Batching with configurable size limits
27
+ - Retry with exponential backoff
28
+ - Optional gzip compression
29
+ - Authentication via API key or Bearer token
30
+ - Timeout handling
31
+
32
+ The exporter sends spans to the configured endpoint as JSON with the format:
33
+ ```json
34
+ {
35
+ "trace_id": "...",
36
+ "service_name": "...",
37
+ "started_at": "2025-01-27T10:00:00.000000Z",
38
+ "completed_at": "2025-01-27T10:00:01.000000Z",
39
+ "duration_ms": 1000.0,
40
+ "status": "SUCCESS",
41
+ "spans": [...]
42
+ }
43
+ ```
44
+
45
+ Example:
46
+ ```python
47
+ from prela import init
48
+
49
+ # Simple usage with Railway deployment
50
+ init(
51
+ service_name="my-agent",
52
+ exporter="http",
53
+ http_endpoint="https://prela-ingest-gateway-xxx.railway.app/v1/traces"
54
+ )
55
+
56
+ # Advanced usage with authentication and compression
57
+ from prela.exporters.http import HTTPExporter
58
+ from prela import Tracer
59
+
60
+ exporter = HTTPExporter(
61
+ endpoint="https://api.prela.io/v1/traces",
62
+ api_key="your-api-key",
63
+ compress=True,
64
+ timeout_ms=10000
65
+ )
66
+
67
+ tracer = Tracer(service_name="my-agent", exporter=exporter)
68
+ ```
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ endpoint: str,
74
+ api_key: Optional[str] = None,
75
+ bearer_token: Optional[str] = None,
76
+ compress: bool = False,
77
+ headers: Optional[dict[str, str]] = None,
78
+ max_retries: int = 3,
79
+ initial_backoff_ms: float = 100.0,
80
+ max_backoff_ms: float = 10000.0,
81
+ timeout_ms: float = 30000.0,
82
+ ) -> None:
83
+ """
84
+ Initialize HTTP exporter.
85
+
86
+ Args:
87
+ endpoint: The HTTP endpoint URL (e.g., "https://api.prela.io/v1/traces")
88
+ api_key: Optional API key for authentication (sent as X-API-Key header)
89
+ bearer_token: Optional Bearer token for authentication
90
+ compress: Enable gzip compression for request body
91
+ headers: Additional HTTP headers to include
92
+ max_retries: Maximum number of retry attempts (default: 3)
93
+ initial_backoff_ms: Initial backoff delay in milliseconds (default: 100)
94
+ max_backoff_ms: Maximum backoff delay in milliseconds (default: 10000)
95
+ timeout_ms: Timeout for HTTP requests in milliseconds (default: 30000)
96
+
97
+ Raises:
98
+ ValueError: If endpoint is empty or both api_key and bearer_token are provided
99
+ """
100
+ if not endpoint:
101
+ raise ValueError("endpoint cannot be empty")
102
+
103
+ if api_key and bearer_token:
104
+ raise ValueError("Cannot specify both api_key and bearer_token")
105
+
106
+ super().__init__(
107
+ max_retries=max_retries,
108
+ initial_backoff_ms=initial_backoff_ms,
109
+ max_backoff_ms=max_backoff_ms,
110
+ timeout_ms=timeout_ms,
111
+ )
112
+
113
+ self.endpoint = endpoint.rstrip("/")
114
+ self.api_key = api_key
115
+ self.bearer_token = bearer_token
116
+ self.compress = compress
117
+ self.headers = headers or {}
118
+
119
+ # Import requests here to make it optional dependency
120
+ try:
121
+ import requests
122
+
123
+ self._requests = requests
124
+ self._session = requests.Session()
125
+
126
+ # Set default headers
127
+ self._session.headers.update(
128
+ {
129
+ "Content-Type": "application/json",
130
+ "User-Agent": "prela-sdk/0.1.0",
131
+ }
132
+ )
133
+
134
+ # Add authentication headers
135
+ if self.api_key:
136
+ self._session.headers["X-API-Key"] = self.api_key
137
+ elif self.bearer_token:
138
+ self._session.headers["Authorization"] = f"Bearer {self.bearer_token}"
139
+
140
+ # Add custom headers
141
+ self._session.headers.update(self.headers)
142
+
143
+ # Add compression header if enabled
144
+ if self.compress:
145
+ self._session.headers["Content-Encoding"] = "gzip"
146
+
147
+ except ImportError:
148
+ raise ImportError(
149
+ "requests library is required for HTTPExporter. "
150
+ "Install it with: pip install requests"
151
+ )
152
+
153
+ logger.debug(
154
+ "Initialized HTTPExporter: endpoint=%s, compress=%s",
155
+ self.endpoint,
156
+ self.compress,
157
+ )
158
+
159
+ def _do_export(self, spans: list[Span]) -> ExportResult:
160
+ """
161
+ Perform the actual HTTP export operation.
162
+
163
+ Sends spans to the configured endpoint as JSON. Groups spans by trace_id
164
+ and sends each trace as a separate request.
165
+
166
+ Args:
167
+ spans: List of spans to export
168
+
169
+ Returns:
170
+ ExportResult.SUCCESS if export succeeded
171
+ ExportResult.RETRY if export should be retried (5xx errors, network errors)
172
+ ExportResult.FAILURE if export failed permanently (4xx errors)
173
+ """
174
+ if not spans:
175
+ return ExportResult.SUCCESS
176
+
177
+ # Group spans by trace_id
178
+ traces: dict[str, list[Span]] = {}
179
+ for span in spans:
180
+ if span.trace_id not in traces:
181
+ traces[span.trace_id] = []
182
+ traces[span.trace_id].append(span)
183
+
184
+ # Send each trace
185
+ for trace_id, trace_spans in traces.items():
186
+ try:
187
+ result = self._send_trace(trace_id, trace_spans)
188
+ if result != ExportResult.SUCCESS:
189
+ return result
190
+ except Exception as e:
191
+ logger.error("Failed to export trace %s: %s", trace_id, str(e))
192
+ # Network errors should be retried
193
+ if self._is_retryable_error(e):
194
+ return ExportResult.RETRY
195
+ return ExportResult.FAILURE
196
+
197
+ return ExportResult.SUCCESS
198
+
199
+ def _send_trace(self, trace_id: str, spans: list[Span]) -> ExportResult:
200
+ """
201
+ Send a single trace to the HTTP endpoint.
202
+
203
+ Args:
204
+ trace_id: The trace ID
205
+ spans: List of spans in this trace
206
+
207
+ Returns:
208
+ ExportResult indicating success, retry, or failure
209
+ """
210
+ # Build trace payload
211
+ # Sort spans by started_at to find root span
212
+ sorted_spans = sorted(spans, key=lambda s: s.started_at)
213
+ root_span = sorted_spans[0]
214
+
215
+ # Get service name from root span or first span with the attribute
216
+ service_name = None
217
+ for span in sorted_spans:
218
+ if "service.name" in span.attributes:
219
+ service_name = span.attributes["service.name"]
220
+ break
221
+
222
+ if not service_name:
223
+ service_name = "unknown"
224
+
225
+ payload = {
226
+ "trace_id": trace_id,
227
+ "service_name": service_name,
228
+ "started_at": root_span.started_at.isoformat() + "Z",
229
+ "completed_at": sorted_spans[-1].ended_at.isoformat() + "Z"
230
+ if sorted_spans[-1].ended_at
231
+ else root_span.started_at.isoformat() + "Z",
232
+ "duration_ms": sum(
233
+ (s.ended_at - s.started_at).total_seconds() * 1000
234
+ for s in spans
235
+ if s.ended_at
236
+ ),
237
+ "status": self._aggregate_status(spans),
238
+ "spans": [self._span_to_dict(span) for span in spans],
239
+ }
240
+
241
+ # Serialize to JSON
242
+ try:
243
+ json_data = json.dumps(payload)
244
+ except (TypeError, ValueError) as e:
245
+ logger.error("Failed to serialize trace %s: %s", trace_id, str(e))
246
+ return ExportResult.FAILURE
247
+
248
+ # Compress if enabled
249
+ if self.compress:
250
+ json_bytes = json_data.encode("utf-8")
251
+ body = gzip.compress(json_bytes)
252
+ else:
253
+ body = json_data
254
+
255
+ # Send HTTP request
256
+ try:
257
+ timeout_seconds = self.timeout_ms / 1000
258
+ response = self._session.post(
259
+ self.endpoint, data=body, timeout=timeout_seconds
260
+ )
261
+
262
+ # Check response status
263
+ if response.status_code == 200 or response.status_code == 202:
264
+ logger.debug(
265
+ "Successfully exported trace %s (%d spans)",
266
+ trace_id[:8],
267
+ len(spans),
268
+ )
269
+
270
+ # Extract tier from response if available
271
+ # The ingest gateway can return tier info in custom headers
272
+ if "X-Prela-Tier" in response.headers:
273
+ tier = response.headers["X-Prela-Tier"]
274
+ set_tier(tier)
275
+ logger.debug(f"Subscription tier detected: {tier}")
276
+
277
+ return ExportResult.SUCCESS
278
+
279
+ elif 400 <= response.status_code < 500:
280
+ # Client errors (bad request, auth, etc.) - don't retry
281
+ logger.error(
282
+ "Export failed with client error %d: %s",
283
+ response.status_code,
284
+ response.text[:200],
285
+ )
286
+ return ExportResult.FAILURE
287
+
288
+ elif 500 <= response.status_code < 600:
289
+ # Server errors - retry
290
+ logger.warning(
291
+ "Export failed with server error %d: %s",
292
+ response.status_code,
293
+ response.text[:200],
294
+ )
295
+ return ExportResult.RETRY
296
+
297
+ else:
298
+ # Unexpected status code
299
+ logger.error(
300
+ "Export failed with unexpected status %d: %s",
301
+ response.status_code,
302
+ response.text[:200],
303
+ )
304
+ return ExportResult.FAILURE
305
+
306
+ except self._requests.exceptions.Timeout:
307
+ logger.warning("Export request timed out after %.2fs", timeout_seconds)
308
+ return ExportResult.RETRY
309
+
310
+ except self._requests.exceptions.ConnectionError as e:
311
+ logger.warning("Export connection error: %s", str(e))
312
+ return ExportResult.RETRY
313
+
314
+ except Exception as e:
315
+ logger.error("Export request failed: %s", str(e))
316
+ return ExportResult.RETRY
317
+
318
+ def _span_to_dict(self, span: Span) -> dict[str, Any]:
319
+ """
320
+ Convert a span to a dictionary for JSON serialization.
321
+
322
+ Args:
323
+ span: The span to convert
324
+
325
+ Returns:
326
+ Dictionary representation of the span
327
+ """
328
+ return {
329
+ "span_id": span.span_id,
330
+ "trace_id": span.trace_id,
331
+ "parent_span_id": span.parent_span_id,
332
+ "name": span.name,
333
+ "span_type": span.span_type.value,
334
+ "started_at": span.started_at.isoformat() + "Z",
335
+ "ended_at": span.ended_at.isoformat() + "Z" if span.ended_at else None,
336
+ "duration_ms": (span.ended_at - span.started_at).total_seconds() * 1000
337
+ if span.ended_at
338
+ else 0.0,
339
+ "status": span.status.value,
340
+ "status_message": span.status_message,
341
+ "attributes": span.attributes,
342
+ "events": [
343
+ {
344
+ "name": event.name,
345
+ "timestamp": event.timestamp.isoformat() + "Z",
346
+ "attributes": event.attributes,
347
+ }
348
+ for event in span.events
349
+ ],
350
+ }
351
+
352
+ def _aggregate_status(self, spans: list[Span]) -> str:
353
+ """
354
+ Aggregate span statuses into a single trace status.
355
+
356
+ Args:
357
+ spans: List of spans in the trace
358
+
359
+ Returns:
360
+ "ERROR" if any span has ERROR status, otherwise "SUCCESS"
361
+ """
362
+ from prela.core.span import SpanStatus
363
+
364
+ for span in spans:
365
+ if span.status == SpanStatus.ERROR:
366
+ return "ERROR"
367
+ return "SUCCESS"
368
+
369
+ def _is_retryable_error(self, error: Exception) -> bool:
370
+ """
371
+ Check if an error is retryable.
372
+
373
+ Args:
374
+ error: The exception to check
375
+
376
+ Returns:
377
+ True if the error should be retried, False otherwise
378
+ """
379
+ # Network errors, timeouts, and server errors are retryable
380
+ retryable_types = (
381
+ self._requests.exceptions.Timeout,
382
+ self._requests.exceptions.ConnectionError,
383
+ self._requests.exceptions.HTTPError,
384
+ )
385
+ return isinstance(error, retryable_types)
386
+
387
+ def shutdown(self) -> None:
388
+ """
389
+ Shutdown the exporter and close the HTTP session.
390
+ """
391
+ super().shutdown()
392
+ if hasattr(self, "_session"):
393
+ self._session.close()
394
+ logger.debug("HTTP session closed")
@@ -0,0 +1,154 @@
1
+ """
2
+ Multi exporter for sending traces to multiple backends simultaneously.
3
+
4
+ This exporter allows you to send the same traces to multiple destinations,
5
+ such as console + file, or file + OTLP, etc.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Sequence
12
+
13
+ from prela.core.span import Span
14
+ from prela.exporters.base import BaseExporter, ExportResult
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class MultiExporter(BaseExporter):
20
+ """
21
+ Exporter that fans out to multiple exporters.
22
+
23
+ This allows sending traces to multiple backends simultaneously.
24
+ Each exporter is called independently, and failures in one exporter
25
+ don't affect others.
26
+
27
+ Example:
28
+ ```python
29
+ from prela import init
30
+ from prela.exporters import ConsoleExporter, FileExporter, MultiExporter
31
+ from prela.exporters.otlp import OTLPExporter
32
+
33
+ # Send to console + file + OTLP
34
+ exporter = MultiExporter([
35
+ ConsoleExporter(verbosity="normal"),
36
+ FileExporter(directory="./traces"),
37
+ OTLPExporter(endpoint="http://localhost:4318")
38
+ ])
39
+
40
+ init(service_name="my-app", exporter=exporter)
41
+ ```
42
+
43
+ Example with mixed results:
44
+ ```python
45
+ # Some exporters may succeed while others fail
46
+ exporter = MultiExporter([
47
+ ConsoleExporter(), # Always succeeds
48
+ FileExporter(), # May fail (disk full)
49
+ OTLPExporter() # May fail (network down)
50
+ ])
51
+
52
+ # MultiExporter returns:
53
+ # - SUCCESS if ALL exporters succeed
54
+ # - RETRY if ANY exporter requests retry
55
+ # - FAILURE if all exporters fail
56
+ ```
57
+ """
58
+
59
+ def __init__(self, exporters: Sequence[BaseExporter]):
60
+ """
61
+ Initialize multi exporter.
62
+
63
+ Args:
64
+ exporters: List of exporters to fan out to
65
+
66
+ Raises:
67
+ ValueError: If exporters list is empty
68
+ """
69
+ if not exporters:
70
+ raise ValueError("MultiExporter requires at least one exporter")
71
+
72
+ self.exporters = list(exporters)
73
+ logger.debug(f"MultiExporter initialized with {len(self.exporters)} exporters")
74
+
75
+ def export(self, spans: list[Span]) -> ExportResult:
76
+ """
77
+ Export spans to all exporters.
78
+
79
+ The export result is determined by:
80
+ - SUCCESS: If all exporters succeed
81
+ - RETRY: If any exporter requests retry (even if others succeed)
82
+ - FAILURE: If all exporters fail
83
+
84
+ Args:
85
+ spans: List of spans to export
86
+
87
+ Returns:
88
+ ExportResult based on combined results
89
+ """
90
+ if not spans:
91
+ return ExportResult.SUCCESS
92
+
93
+ results = []
94
+
95
+ # Export to each exporter
96
+ for i, exporter in enumerate(self.exporters):
97
+ try:
98
+ result = exporter.export(spans)
99
+ results.append(result)
100
+ logger.debug(
101
+ f"Exporter {i} ({exporter.__class__.__name__}): {result.name}"
102
+ )
103
+ except Exception as e:
104
+ logger.error(
105
+ f"Exporter {i} ({exporter.__class__.__name__}) raised exception: {e}",
106
+ exc_info=True,
107
+ )
108
+ results.append(ExportResult.FAILURE)
109
+
110
+ # Determine combined result
111
+ return self._combine_results(results)
112
+
113
+ def _combine_results(self, results: list[ExportResult]) -> ExportResult:
114
+ """
115
+ Combine results from multiple exporters.
116
+
117
+ Logic:
118
+ - If any exporter requests RETRY, return RETRY
119
+ - Else if all exporters return FAILURE, return FAILURE
120
+ - Else return SUCCESS
121
+
122
+ Args:
123
+ results: List of export results
124
+
125
+ Returns:
126
+ Combined export result
127
+ """
128
+ if not results:
129
+ return ExportResult.SUCCESS
130
+
131
+ # Check for retry first (highest priority)
132
+ if ExportResult.RETRY in results:
133
+ return ExportResult.RETRY
134
+
135
+ # Check if all failed
136
+ if all(r == ExportResult.FAILURE for r in results):
137
+ return ExportResult.FAILURE
138
+
139
+ # Otherwise success (at least one succeeded)
140
+ return ExportResult.SUCCESS
141
+
142
+ def shutdown(self) -> None:
143
+ """Shutdown all exporters."""
144
+ logger.debug(f"Shutting down {len(self.exporters)} exporters")
145
+
146
+ for i, exporter in enumerate(self.exporters):
147
+ try:
148
+ exporter.shutdown()
149
+ logger.debug(f"Exporter {i} ({exporter.__class__.__name__}) shutdown")
150
+ except Exception as e:
151
+ logger.error(
152
+ f"Exporter {i} ({exporter.__class__.__name__}) shutdown error: {e}",
153
+ exc_info=True,
154
+ )