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/exporters/http.py
ADDED
|
@@ -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")
|
prela/exporters/multi.py
ADDED
|
@@ -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
|
+
)
|