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/sampler.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Trace sampling algorithms for spanwire."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, Optional
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from spanwire.core import SpanContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Sampler(ABC):
|
|
13
|
+
"""Abstract base class for all spanwire samplers."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def should_sample(
|
|
17
|
+
self,
|
|
18
|
+
parent_context: Optional[SpanContext],
|
|
19
|
+
trace_id: str,
|
|
20
|
+
name: str,
|
|
21
|
+
) -> bool:
|
|
22
|
+
"""Determine if a trace should be sampled."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AlwaysOnSampler(Sampler):
|
|
27
|
+
"""Samples every trace."""
|
|
28
|
+
|
|
29
|
+
def should_sample(
|
|
30
|
+
self,
|
|
31
|
+
parent_context: Optional[SpanContext],
|
|
32
|
+
trace_id: str,
|
|
33
|
+
name: str,
|
|
34
|
+
) -> bool:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return "AlwaysOnSampler()"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AlwaysOffSampler(Sampler):
|
|
42
|
+
"""Drops every trace."""
|
|
43
|
+
|
|
44
|
+
def should_sample(
|
|
45
|
+
self,
|
|
46
|
+
parent_context: Optional[SpanContext],
|
|
47
|
+
trace_id: str,
|
|
48
|
+
name: str,
|
|
49
|
+
) -> bool:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return "AlwaysOffSampler()"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RatioSampler(Sampler):
|
|
57
|
+
"""Deterministic ratio-based trace sampler using trace_id hashing.
|
|
58
|
+
|
|
59
|
+
Guarantees consistent sampling decisions for the same trace across services.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, ratio: float) -> None:
|
|
63
|
+
if not (0.0 <= ratio <= 1.0):
|
|
64
|
+
raise ValueError(f"Ratio must be between 0.0 and 1.0, got {ratio}")
|
|
65
|
+
self.ratio = float(ratio)
|
|
66
|
+
# 32-bit unsigned threshold
|
|
67
|
+
self._threshold = int(self.ratio * 0xFFFFFFFF)
|
|
68
|
+
|
|
69
|
+
def should_sample(
|
|
70
|
+
self,
|
|
71
|
+
parent_context: Optional[SpanContext],
|
|
72
|
+
trace_id: str,
|
|
73
|
+
name: str,
|
|
74
|
+
) -> bool:
|
|
75
|
+
if self.ratio <= 0.0:
|
|
76
|
+
return False
|
|
77
|
+
if self.ratio >= 1.0:
|
|
78
|
+
return True
|
|
79
|
+
# Extract lower 8 hex characters (32 bits) of the trace_id
|
|
80
|
+
tail = trace_id[-8:] if len(trace_id) >= 8 else trace_id
|
|
81
|
+
try:
|
|
82
|
+
val = int(tail, 16)
|
|
83
|
+
return val <= self._threshold
|
|
84
|
+
except ValueError:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"RatioSampler(ratio={self.ratio})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ParentBasedSampler(Sampler):
|
|
92
|
+
"""Honors parent context sampling decision if present; otherwise delegates to root sampler."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, root: Optional[Sampler] = None) -> None:
|
|
95
|
+
self.root: Sampler = root if root is not None else AlwaysOnSampler()
|
|
96
|
+
|
|
97
|
+
def should_sample(
|
|
98
|
+
self,
|
|
99
|
+
parent_context: Optional[SpanContext],
|
|
100
|
+
trace_id: str,
|
|
101
|
+
name: str,
|
|
102
|
+
) -> bool:
|
|
103
|
+
if parent_context is not None:
|
|
104
|
+
return parent_context.is_sampled
|
|
105
|
+
return self.root.should_sample(parent_context, trace_id, name)
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
return f"ParentBasedSampler(root={self.root!r})"
|
spanwire/w3c.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""W3C Trace Context and Baggage standard implementation.
|
|
2
|
+
|
|
3
|
+
Compliant with:
|
|
4
|
+
- W3C Trace Context Recommendation (https://www.w3.org/TR/trace-context/)
|
|
5
|
+
- W3C Baggage Recommendation (https://www.w3.org/TR/baggage/)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from typing import Dict, Mapping, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
TRACEPARENT_REGEX = re.compile(
|
|
15
|
+
r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(?:-.*)?$"
|
|
16
|
+
)
|
|
17
|
+
INVALID_TRACE_ID = "0" * 32
|
|
18
|
+
INVALID_SPAN_ID = "0" * 16
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_valid_trace_id(trace_id: str) -> bool:
|
|
22
|
+
"""Check if trace_id is a valid 32-character hex string and not all zeros."""
|
|
23
|
+
if not isinstance(trace_id, str) or len(trace_id) != 32:
|
|
24
|
+
return False
|
|
25
|
+
if trace_id == INVALID_TRACE_ID:
|
|
26
|
+
return False
|
|
27
|
+
return bool(re.fullmatch(r"[0-9a-f]{32}", trace_id))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_valid_span_id(span_id: str) -> bool:
|
|
31
|
+
"""Check if span_id is a valid 16-character hex string and not all zeros."""
|
|
32
|
+
if not isinstance(span_id, str) or len(span_id) != 16:
|
|
33
|
+
return False
|
|
34
|
+
if span_id == INVALID_SPAN_ID:
|
|
35
|
+
return False
|
|
36
|
+
return bool(re.fullmatch(r"[0-9a-f]{16}", span_id))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_traceparent(
|
|
40
|
+
trace_id: str,
|
|
41
|
+
span_id: str,
|
|
42
|
+
trace_flags: int = 1,
|
|
43
|
+
version: str = "00",
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Format trace context into a W3C traceparent header string."""
|
|
46
|
+
flags_hex = f"{trace_flags & 0xFF:02x}"
|
|
47
|
+
return f"{version}-{trace_id.lower()}-{span_id.lower()}-{flags_hex}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_traceparent(header_value: str) -> Optional[Tuple[str, str, int]]:
|
|
51
|
+
"""Parse a W3C traceparent header.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
(trace_id, parent_span_id, trace_flags) or None if malformed/invalid.
|
|
55
|
+
"""
|
|
56
|
+
if not header_value or not isinstance(header_value, str):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
clean_value = header_value.strip().lower()
|
|
60
|
+
match = TRACEPARENT_REGEX.match(clean_value)
|
|
61
|
+
if not match:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
version, trace_id, parent_id, flags_hex = match.groups()
|
|
65
|
+
|
|
66
|
+
# Version 'ff' is explicitly invalid according to W3C spec
|
|
67
|
+
if version == "ff":
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
# Version 00 cannot have trailing fields
|
|
71
|
+
if version == "00" and len(clean_value) != 55:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# All zeros are forbidden for trace_id and parent_id
|
|
75
|
+
if trace_id == INVALID_TRACE_ID or parent_id == INVALID_SPAN_ID:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
flags = int(flags_hex, 16)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
return trace_id, parent_id, flags
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_baggage(baggage: Mapping[str, str]) -> str:
|
|
87
|
+
"""Format key-value pairs into a W3C baggage header."""
|
|
88
|
+
if not baggage:
|
|
89
|
+
return ""
|
|
90
|
+
entries = []
|
|
91
|
+
for k, v in baggage.items():
|
|
92
|
+
k_enc = urllib.parse.quote(str(k).strip(), safe="")
|
|
93
|
+
v_enc = urllib.parse.quote(str(v).strip(), safe="")
|
|
94
|
+
if k_enc:
|
|
95
|
+
entries.append(f"{k_enc}={v_enc}")
|
|
96
|
+
return ", ".join(entries)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_baggage(header_value: str) -> Dict[str, str]:
|
|
100
|
+
"""Parse a W3C baggage header into a dictionary of key-value pairs."""
|
|
101
|
+
baggage: Dict[str, str] = {}
|
|
102
|
+
if not header_value or not isinstance(header_value, str):
|
|
103
|
+
return baggage
|
|
104
|
+
|
|
105
|
+
for member in header_value.split(","):
|
|
106
|
+
member = member.strip()
|
|
107
|
+
if not member:
|
|
108
|
+
continue
|
|
109
|
+
# Split on semicolon first to ignore properties (e.g. key=val;prop=x)
|
|
110
|
+
parts = member.split(";", 1)
|
|
111
|
+
key_val = parts[0].strip()
|
|
112
|
+
if "=" in key_val:
|
|
113
|
+
k, v = key_val.split("=", 1)
|
|
114
|
+
k = urllib.parse.unquote(k.strip())
|
|
115
|
+
v = urllib.parse.unquote(v.strip())
|
|
116
|
+
if k:
|
|
117
|
+
baggage[k] = v
|
|
118
|
+
return baggage
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_w3c_headers(
|
|
122
|
+
headers: Mapping[str, str],
|
|
123
|
+
) -> Tuple[Optional[Tuple[str, str, int]], Dict[str, str], str]:
|
|
124
|
+
"""Extract traceparent, baggage, and tracestate from HTTP headers.
|
|
125
|
+
|
|
126
|
+
Headers lookup is case-insensitive.
|
|
127
|
+
Returns:
|
|
128
|
+
(traceparent_tuple_or_none, baggage_dict, tracestate_str)
|
|
129
|
+
"""
|
|
130
|
+
normalized = {k.lower(): v for k, v in headers.items()}
|
|
131
|
+
|
|
132
|
+
traceparent_val = normalized.get("traceparent")
|
|
133
|
+
parsed_tp = parse_traceparent(traceparent_val) if traceparent_val else None
|
|
134
|
+
|
|
135
|
+
baggage_val = normalized.get("baggage", "")
|
|
136
|
+
parsed_baggage = parse_baggage(baggage_val)
|
|
137
|
+
|
|
138
|
+
tracestate = normalized.get("tracestate", "").strip()
|
|
139
|
+
|
|
140
|
+
return parsed_tp, parsed_baggage, tracestate
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def inject_w3c_headers(
|
|
144
|
+
trace_id: str,
|
|
145
|
+
span_id: str,
|
|
146
|
+
trace_flags: int = 1,
|
|
147
|
+
baggage: Optional[Mapping[str, str]] = None,
|
|
148
|
+
tracestate: str = "",
|
|
149
|
+
target_headers: Optional[Dict[str, str]] = None,
|
|
150
|
+
) -> Dict[str, str]:
|
|
151
|
+
"""Inject W3C traceparent, baggage, and tracestate into a header dictionary."""
|
|
152
|
+
out = target_headers if target_headers is not None else {}
|
|
153
|
+
out["traceparent"] = format_traceparent(trace_id, span_id, trace_flags)
|
|
154
|
+
if baggage:
|
|
155
|
+
formatted_baggage = format_baggage(baggage)
|
|
156
|
+
if formatted_baggage:
|
|
157
|
+
out["baggage"] = formatted_baggage
|
|
158
|
+
if tracestate:
|
|
159
|
+
out["tracestate"] = tracestate
|
|
160
|
+
return out
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spanwire
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency micro-tracing, distributed context propagation & breadcrumb telemetry engine for Python.
|
|
5
|
+
Author-email: Hamidreza <h4midrezam@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mrhamidrezam/spanwire
|
|
8
|
+
Project-URL: Repository, https://github.com/mrhamidrezam/spanwire
|
|
9
|
+
Project-URL: Issues, https://github.com/mrhamidrezam/spanwire/issues
|
|
10
|
+
Keywords: tracing,distributed-tracing,opentelemetry,w3c,traceparent,baggage,breadcrumbs,telemetry,contextvars,zero-dependency,async
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Topic :: System :: Logging
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
# ⚡ spanwire
|
|
31
|
+
|
|
32
|
+
**Zero-dependency micro-tracing, distributed context propagation & breadcrumb telemetry engine for Python.**
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/spanwire/)
|
|
35
|
+
[](https://pypi.org/project/spanwire/)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
[](https://github.com/mrhamidrezam/spanwire/actions/workflows/ci.yml)
|
|
38
|
+
[](https://peps.python.org/pep-0561/)
|
|
39
|
+
[](#)
|
|
40
|
+
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 💡 Why spanwire?
|
|
46
|
+
|
|
47
|
+
Standard distributed tracing solutions (like OpenTelemetry SDK) pull in dozens of heavy transitive dependencies, incur noticeable startup delays, and add architectural bloat to lightweight microservices, CLI utilities, serverless functions (AWS Lambda, Cloud Run), and async LLM pipelines.
|
|
48
|
+
|
|
49
|
+
**spanwire** delivers enterprise-grade distributed telemetry and error context trails with **zero external dependencies**, utilizing pure Python standard library primitives (`contextvars`, `dataclasses`, `time`, `secrets`).
|
|
50
|
+
|
|
51
|
+
- **100% W3C Trace Context & Baggage Standard**: Interoperable with Datadog, Jaeger, Grafana Tempo, AWS X-Ray, and OpenTelemetry collectors.
|
|
52
|
+
- **Flawless Context Propagation**: Works out of the box across `asyncio` coroutines, background tasks, and OS threads without context leakage.
|
|
53
|
+
- **In-Memory Breadcrumb Trail**: Maintains an efficient circular ring-buffer of recent operational milestones, automatically attached when errors or exceptions occur.
|
|
54
|
+
- **Multiple Exporters**: ANSI-colorized CLI console, NDJSON/JSONLines for log aggregators (Vector, Fluentd, Promtail), or in-memory collectors for unit testing.
|
|
55
|
+
- **Deterministic Sampling**: Ratio-based trace hashing guarantees synchronized sampling decisions across distributed microservice boundaries.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 📐 Architecture & Flow
|
|
60
|
+
|
|
61
|
+
```mermaid
|
|
62
|
+
sequenceDiagram
|
|
63
|
+
autonumber
|
|
64
|
+
participant Client as API Gateway (Service A)
|
|
65
|
+
participant Wire as W3C HTTP Headers
|
|
66
|
+
participant Worker as Downstream Worker (Service B)
|
|
67
|
+
participant Exporter as Telemetry Exporter
|
|
68
|
+
|
|
69
|
+
Client->>Client: Start Span ("checkout.process")
|
|
70
|
+
Client->>Client: Record Breadcrumb ("User tier: VIP")
|
|
71
|
+
Client->>Wire: Inject W3C traceparent & baggage headers
|
|
72
|
+
Wire->>Worker: Forward HTTP Request
|
|
73
|
+
Worker->>Worker: Extract W3C Context (inherits trace_id)
|
|
74
|
+
Worker->>Worker: Execute child span ("db.persist_order")
|
|
75
|
+
Worker-->>Exporter: Export child span ([OK] 12.4ms)
|
|
76
|
+
Client-->>Exporter: Export parent span ([OK] 45.1ms)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🚀 Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install spanwire
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
*Requires Python 3.10 or higher. No third-party dependencies required.*
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## ⚡ Quick Start
|
|
92
|
+
|
|
93
|
+
### 1. Basic Spans and Console Export
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import time
|
|
97
|
+
from spanwire import Tracer, ConsoleExporter
|
|
98
|
+
|
|
99
|
+
tracer = Tracer(
|
|
100
|
+
service_name="payment-service",
|
|
101
|
+
exporter=ConsoleExporter(colored=True)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
tracer.add_breadcrumb("Validating customer cart", category="cart")
|
|
105
|
+
|
|
106
|
+
with tracer.span("process_payment", attributes={"order.id": "ord_1042", "amount": 89.00}) as span:
|
|
107
|
+
time.sleep(0.02)
|
|
108
|
+
|
|
109
|
+
# Nested child span
|
|
110
|
+
with tracer.span("stripe.charge") as child:
|
|
111
|
+
time.sleep(0.03)
|
|
112
|
+
child.set_attribute("charge.id", "ch_abc123")
|
|
113
|
+
tracer.add_breadcrumb("Payment captured via webhook", category="billing")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Console Output:
|
|
117
|
+
```text
|
|
118
|
+
⚡ [OK] stripe.charge (30.12ms) [trace=63d3791c.. span=ff0dba3fa8e11f3c parent=97bc44600e80142d]
|
|
119
|
+
↳ attributes: service.name='payment-service', charge.id='ch_abc123'
|
|
120
|
+
↳ breadcrumbs (2):
|
|
121
|
+
- [INFO] cart: Validating customer cart
|
|
122
|
+
- [INFO] billing: Payment captured via webhook
|
|
123
|
+
⚡ [OK] process_payment (50.84ms) [trace=63d3791c.. span=97bc44600e80142d]
|
|
124
|
+
↳ attributes: service.name='payment-service', order.id='ord_1042', amount=89.0
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### 2. Distributed Tracing over HTTP (W3C Traceparent & Baggage)
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import requests
|
|
133
|
+
from spanwire import Tracer, set_baggage_item
|
|
134
|
+
|
|
135
|
+
tracer = Tracer(service_name="gateway")
|
|
136
|
+
|
|
137
|
+
# Set user context in baggage
|
|
138
|
+
set_baggage_item("user_id", "u_8841")
|
|
139
|
+
|
|
140
|
+
with tracer.span("http_outgoing_call") as span:
|
|
141
|
+
# 1. Inject W3C headers into outbound HTTP request
|
|
142
|
+
headers = {}
|
|
143
|
+
tracer.inject_headers(headers)
|
|
144
|
+
# headers contains:
|
|
145
|
+
# 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
|
|
146
|
+
# 'baggage': 'user_id=u_8841'
|
|
147
|
+
|
|
148
|
+
# requests.get("https://worker.internal/api/tasks", headers=headers)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
On the receiving downstream service:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from spanwire import Tracer
|
|
155
|
+
|
|
156
|
+
worker_tracer = Tracer(service_name="worker")
|
|
157
|
+
|
|
158
|
+
def handle_incoming_request(incoming_headers: dict):
|
|
159
|
+
# Extract parent context & baggage from headers
|
|
160
|
+
parent_ctx, baggage, _ = worker_tracer.extract_headers(incoming_headers)
|
|
161
|
+
|
|
162
|
+
# Continue the distributed trace seamlessly
|
|
163
|
+
with worker_tracer.span("worker.handle_job", parent_context=parent_ctx) as span:
|
|
164
|
+
print(f"Propagated trace_id: {span.context.trace_id}")
|
|
165
|
+
print(f"Parent span_id: {span.parent_span_id}")
|
|
166
|
+
print(f"User ID from baggage: {baggage.get('user_id')}")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### 3. Asynchronous Function Decoration & LLM Agent Pipeline
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
import asyncio
|
|
175
|
+
from spanwire import Tracer, ConsoleExporter
|
|
176
|
+
|
|
177
|
+
tracer = Tracer(service_name="llm-orchestrator", exporter=ConsoleExporter())
|
|
178
|
+
|
|
179
|
+
@tracer.trace(name="llm.generate_plan")
|
|
180
|
+
async def generate_plan(prompt: str) -> list[str]:
|
|
181
|
+
tracer.add_breadcrumb(f"Generating plan for: {prompt[:30]}", category="llm")
|
|
182
|
+
await asyncio.sleep(0.05)
|
|
183
|
+
return ["search_db", "render_markdown"]
|
|
184
|
+
|
|
185
|
+
async def main():
|
|
186
|
+
with tracer.span("orchestrate_query"):
|
|
187
|
+
plan = await generate_plan("Summarize latest server telemetry")
|
|
188
|
+
|
|
189
|
+
asyncio.run(main())
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 🛠️ Exporters
|
|
195
|
+
|
|
196
|
+
| Exporter | Description | Best Suited For |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `ConsoleExporter` | Formatted CLI output with ANSI colors, durations, and breadcrumb trails | Development, debugging, CLI tools |
|
|
199
|
+
| `JsonLinesExporter` | Newline-delimited JSON (NDJSON) output to file or stdout | Vector, Promtail, Datadog Agent, CloudWatch |
|
|
200
|
+
| `InMemoryExporter` | Stores finished spans in a thread-safe list | Pytest unit assertions, custom telemetry queues |
|
|
201
|
+
| `CallbackExporter` | Invokes custom callback function on each completed span | Custom event emission, Webhooks |
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 🎯 Samplers
|
|
206
|
+
|
|
207
|
+
- **`AlwaysOnSampler()`**: Traces 100% of operations (default).
|
|
208
|
+
- **`AlwaysOffSampler()`**: Disables tracing overhead completely.
|
|
209
|
+
- **`RatioSampler(ratio=0.1)`**: Probabilistically samples `10%` of traces using deterministic hashing on `trace_id`.
|
|
210
|
+
- **`ParentBasedSampler(root=...)`**: Respects the upstream service's sampling flag, with fallback to root sampler.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 🧪 Running Unit Tests
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
python -m pytest tests -v
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
All 33 unit tests execute in ~0.15s with zero external test plugins.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 📜 License
|
|
225
|
+
|
|
226
|
+
MIT License © 2026 Hamidreza Moghaddam Kohi. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
spanwire/__init__.py,sha256=t75NacFTB50nG-8oR1SFS_Vquy0AK_PK5qmQQpSXdVU,3395
|
|
2
|
+
spanwire/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
spanwire/breadcrumbs.py,sha256=2ZtSNTmfcFOfHv2BR5GbeMEC8iFjP-qYse9hSAJiEck,2653
|
|
4
|
+
spanwire/context.py,sha256=98CZk0_ObKjOc2mHJXkheKqJZB-IOzIALRYP3vwoBOE,1927
|
|
5
|
+
spanwire/core.py,sha256=uyIkeGQNDHG4-D9maR9THtYGii1LSvB-q_O3aVHQgmI,14085
|
|
6
|
+
spanwire/exporters.py,sha256=HwTOdyxkkV1Nu70h7iHtrOJycrXLTamstsIIjogmOhE,6211
|
|
7
|
+
spanwire/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
spanwire/sampler.py,sha256=Lbujg_7Q6U1hUme0P5a7WTVR-3hPCsYh-WdEHyZfieQ,2871
|
|
9
|
+
spanwire/w3c.py,sha256=ZU8_2XmtAIuzmTDEm-aDMOp8Mi_EzWwfZEKpSD9GhAY,5022
|
|
10
|
+
spanwire-0.1.0.dist-info/licenses/LICENSE,sha256=SzrgpH9v6YiNHAbiye6l8jqMihAM3_tt_nJkrHS2A6Q,1081
|
|
11
|
+
spanwire-0.1.0.dist-info/METADATA,sha256=izqOqQZWOK4Tv6-3xxOTy5BaO5PwnbAHf5Nf9bVFnNM,8479
|
|
12
|
+
spanwire-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
spanwire-0.1.0.dist-info/top_level.txt,sha256=1_NlRrHckhHPZjLE-DZnMe3DEjty4MzNzKJk_xe-U5g,9
|
|
14
|
+
spanwire-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hamidreza Moghaddam Kohi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spanwire
|