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