spanwire 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.
- spanwire/__init__.py +145 -0
- spanwire/_version.py +1 -0
- spanwire/breadcrumbs.py +88 -0
- spanwire/context.py +59 -0
- spanwire/core.py +463 -0
- spanwire/exporters.py +188 -0
- spanwire/py.typed +0 -0
- spanwire/sampler.py +108 -0
- spanwire/w3c.py +160 -0
- spanwire-0.1.0.dist-info/METADATA +226 -0
- spanwire-0.1.0.dist-info/RECORD +14 -0
- spanwire-0.1.0.dist-info/WHEEL +5 -0
- spanwire-0.1.0.dist-info/licenses/LICENSE +21 -0
- spanwire-0.1.0.dist-info/top_level.txt +1 -0
spanwire/core.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Core primitives of spanwire: SpanContext, Span, Tracer, and SpanStatus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
Callable,
|
|
16
|
+
Dict,
|
|
17
|
+
List,
|
|
18
|
+
Mapping,
|
|
19
|
+
Optional,
|
|
20
|
+
Tuple,
|
|
21
|
+
TypeVar,
|
|
22
|
+
Union,
|
|
23
|
+
cast,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from spanwire.breadcrumbs import Breadcrumb, BreadcrumbBuffer
|
|
27
|
+
from spanwire.context import (
|
|
28
|
+
clear_baggage,
|
|
29
|
+
get_baggage,
|
|
30
|
+
get_current_span,
|
|
31
|
+
reset_current_span,
|
|
32
|
+
set_baggage,
|
|
33
|
+
set_current_span,
|
|
34
|
+
)
|
|
35
|
+
from spanwire.exporters import InMemoryExporter, SpanExporter
|
|
36
|
+
from spanwire.sampler import AlwaysOnSampler, Sampler
|
|
37
|
+
from spanwire.w3c import (
|
|
38
|
+
extract_w3c_headers,
|
|
39
|
+
format_traceparent,
|
|
40
|
+
inject_w3c_headers,
|
|
41
|
+
is_valid_span_id,
|
|
42
|
+
is_valid_trace_id,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def generate_trace_id() -> str:
|
|
49
|
+
"""Generate a random 128-bit (32 hex characters) trace ID."""
|
|
50
|
+
return secrets.token_hex(16)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def generate_span_id() -> str:
|
|
54
|
+
"""Generate a random 64-bit (16 hex characters) span ID."""
|
|
55
|
+
return secrets.token_hex(8)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SpanStatus(str, Enum):
|
|
59
|
+
"""Execution status of a span."""
|
|
60
|
+
|
|
61
|
+
UNSET = "UNSET"
|
|
62
|
+
OK = "OK"
|
|
63
|
+
ERROR = "ERROR"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class SpanContext:
|
|
68
|
+
"""Immutable context identifying a span across distributed components."""
|
|
69
|
+
|
|
70
|
+
trace_id: str
|
|
71
|
+
span_id: str
|
|
72
|
+
trace_flags: int = 1
|
|
73
|
+
tracestate: str = ""
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
if not is_valid_trace_id(self.trace_id):
|
|
77
|
+
raise ValueError(f"Invalid trace_id: {self.trace_id}")
|
|
78
|
+
if not is_valid_span_id(self.span_id):
|
|
79
|
+
raise ValueError(f"Invalid span_id: {self.span_id}")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_sampled(self) -> bool:
|
|
83
|
+
"""Return True if the sampled flag is set."""
|
|
84
|
+
return bool(self.trace_flags & 1)
|
|
85
|
+
|
|
86
|
+
def to_traceparent(self) -> str:
|
|
87
|
+
"""Format as W3C traceparent string."""
|
|
88
|
+
return format_traceparent(self.trace_id, self.span_id, self.trace_flags)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
91
|
+
"""Convert context to dictionary."""
|
|
92
|
+
return {
|
|
93
|
+
"trace_id": self.trace_id,
|
|
94
|
+
"span_id": self.span_id,
|
|
95
|
+
"trace_flags": self.trace_flags,
|
|
96
|
+
"tracestate": self.tracestate,
|
|
97
|
+
"is_sampled": self.is_sampled,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class SpanEvent:
|
|
103
|
+
"""A timed event attached to a span with arbitrary attributes."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
timestamp_ns: int = field(default_factory=time.time_ns)
|
|
107
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
110
|
+
return {
|
|
111
|
+
"name": self.name,
|
|
112
|
+
"timestamp_ns": self.timestamp_ns,
|
|
113
|
+
"attributes": dict(self.attributes),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Span:
|
|
118
|
+
"""Represents a single timed unit of work in a trace."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
name: str,
|
|
123
|
+
context: SpanContext,
|
|
124
|
+
parent_span_id: Optional[str] = None,
|
|
125
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
126
|
+
tracer: Optional[Tracer] = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self.name = name
|
|
129
|
+
self.context = context
|
|
130
|
+
self.parent_span_id = parent_span_id
|
|
131
|
+
self._tracer = tracer
|
|
132
|
+
|
|
133
|
+
self.start_time_ns: int = time.time_ns()
|
|
134
|
+
self.end_time_ns: Optional[int] = None
|
|
135
|
+
self.status: SpanStatus = SpanStatus.UNSET
|
|
136
|
+
self.status_description: str = ""
|
|
137
|
+
|
|
138
|
+
self.attributes: Dict[str, Any] = dict(attributes) if attributes else {}
|
|
139
|
+
self.events: List[SpanEvent] = []
|
|
140
|
+
self.breadcrumbs: List[Breadcrumb] = []
|
|
141
|
+
self._token: Optional[Any] = None
|
|
142
|
+
self._ended: bool = False
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def duration_ns(self) -> int:
|
|
146
|
+
"""Duration in nanoseconds."""
|
|
147
|
+
end = self.end_time_ns if self.end_time_ns is not None else time.time_ns()
|
|
148
|
+
return max(0, end - self.start_time_ns)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def duration_ms(self) -> float:
|
|
152
|
+
"""Duration in milliseconds."""
|
|
153
|
+
return self.duration_ns / 1_000_000.0
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def is_ended(self) -> bool:
|
|
157
|
+
return self._ended
|
|
158
|
+
|
|
159
|
+
def set_attribute(self, key: str, value: Any) -> Span:
|
|
160
|
+
"""Set a single key-value attribute."""
|
|
161
|
+
if not self._ended:
|
|
162
|
+
self.attributes[str(key)] = value
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def set_attributes(self, mapping: Mapping[str, Any]) -> Span:
|
|
166
|
+
"""Set multiple attributes at once."""
|
|
167
|
+
if not self._ended and mapping:
|
|
168
|
+
for k, v in mapping.items():
|
|
169
|
+
self.attributes[str(k)] = v
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def add_event(
|
|
173
|
+
self,
|
|
174
|
+
name: str,
|
|
175
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
176
|
+
) -> SpanEvent:
|
|
177
|
+
"""Add a timed milestone event to this span."""
|
|
178
|
+
event = SpanEvent(
|
|
179
|
+
name=name,
|
|
180
|
+
timestamp_ns=time.time_ns(),
|
|
181
|
+
attributes=dict(attributes) if attributes else {},
|
|
182
|
+
)
|
|
183
|
+
if not self._ended:
|
|
184
|
+
self.events.append(event)
|
|
185
|
+
return event
|
|
186
|
+
|
|
187
|
+
def set_status(
|
|
188
|
+
self,
|
|
189
|
+
status: Union[SpanStatus, str],
|
|
190
|
+
description: str = "",
|
|
191
|
+
) -> Span:
|
|
192
|
+
"""Set the completion status of the span."""
|
|
193
|
+
if not self._ended:
|
|
194
|
+
if isinstance(status, str):
|
|
195
|
+
self.status = SpanStatus(status.upper())
|
|
196
|
+
else:
|
|
197
|
+
self.status = status
|
|
198
|
+
self.status_description = description
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def record_exception(
|
|
202
|
+
self,
|
|
203
|
+
exc: BaseException,
|
|
204
|
+
escaped: bool = True,
|
|
205
|
+
) -> Span:
|
|
206
|
+
"""Record an exception on this span, setting status to ERROR."""
|
|
207
|
+
self.set_status(SpanStatus.ERROR, str(exc))
|
|
208
|
+
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
209
|
+
self.add_event(
|
|
210
|
+
"exception",
|
|
211
|
+
{
|
|
212
|
+
"exception.type": type(exc).__name__,
|
|
213
|
+
"exception.message": str(exc),
|
|
214
|
+
"exception.stacktrace": tb,
|
|
215
|
+
"exception.escaped": escaped,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
def add_breadcrumb(
|
|
221
|
+
self,
|
|
222
|
+
message: str,
|
|
223
|
+
level: str = "INFO",
|
|
224
|
+
category: str = "default",
|
|
225
|
+
**data: Any,
|
|
226
|
+
) -> Breadcrumb:
|
|
227
|
+
"""Record a breadcrumb specifically attached to this span."""
|
|
228
|
+
crumb = Breadcrumb(
|
|
229
|
+
message=message,
|
|
230
|
+
level=level.upper(),
|
|
231
|
+
category=category,
|
|
232
|
+
timestamp=time.time(),
|
|
233
|
+
data=data,
|
|
234
|
+
)
|
|
235
|
+
self.breadcrumbs.append(crumb)
|
|
236
|
+
return crumb
|
|
237
|
+
|
|
238
|
+
def end(self) -> None:
|
|
239
|
+
"""Mark the span as completed and dispatch to exporter."""
|
|
240
|
+
if self._ended:
|
|
241
|
+
return
|
|
242
|
+
self.end_time_ns = time.time_ns()
|
|
243
|
+
self._ended = True
|
|
244
|
+
|
|
245
|
+
if self.status == SpanStatus.UNSET:
|
|
246
|
+
self.status = SpanStatus.OK
|
|
247
|
+
|
|
248
|
+
# Collect breadcrumbs from tracer buffer if available
|
|
249
|
+
if self._tracer and hasattr(self._tracer, "breadcrumbs"):
|
|
250
|
+
recent = self._tracer.breadcrumbs.get_crumbs()
|
|
251
|
+
if recent and not self.breadcrumbs:
|
|
252
|
+
self.breadcrumbs.extend(recent)
|
|
253
|
+
|
|
254
|
+
if self._tracer:
|
|
255
|
+
self._tracer._on_span_ended(self)
|
|
256
|
+
|
|
257
|
+
def __enter__(self) -> Span:
|
|
258
|
+
self._token = set_current_span(self)
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def __exit__(
|
|
262
|
+
self,
|
|
263
|
+
exc_type: Optional[type],
|
|
264
|
+
exc_val: Optional[BaseException],
|
|
265
|
+
exc_tb: Optional[Any],
|
|
266
|
+
) -> bool:
|
|
267
|
+
try:
|
|
268
|
+
if exc_val is not None:
|
|
269
|
+
self.record_exception(exc_val)
|
|
270
|
+
self.end()
|
|
271
|
+
finally:
|
|
272
|
+
if self._token is not None:
|
|
273
|
+
reset_current_span(self._token)
|
|
274
|
+
self._token = None
|
|
275
|
+
return False # Do not suppress exceptions
|
|
276
|
+
|
|
277
|
+
async def __aenter__(self) -> Span:
|
|
278
|
+
return self.__enter__()
|
|
279
|
+
|
|
280
|
+
async def __aexit__(
|
|
281
|
+
self,
|
|
282
|
+
exc_type: Optional[type],
|
|
283
|
+
exc_val: Optional[BaseException],
|
|
284
|
+
exc_tb: Optional[Any],
|
|
285
|
+
) -> bool:
|
|
286
|
+
return self.__exit__(exc_type, exc_val, exc_tb)
|
|
287
|
+
|
|
288
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
289
|
+
"""Serialize span data to dictionary for JSON export."""
|
|
290
|
+
return {
|
|
291
|
+
"name": self.name,
|
|
292
|
+
"context": self.context.to_dict(),
|
|
293
|
+
"parent_span_id": self.parent_span_id,
|
|
294
|
+
"start_time_ns": self.start_time_ns,
|
|
295
|
+
"end_time_ns": self.end_time_ns,
|
|
296
|
+
"duration_ms": round(self.duration_ms, 3),
|
|
297
|
+
"status": self.status.value,
|
|
298
|
+
"status_description": self.status_description,
|
|
299
|
+
"attributes": self.attributes,
|
|
300
|
+
"events": [e.to_dict() for e in self.events],
|
|
301
|
+
"breadcrumbs": [b.to_dict() for b in self.breadcrumbs],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
def __repr__(self) -> str:
|
|
305
|
+
status_name = self.status.name
|
|
306
|
+
return (
|
|
307
|
+
f"Span(name={self.name!r}, trace_id={self.context.trace_id}, "
|
|
308
|
+
f"span_id={self.context.span_id}, status={status_name})"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Tracer:
|
|
313
|
+
"""Manages active spans, context propagation, breadcrumbs, and export."""
|
|
314
|
+
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
service_name: str = "unnamed-service",
|
|
318
|
+
sampler: Optional[Sampler] = None,
|
|
319
|
+
exporter: Optional[SpanExporter] = None,
|
|
320
|
+
breadcrumb_capacity: int = 50,
|
|
321
|
+
) -> None:
|
|
322
|
+
self.service_name = service_name
|
|
323
|
+
self.sampler: Sampler = sampler if sampler is not None else AlwaysOnSampler()
|
|
324
|
+
self.exporter: Optional[SpanExporter] = exporter
|
|
325
|
+
self.breadcrumbs = BreadcrumbBuffer(capacity=breadcrumb_capacity)
|
|
326
|
+
|
|
327
|
+
def current_span(self) -> Optional[Span]:
|
|
328
|
+
"""Return the currently active span in context."""
|
|
329
|
+
return get_current_span()
|
|
330
|
+
|
|
331
|
+
def current_context(self) -> Optional[SpanContext]:
|
|
332
|
+
"""Return the SpanContext of the currently active span, if any."""
|
|
333
|
+
span = self.current_span()
|
|
334
|
+
return span.context if span else None
|
|
335
|
+
|
|
336
|
+
def span(
|
|
337
|
+
self,
|
|
338
|
+
name: str,
|
|
339
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
340
|
+
parent_context: Optional[SpanContext] = None,
|
|
341
|
+
) -> Span:
|
|
342
|
+
"""Create and start a new Span as a child of the active context or parent_context."""
|
|
343
|
+
parent = parent_context or self.current_context()
|
|
344
|
+
|
|
345
|
+
if parent:
|
|
346
|
+
trace_id = parent.trace_id
|
|
347
|
+
parent_span_id = parent.span_id
|
|
348
|
+
tracestate = parent.tracestate
|
|
349
|
+
else:
|
|
350
|
+
trace_id = generate_trace_id()
|
|
351
|
+
parent_span_id = None
|
|
352
|
+
tracestate = ""
|
|
353
|
+
|
|
354
|
+
span_id = generate_span_id()
|
|
355
|
+
is_sampled = self.sampler.should_sample(parent, trace_id, name)
|
|
356
|
+
trace_flags = 1 if is_sampled else 0
|
|
357
|
+
|
|
358
|
+
ctx = SpanContext(
|
|
359
|
+
trace_id=trace_id,
|
|
360
|
+
span_id=span_id,
|
|
361
|
+
trace_flags=trace_flags,
|
|
362
|
+
tracestate=tracestate,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
merged_attrs = {"service.name": self.service_name}
|
|
366
|
+
if attributes:
|
|
367
|
+
merged_attrs.update(attributes)
|
|
368
|
+
|
|
369
|
+
return Span(
|
|
370
|
+
name=name,
|
|
371
|
+
context=ctx,
|
|
372
|
+
parent_span_id=parent_span_id,
|
|
373
|
+
attributes=merged_attrs,
|
|
374
|
+
tracer=self,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def add_breadcrumb(
|
|
378
|
+
self,
|
|
379
|
+
message: str,
|
|
380
|
+
level: str = "INFO",
|
|
381
|
+
category: str = "default",
|
|
382
|
+
data: Optional[Dict[str, Any]] = None,
|
|
383
|
+
**kwargs: Any,
|
|
384
|
+
) -> Breadcrumb:
|
|
385
|
+
"""Record a breadcrumb in the tracer buffer."""
|
|
386
|
+
return self.breadcrumbs.record(
|
|
387
|
+
message=message,
|
|
388
|
+
level=level,
|
|
389
|
+
category=category,
|
|
390
|
+
data=data,
|
|
391
|
+
**kwargs,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def inject_headers(
|
|
395
|
+
self,
|
|
396
|
+
headers: Optional[Dict[str, str]] = None,
|
|
397
|
+
context: Optional[SpanContext] = None,
|
|
398
|
+
) -> Dict[str, str]:
|
|
399
|
+
"""Inject current span's trace context and baggage into HTTP headers."""
|
|
400
|
+
ctx = context or self.current_context()
|
|
401
|
+
if not ctx:
|
|
402
|
+
return headers if headers is not None else {}
|
|
403
|
+
baggage = get_baggage()
|
|
404
|
+
return inject_w3c_headers(
|
|
405
|
+
trace_id=ctx.trace_id,
|
|
406
|
+
span_id=ctx.span_id,
|
|
407
|
+
trace_flags=ctx.trace_flags,
|
|
408
|
+
baggage=baggage,
|
|
409
|
+
tracestate=ctx.tracestate,
|
|
410
|
+
target_headers=headers,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def extract_headers(
|
|
414
|
+
self,
|
|
415
|
+
headers: Mapping[str, str],
|
|
416
|
+
) -> Tuple[Optional[SpanContext], Dict[str, str], str]:
|
|
417
|
+
"""Extract W3C trace context, baggage, and tracestate from incoming headers."""
|
|
418
|
+
tp_parsed, baggage, tracestate = extract_w3c_headers(headers)
|
|
419
|
+
if tp_parsed is None:
|
|
420
|
+
return None, baggage, tracestate
|
|
421
|
+
|
|
422
|
+
trace_id, parent_id, flags = tp_parsed
|
|
423
|
+
ctx = SpanContext(
|
|
424
|
+
trace_id=trace_id,
|
|
425
|
+
span_id=parent_id,
|
|
426
|
+
trace_flags=flags,
|
|
427
|
+
tracestate=tracestate,
|
|
428
|
+
)
|
|
429
|
+
return ctx, baggage, tracestate
|
|
430
|
+
|
|
431
|
+
def _on_span_ended(self, span: Span) -> None:
|
|
432
|
+
"""Called when a span completes."""
|
|
433
|
+
if self.exporter is not None and span.context.is_sampled:
|
|
434
|
+
self.exporter.export([span])
|
|
435
|
+
|
|
436
|
+
def trace(
|
|
437
|
+
self,
|
|
438
|
+
name: Optional[str] = None,
|
|
439
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
440
|
+
) -> Callable[[F], F]:
|
|
441
|
+
"""Decorator for tracing both synchronous and asynchronous functions."""
|
|
442
|
+
|
|
443
|
+
def decorator(fn: F) -> F:
|
|
444
|
+
span_name = name or fn.__qualname__
|
|
445
|
+
|
|
446
|
+
if inspect.iscoroutinefunction(fn):
|
|
447
|
+
|
|
448
|
+
@functools.wraps(fn)
|
|
449
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
450
|
+
with self.span(span_name, attributes=attributes):
|
|
451
|
+
return await fn(*args, **kwargs)
|
|
452
|
+
|
|
453
|
+
return cast(F, async_wrapper)
|
|
454
|
+
else:
|
|
455
|
+
|
|
456
|
+
@functools.wraps(fn)
|
|
457
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
458
|
+
with self.span(span_name, attributes=attributes):
|
|
459
|
+
return fn(*args, **kwargs)
|
|
460
|
+
|
|
461
|
+
return cast(F, sync_wrapper)
|
|
462
|
+
|
|
463
|
+
return decorator
|
spanwire/exporters.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Exporters for dispatching finished spans to telemetry collectors, logs, or memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, TextIO, Union
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from spanwire.core import Span
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SpanExporter(ABC):
|
|
18
|
+
"""Abstract base class for span exporters."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def export(self, spans: Sequence[Span]) -> None:
|
|
22
|
+
"""Export a batch of finished spans."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def shutdown(self) -> None:
|
|
26
|
+
"""Clean up exporter resources."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InMemoryExporter(SpanExporter):
|
|
31
|
+
"""Stores exported spans in memory, ideal for test suites and assertions."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._spans: List[Span] = []
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def export(self, spans: Sequence[Span]) -> None:
|
|
38
|
+
with self._lock:
|
|
39
|
+
self._spans.extend(spans)
|
|
40
|
+
|
|
41
|
+
def get_finished_spans(self) -> List[Span]:
|
|
42
|
+
"""Return a copy of all exported spans."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
return list(self._spans)
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
"""Clear all stored spans."""
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._spans.clear()
|
|
50
|
+
|
|
51
|
+
def __len__(self) -> int:
|
|
52
|
+
with self._lock:
|
|
53
|
+
return len(self._spans)
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return f"InMemoryExporter(count={len(self)})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class JsonLinesExporter(SpanExporter):
|
|
60
|
+
"""Exports finished spans formatted as newline-delimited JSON (NDJSON)."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
stream_or_path: Optional[Union[TextIO, str]] = None,
|
|
65
|
+
auto_flush: bool = True,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
self._auto_flush = auto_flush
|
|
69
|
+
self._owned_file: Optional[TextIO] = None
|
|
70
|
+
|
|
71
|
+
if stream_or_path is None:
|
|
72
|
+
self._stream: TextIO = sys.stdout
|
|
73
|
+
elif isinstance(stream_or_path, str):
|
|
74
|
+
self._owned_file = open(stream_or_path, "a", encoding="utf-8")
|
|
75
|
+
self._stream = self._owned_file
|
|
76
|
+
else:
|
|
77
|
+
self._stream = stream_or_path
|
|
78
|
+
|
|
79
|
+
def export(self, spans: Sequence[Span]) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
for span in spans:
|
|
82
|
+
record = span.to_dict()
|
|
83
|
+
line = json.dumps(record, default=str)
|
|
84
|
+
self._stream.write(line + "\n")
|
|
85
|
+
if self._auto_flush:
|
|
86
|
+
self._stream.flush()
|
|
87
|
+
|
|
88
|
+
def shutdown(self) -> None:
|
|
89
|
+
with self._lock:
|
|
90
|
+
if self._auto_flush:
|
|
91
|
+
self._stream.flush()
|
|
92
|
+
if self._owned_file is not None and not self._owned_file.closed:
|
|
93
|
+
self._owned_file.close()
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return "JsonLinesExporter()"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ConsoleExporter(SpanExporter):
|
|
100
|
+
"""Human-readable console output exporter with ANSI colors."""
|
|
101
|
+
|
|
102
|
+
COLOR_RESET = "\033[0m"
|
|
103
|
+
COLOR_CYAN = "\033[36m"
|
|
104
|
+
COLOR_GREEN = "\033[32m"
|
|
105
|
+
COLOR_YELLOW = "\033[33m"
|
|
106
|
+
COLOR_RED = "\033[31m"
|
|
107
|
+
COLOR_GRAY = "\033[90m"
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
stream: Optional[TextIO] = None,
|
|
112
|
+
colored: Optional[bool] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self._stream: TextIO = stream if stream is not None else sys.stderr
|
|
115
|
+
self._lock = threading.Lock()
|
|
116
|
+
|
|
117
|
+
if colored is not None:
|
|
118
|
+
self.colored = colored
|
|
119
|
+
else:
|
|
120
|
+
# Enable colors if running in a TTY or NO_COLOR is not set
|
|
121
|
+
is_tty = hasattr(self._stream, "isatty") and self._stream.isatty()
|
|
122
|
+
self.colored = is_tty and ("NO_COLOR" not in os.environ)
|
|
123
|
+
|
|
124
|
+
def _c(self, text: str, code: str) -> str:
|
|
125
|
+
if not self.colored:
|
|
126
|
+
return text
|
|
127
|
+
return f"{code}{text}{self.COLOR_RESET}"
|
|
128
|
+
|
|
129
|
+
def export(self, spans: Sequence[Span]) -> None:
|
|
130
|
+
with self._lock:
|
|
131
|
+
for span in spans:
|
|
132
|
+
status_str = span.status.name
|
|
133
|
+
if status_str == "OK":
|
|
134
|
+
status_badge = self._c("[OK]", self.COLOR_GREEN)
|
|
135
|
+
elif status_str == "ERROR":
|
|
136
|
+
status_badge = self._c("[ERROR]", self.COLOR_RED)
|
|
137
|
+
else:
|
|
138
|
+
status_badge = self._c("[UNSET]", self.COLOR_GRAY)
|
|
139
|
+
|
|
140
|
+
duration_str = self._c(f"{span.duration_ms:.2f}ms", self.COLOR_YELLOW)
|
|
141
|
+
trace_info = self._c(
|
|
142
|
+
f"trace={span.context.trace_id[:8]}.. span={span.context.span_id}",
|
|
143
|
+
self.COLOR_CYAN,
|
|
144
|
+
)
|
|
145
|
+
parent_info = (
|
|
146
|
+
f" parent={span.parent_span_id}" if span.parent_span_id else ""
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
line = f"⚡ {status_badge} {span.name} ({duration_str}) [{trace_info}{parent_info}]"
|
|
150
|
+
self._stream.write(line + "\n")
|
|
151
|
+
|
|
152
|
+
if span.attributes:
|
|
153
|
+
attrs_formatted = ", ".join(
|
|
154
|
+
f"{k}={v!r}" for k, v in span.attributes.items()
|
|
155
|
+
)
|
|
156
|
+
self._stream.write(f" ↳ attributes: {attrs_formatted}\n")
|
|
157
|
+
|
|
158
|
+
if span.breadcrumbs:
|
|
159
|
+
self._stream.write(f" ↳ breadcrumbs ({len(span.breadcrumbs)}):\n")
|
|
160
|
+
for b in span.breadcrumbs[-5:]:
|
|
161
|
+
b_line = f" - [{b.level}] {b.category}: {b.message}"
|
|
162
|
+
self._stream.write(f"{b_line}\n")
|
|
163
|
+
|
|
164
|
+
if span.status.name == "ERROR" and span.status_description:
|
|
165
|
+
self._stream.write(
|
|
166
|
+
self._c(f" ↳ error: {span.status_description}\n", self.COLOR_RED)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
self._stream.flush()
|
|
170
|
+
|
|
171
|
+
def __repr__(self) -> str:
|
|
172
|
+
return f"ConsoleExporter(colored={self.colored})"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CallbackExporter(SpanExporter):
|
|
176
|
+
"""Invokes a callable for each exported span."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, callback: Callable[[Span], None]) -> None:
|
|
179
|
+
self.callback = callback
|
|
180
|
+
self._lock = threading.Lock()
|
|
181
|
+
|
|
182
|
+
def export(self, spans: Sequence[Span]) -> None:
|
|
183
|
+
with self._lock:
|
|
184
|
+
for span in spans:
|
|
185
|
+
self.callback(span)
|
|
186
|
+
|
|
187
|
+
def __repr__(self) -> str:
|
|
188
|
+
return f"CallbackExporter({self.callback!r})"
|
spanwire/py.typed
ADDED
|
File without changes
|