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.
@@ -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
@@ -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