proofstep 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.
- proofstep/__init__.py +192 -0
- proofstep/client.py +209 -0
- proofstep/config.py +152 -0
- proofstep/context.py +74 -0
- proofstep/decorators.py +170 -0
- proofstep/exporter.py +276 -0
- proofstep/propagation.py +49 -0
- proofstep/py.typed +0 -0
- proofstep/recorder.py +366 -0
- proofstep/redaction.py +22 -0
- proofstep/safety.py +119 -0
- proofstep-0.1.0.dist-info/METADATA +58 -0
- proofstep-0.1.0.dist-info/RECORD +14 -0
- proofstep-0.1.0.dist-info/WHEEL +4 -0
proofstep/decorators.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Decorators — the lowest-friction way to instrument existing code.
|
|
2
|
+
|
|
3
|
+
Each inspects the wrapped function and returns a matching sync or async wrapper, so
|
|
4
|
+
the same decorator works on both without the user thinking about it.
|
|
5
|
+
|
|
6
|
+
`tool` is deliberately separate from `span`. It sets `span_type=tool`, records the
|
|
7
|
+
tool name, and captures the call arguments — which is precisely what the trajectory
|
|
8
|
+
engine consumes. Making it an obvious, distinct decorator is what turns policies from
|
|
9
|
+
a configuration exercise into a one-line change.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import inspect
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
|
18
|
+
|
|
19
|
+
from proofstep.safety import NOOP
|
|
20
|
+
from proofstep_types import SpanType
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from proofstep.client import Client
|
|
24
|
+
|
|
25
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Decorator(Protocol):
|
|
29
|
+
"""A decorator factory that preserves the wrapped function's signature.
|
|
30
|
+
|
|
31
|
+
Positional-or-keyword `name` plus arbitrary keywords: `span` accepts several
|
|
32
|
+
extra keyword-only options that callers may pass through.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __call__(self, name: str | None = ..., /, **kwargs: Any) -> Callable[[F], F]: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SpanDecorator(Protocol):
|
|
39
|
+
"""`span` exposes its options explicitly rather than behind `**kwargs`.
|
|
40
|
+
|
|
41
|
+
A typo in `capture_args` should be a type error, not a silently ignored keyword.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __call__(
|
|
45
|
+
self,
|
|
46
|
+
name: str | None = ...,
|
|
47
|
+
/,
|
|
48
|
+
*,
|
|
49
|
+
span_type: SpanType | str = ...,
|
|
50
|
+
tool_name: str | None = ...,
|
|
51
|
+
capture_args: bool = ...,
|
|
52
|
+
capture_result: bool = ...,
|
|
53
|
+
) -> Callable[[F], F]: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Arguments that are never worth capturing: they are either huge or self-referential.
|
|
57
|
+
_SKIP_ARGS = frozenset({"self", "cls"})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bind_args(
|
|
61
|
+
fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Best-effort mapping of a call to a plain dict for `tool_args`."""
|
|
64
|
+
try:
|
|
65
|
+
bound = inspect.signature(fn).bind_partial(*args, **kwargs)
|
|
66
|
+
bound.apply_defaults()
|
|
67
|
+
return {k: v for k, v in bound.arguments.items() if k not in _SKIP_ARGS}
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return {"args": list(args), "kwargs": kwargs}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def make_trace(client_of: Callable[[], Client]) -> Decorator:
|
|
73
|
+
def trace(name: str | None = None, /, **trace_kwargs: Any) -> Callable[[F], F]:
|
|
74
|
+
def decorate(fn: F) -> F:
|
|
75
|
+
label = name or fn.__name__
|
|
76
|
+
|
|
77
|
+
if inspect.iscoroutinefunction(fn):
|
|
78
|
+
|
|
79
|
+
@functools.wraps(fn)
|
|
80
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
with client_of().trace(label, **trace_kwargs) as recorder:
|
|
82
|
+
result = await fn(*args, **kwargs)
|
|
83
|
+
if recorder is not NOOP:
|
|
84
|
+
recorder.set_metadata(**trace_kwargs.get("metadata", {}))
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
return async_wrapper # type: ignore[return-value]
|
|
88
|
+
|
|
89
|
+
@functools.wraps(fn)
|
|
90
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
91
|
+
with client_of().trace(label, **trace_kwargs):
|
|
92
|
+
return fn(*args, **kwargs)
|
|
93
|
+
|
|
94
|
+
return sync_wrapper # type: ignore[return-value]
|
|
95
|
+
|
|
96
|
+
return decorate
|
|
97
|
+
|
|
98
|
+
return trace
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def make_span(client_of: Callable[[], Client]) -> SpanDecorator:
|
|
102
|
+
def span(
|
|
103
|
+
name: str | None = None,
|
|
104
|
+
/,
|
|
105
|
+
*,
|
|
106
|
+
span_type: SpanType | str = SpanType.CUSTOM,
|
|
107
|
+
tool_name: str | None = None,
|
|
108
|
+
capture_args: bool = False,
|
|
109
|
+
capture_result: bool = True,
|
|
110
|
+
) -> Callable[[F], F]:
|
|
111
|
+
def decorate(fn: F) -> F:
|
|
112
|
+
label = name or fn.__name__
|
|
113
|
+
|
|
114
|
+
def before(recorder: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
115
|
+
if recorder is NOOP:
|
|
116
|
+
return
|
|
117
|
+
bound = _bind_args(fn, args, kwargs)
|
|
118
|
+
if capture_args:
|
|
119
|
+
recorder.set_args(bound)
|
|
120
|
+
recorder.set_input(bound)
|
|
121
|
+
|
|
122
|
+
def after(recorder: Any, result: Any) -> None:
|
|
123
|
+
if recorder is not NOOP and capture_result:
|
|
124
|
+
recorder.set_output(result)
|
|
125
|
+
|
|
126
|
+
if inspect.iscoroutinefunction(fn):
|
|
127
|
+
|
|
128
|
+
@functools.wraps(fn)
|
|
129
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
130
|
+
with client_of().span(label, span_type=span_type, tool_name=tool_name) as rec:
|
|
131
|
+
before(rec, args, kwargs)
|
|
132
|
+
result = await fn(*args, **kwargs)
|
|
133
|
+
after(rec, result)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
return async_wrapper # type: ignore[return-value]
|
|
137
|
+
|
|
138
|
+
@functools.wraps(fn)
|
|
139
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
140
|
+
with client_of().span(label, span_type=span_type, tool_name=tool_name) as rec:
|
|
141
|
+
before(rec, args, kwargs)
|
|
142
|
+
result = fn(*args, **kwargs)
|
|
143
|
+
after(rec, result)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
return sync_wrapper # type: ignore[return-value]
|
|
147
|
+
|
|
148
|
+
return decorate
|
|
149
|
+
|
|
150
|
+
return span
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def make_tool(span_factory: SpanDecorator) -> Decorator:
|
|
154
|
+
def tool(name: str | None = None, /, **kwargs: Any) -> Callable[[F], F]:
|
|
155
|
+
"""Instrument a tool call. Captures arguments, which policies read."""
|
|
156
|
+
|
|
157
|
+
def decorate(fn: F) -> F:
|
|
158
|
+
label = name or fn.__name__
|
|
159
|
+
decorated: F = span_factory(
|
|
160
|
+
label,
|
|
161
|
+
span_type=SpanType.TOOL,
|
|
162
|
+
tool_name=label,
|
|
163
|
+
capture_args=True,
|
|
164
|
+
**kwargs,
|
|
165
|
+
)(fn)
|
|
166
|
+
return decorated
|
|
167
|
+
|
|
168
|
+
return decorate
|
|
169
|
+
|
|
170
|
+
return tool
|
proofstep/exporter.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Batching exporter with a bounded buffer.
|
|
2
|
+
|
|
3
|
+
The contract with the host application, in priority order:
|
|
4
|
+
|
|
5
|
+
1. **Never block.** Submitting a trace is a non-blocking enqueue. If the buffer is
|
|
6
|
+
full we drop and count; a full buffer must never become backpressure on the
|
|
7
|
+
caller's request path.
|
|
8
|
+
2. **Never raise.** Every failure is swallowed and logged once per window.
|
|
9
|
+
3. **Lose visibly.** Drops increment a counter that is reported on the trace itself.
|
|
10
|
+
Silent loss is worse than visible loss, because it looks like the workflow simply
|
|
11
|
+
did not do the thing.
|
|
12
|
+
|
|
13
|
+
A background thread rather than an asyncio task: the SDK must work identically in a
|
|
14
|
+
sync Django view, a Celery worker, and an async FastAPI handler, and a thread is the
|
|
15
|
+
only one of those that needs no event loop.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import gzip
|
|
21
|
+
import json
|
|
22
|
+
import queue
|
|
23
|
+
import random
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
from proofstep.safety import log_once, never_raises
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from proofstep.config import Config
|
|
33
|
+
from proofstep_types import Trace
|
|
34
|
+
|
|
35
|
+
USER_AGENT = "proofstep-python"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExportStats:
|
|
39
|
+
"""Observable counters. A telemetry client that cannot report its own health is
|
|
40
|
+
the one thing worse than no telemetry."""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("dropped_traces", "exported_spans", "exported_traces", "failures", "spooled")
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self.exported_traces = 0
|
|
46
|
+
self.exported_spans = 0
|
|
47
|
+
self.dropped_traces = 0
|
|
48
|
+
self.failures = 0
|
|
49
|
+
self.spooled = 0
|
|
50
|
+
|
|
51
|
+
def as_dict(self) -> dict[str, int]:
|
|
52
|
+
return {slot: getattr(self, slot) for slot in self.__slots__}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Exporter:
|
|
56
|
+
def __init__(self, config: Config, *, transport: Any = None) -> None:
|
|
57
|
+
self.config = config
|
|
58
|
+
self.stats = ExportStats()
|
|
59
|
+
self._queue: queue.Queue[Trace | None] = queue.Queue(maxsize=_capacity(config))
|
|
60
|
+
self._transport = transport
|
|
61
|
+
self._thread: threading.Thread | None = None
|
|
62
|
+
self._stopping = threading.Event()
|
|
63
|
+
self._idle = threading.Event()
|
|
64
|
+
self._idle.set()
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------ lifecycle
|
|
68
|
+
|
|
69
|
+
def _ensure_started(self) -> None:
|
|
70
|
+
if self._thread is not None and self._thread.is_alive():
|
|
71
|
+
return
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._thread is not None and self._thread.is_alive():
|
|
74
|
+
return
|
|
75
|
+
self._stopping.clear()
|
|
76
|
+
# Daemon: a forgotten flush() must never stop the process exiting.
|
|
77
|
+
# `atexit` handles the orderly case.
|
|
78
|
+
self._thread = threading.Thread(
|
|
79
|
+
target=self._run, name="proofstep-exporter", daemon=True
|
|
80
|
+
)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
|
|
83
|
+
@never_raises()
|
|
84
|
+
def submit(self, trace: Trace) -> None:
|
|
85
|
+
"""Non-blocking enqueue. Drops the oldest item when full."""
|
|
86
|
+
if not self.config.sends:
|
|
87
|
+
return
|
|
88
|
+
self._ensure_started()
|
|
89
|
+
self._idle.clear()
|
|
90
|
+
try:
|
|
91
|
+
self._queue.put_nowait(trace)
|
|
92
|
+
except queue.Full:
|
|
93
|
+
# Drop the oldest: the newest trace is the one someone is probably
|
|
94
|
+
# watching for, and an unbounded queue is how a telemetry client turns
|
|
95
|
+
# a provider outage into an OOM.
|
|
96
|
+
try:
|
|
97
|
+
self._queue.get_nowait()
|
|
98
|
+
self.stats.dropped_traces += 1
|
|
99
|
+
self._queue.put_nowait(trace)
|
|
100
|
+
except (queue.Empty, queue.Full):
|
|
101
|
+
self.stats.dropped_traces += 1
|
|
102
|
+
log_once(
|
|
103
|
+
"exporter.full",
|
|
104
|
+
f"export buffer is full ({self._queue.maxsize} traces); dropping oldest. "
|
|
105
|
+
f"{self.stats.dropped_traces} dropped so far.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@never_raises(default=False)
|
|
109
|
+
def flush(self, timeout: float | None = None) -> bool:
|
|
110
|
+
"""Wait for the queue to drain. Returns False on timeout."""
|
|
111
|
+
if self._thread is None or not self._thread.is_alive():
|
|
112
|
+
return True
|
|
113
|
+
deadline = time.monotonic() + (
|
|
114
|
+
timeout if timeout is not None else self.config.shutdown_timeout_s
|
|
115
|
+
)
|
|
116
|
+
while time.monotonic() < deadline:
|
|
117
|
+
if self._queue.empty() and self._idle.is_set():
|
|
118
|
+
return True
|
|
119
|
+
time.sleep(0.01)
|
|
120
|
+
log_once("exporter.flush_timeout", "flush timed out; some traces were not sent")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
@never_raises()
|
|
124
|
+
def shutdown(self, timeout: float | None = None) -> None:
|
|
125
|
+
self.flush(timeout)
|
|
126
|
+
self._stopping.set()
|
|
127
|
+
with self._lock:
|
|
128
|
+
thread, self._thread = self._thread, None
|
|
129
|
+
if thread is not None and thread.is_alive():
|
|
130
|
+
self._queue.put_nowait(None) # wake the worker
|
|
131
|
+
thread.join(timeout=1.0)
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------------------- worker
|
|
134
|
+
|
|
135
|
+
def _run(self) -> None:
|
|
136
|
+
batch: list[Trace] = []
|
|
137
|
+
last_flush = time.monotonic()
|
|
138
|
+
|
|
139
|
+
while not self._stopping.is_set():
|
|
140
|
+
timeout = max(0.01, self.config.flush_interval_s - (time.monotonic() - last_flush))
|
|
141
|
+
try:
|
|
142
|
+
item = self._queue.get(timeout=timeout)
|
|
143
|
+
except queue.Empty:
|
|
144
|
+
item = None
|
|
145
|
+
else:
|
|
146
|
+
if item is None:
|
|
147
|
+
break
|
|
148
|
+
batch.append(item)
|
|
149
|
+
|
|
150
|
+
due = (time.monotonic() - last_flush) >= self.config.flush_interval_s
|
|
151
|
+
spans = sum(len(t.spans) for t in batch)
|
|
152
|
+
if batch and (due or spans >= self.config.batch_size):
|
|
153
|
+
self._send_batch(batch)
|
|
154
|
+
batch = []
|
|
155
|
+
last_flush = time.monotonic()
|
|
156
|
+
|
|
157
|
+
if not batch and self._queue.empty():
|
|
158
|
+
self._idle.set()
|
|
159
|
+
|
|
160
|
+
if batch:
|
|
161
|
+
self._send_batch(batch)
|
|
162
|
+
self._idle.set()
|
|
163
|
+
|
|
164
|
+
def _send_batch(self, batch: list[Trace]) -> None:
|
|
165
|
+
payload = _encode(batch, self.config)
|
|
166
|
+
if payload is None:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
body = gzip.compress(payload)
|
|
170
|
+
for attempt in range(self.config.max_retries):
|
|
171
|
+
if self._stopping.is_set() and attempt > 0:
|
|
172
|
+
break
|
|
173
|
+
try:
|
|
174
|
+
self._post(body)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
self.stats.failures += 1
|
|
177
|
+
if attempt == self.config.max_retries - 1:
|
|
178
|
+
log_once(
|
|
179
|
+
"exporter.send_failed",
|
|
180
|
+
f"could not reach {self.config.endpoint} after "
|
|
181
|
+
f"{self.config.max_retries} attempts ({type(exc).__name__}: {exc}); "
|
|
182
|
+
"traces are being spooled or dropped",
|
|
183
|
+
)
|
|
184
|
+
self._spool(body)
|
|
185
|
+
return
|
|
186
|
+
# Exponential backoff with full jitter: synchronized retries from
|
|
187
|
+
# many processes are how a struggling server stays down.
|
|
188
|
+
delay = min(30.0, (2**attempt) * 0.5)
|
|
189
|
+
time.sleep(random.uniform(0, delay)) # noqa: S311 — jitter, not crypto
|
|
190
|
+
else:
|
|
191
|
+
self.stats.exported_traces += len(batch)
|
|
192
|
+
self.stats.exported_spans += sum(len(t.spans) for t in batch)
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
def _post(self, body: bytes) -> None:
|
|
196
|
+
if self._transport is not None:
|
|
197
|
+
self._transport(body)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
import httpx # noqa: PLC0415 — lazy so importing the SDK costs nothing
|
|
201
|
+
|
|
202
|
+
response = httpx.post(
|
|
203
|
+
f"{self.config.endpoint}/v1/ingest/traces",
|
|
204
|
+
content=body,
|
|
205
|
+
headers={
|
|
206
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
"Content-Encoding": "gzip",
|
|
209
|
+
"User-Agent": USER_AGENT,
|
|
210
|
+
},
|
|
211
|
+
timeout=self.config.export_timeout_s,
|
|
212
|
+
)
|
|
213
|
+
if response.status_code >= 500 or response.status_code == 429:
|
|
214
|
+
msg = f"server returned {response.status_code}"
|
|
215
|
+
raise RuntimeError(msg)
|
|
216
|
+
if response.status_code >= 400:
|
|
217
|
+
# 4xx is our bug or a bad key. Retrying cannot fix it, and retrying an
|
|
218
|
+
# auth failure five times per batch is how you get rate-limited too.
|
|
219
|
+
log_once(
|
|
220
|
+
"exporter.rejected",
|
|
221
|
+
f"ingest rejected the batch with {response.status_code}: {response.text[:200]}",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def _spool(self, body: bytes) -> None:
|
|
225
|
+
if self.config.spool_dir is None:
|
|
226
|
+
return
|
|
227
|
+
try:
|
|
228
|
+
directory = Path(self.config.spool_dir)
|
|
229
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
name = f"{int(time.time() * 1000)}-{random.randbytes(4).hex()}.json.gz" # noqa: S311
|
|
231
|
+
(directory / name).write_bytes(body)
|
|
232
|
+
self.stats.spooled += 1
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
log_once("exporter.spool_failed", f"could not spool batch: {exc}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _capacity(config: Config) -> int:
|
|
238
|
+
"""Queue length in traces, derived from the span budget."""
|
|
239
|
+
return max(16, config.max_buffered_spans // 20)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _encode(batch: list[Trace], config: Config) -> bytes | None:
|
|
243
|
+
traces: list[dict[str, Any]] = []
|
|
244
|
+
spans: list[dict[str, Any]] = []
|
|
245
|
+
for trace in batch:
|
|
246
|
+
dumped = trace.model_dump(mode="json", exclude={"spans"})
|
|
247
|
+
traces.append(dumped)
|
|
248
|
+
for span in trace.spans:
|
|
249
|
+
spans.append(span.model_dump(mode="json"))
|
|
250
|
+
|
|
251
|
+
payload = {
|
|
252
|
+
"resource": {
|
|
253
|
+
"service.name": config.service_name or config.project or "unknown",
|
|
254
|
+
"environment": config.environment,
|
|
255
|
+
"git.commit": config.git_commit,
|
|
256
|
+
"sdk.name": USER_AGENT,
|
|
257
|
+
},
|
|
258
|
+
"traces": traces,
|
|
259
|
+
"spans": spans,
|
|
260
|
+
"dropped_span_count": sum(t.dropped_span_count for t in batch),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
encoded = json.dumps(payload, default=str).encode("utf-8")
|
|
265
|
+
except (TypeError, ValueError) as exc:
|
|
266
|
+
log_once("exporter.encode_failed", f"could not serialize batch: {exc}")
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
if len(encoded) > config.max_batch_bytes:
|
|
270
|
+
log_once(
|
|
271
|
+
"exporter.batch_too_large",
|
|
272
|
+
f"batch of {len(encoded)} bytes exceeds max_batch_bytes "
|
|
273
|
+
f"({config.max_batch_bytes}); dropping it",
|
|
274
|
+
)
|
|
275
|
+
return None
|
|
276
|
+
return encoded
|
proofstep/propagation.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""W3C trace-context propagation across service boundaries.
|
|
2
|
+
|
|
3
|
+
Uses the standard `traceparent` header rather than a bespoke one, so a request that
|
|
4
|
+
crosses into a service instrumented with plain OpenTelemetry still stitches together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from proofstep.context import current_span, current_trace
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping, MutableMapping
|
|
16
|
+
|
|
17
|
+
TRACEPARENT = "traceparent"
|
|
18
|
+
_FORMAT = re.compile(r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def inject(headers: MutableMapping[str, str] | None = None) -> dict[str, str]:
|
|
22
|
+
"""Add `traceparent` for the current span, if any."""
|
|
23
|
+
out: dict[str, str] = dict(headers or {})
|
|
24
|
+
trace = current_trace()
|
|
25
|
+
span = current_span()
|
|
26
|
+
if trace is None or span is None:
|
|
27
|
+
return out
|
|
28
|
+
flags = "01" if trace.sampled else "00"
|
|
29
|
+
out[TRACEPARENT] = f"00-{trace.trace_id}-{span.span_id}-{flags}"
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract(headers: Mapping[str, str]) -> tuple[str, str, bool] | None:
|
|
34
|
+
"""Parse `traceparent` into (trace_id, parent_span_id, sampled).
|
|
35
|
+
|
|
36
|
+
Returns None on anything malformed. A caller-supplied header is untrusted input
|
|
37
|
+
and must never be able to corrupt our ids.
|
|
38
|
+
"""
|
|
39
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
40
|
+
raw = lowered.get(TRACEPARENT)
|
|
41
|
+
if not raw:
|
|
42
|
+
return None
|
|
43
|
+
match = _FORMAT.match(raw.strip())
|
|
44
|
+
if not match:
|
|
45
|
+
return None
|
|
46
|
+
trace_id, span_id, flags = match.groups()
|
|
47
|
+
if trace_id == "0" * 32 or span_id == "0" * 16:
|
|
48
|
+
return None
|
|
49
|
+
return trace_id, span_id, bool(int(flags, 16) & 0x01)
|
proofstep/py.typed
ADDED
|
File without changes
|