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 ADDED
@@ -0,0 +1,192 @@
1
+ """Proofstep Python SDK — tracing for AI applications and tool-using agents.
2
+
3
+ import proofstep
4
+
5
+ proofstep.init(project="my-app")
6
+
7
+ @proofstep.trace("generate_outreach")
8
+ async def generate_outreach(prospect_id: str) -> Email:
9
+ ...
10
+
11
+ @proofstep.tool("gmail.send")
12
+ async def send_email(to: str, subject: str, body: str) -> str:
13
+ ...
14
+
15
+ Two guarantees hold everywhere in this package:
16
+
17
+ - **It never raises into your application.** Every public entry point is wrapped;
18
+ internal failures are logged once per window and return a no-op.
19
+ - **It never blocks your application.** Export is a non-blocking enqueue onto a
20
+ bounded buffer drained by a background thread. When the buffer is full it drops
21
+ the oldest trace and counts it, because visible loss beats an invisible stall.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ from collections.abc import Iterator
28
+ from importlib import metadata as _metadata
29
+ from typing import Any
30
+
31
+ from proofstep import redaction
32
+ from proofstep.client import Captured, Client, sampled
33
+ from proofstep.config import Config
34
+ from proofstep.context import current_span, current_trace, propagate
35
+ from proofstep.decorators import make_span, make_tool, make_trace
36
+ from proofstep.propagation import extract, inject
37
+ from proofstep.recorder import SpanRecorder, TraceRecorder
38
+ from proofstep.safety import NOOP
39
+ from proofstep_types import CaptureMode, SpanType, Status, Trace
40
+
41
+ # Read from the installed distribution rather than written here twice. A hand-maintained
42
+ # copy drifts the first time a release bumps one and not the other — which it already did,
43
+ # reporting 0.1.0.dev0 from a 0.1.0 wheel.
44
+ __version__ = _metadata.version("proofstep")
45
+
46
+ _client: Client | None = None
47
+
48
+
49
+ def init(**settings: Any) -> Client:
50
+ """Configure the SDK. Safe to call more than once; the last call wins."""
51
+ global _client # noqa: PLW0603 — one process-wide client is the intended shape
52
+ _client = Client(Config.from_env(**settings))
53
+ return _client
54
+
55
+
56
+ def get_client() -> Client:
57
+ """The active client, created from the environment on first use.
58
+
59
+ Implicit initialization is deliberate: an unconfigured import must still work,
60
+ so that adding a decorator never breaks a script that has not called `init`.
61
+ """
62
+ global _client # noqa: PLW0603
63
+ if _client is None:
64
+ _client = Client(Config.from_env())
65
+ return _client
66
+
67
+
68
+ def configure(**settings: Any) -> None:
69
+ """Update settings on the existing client without replacing it."""
70
+ client = get_client()
71
+ for key, value in settings.items():
72
+ if not hasattr(client.config, key):
73
+ msg = f"unknown Proofstep setting {key!r}"
74
+ raise TypeError(msg)
75
+ setattr(client.config, key, value)
76
+
77
+
78
+ def reset() -> None:
79
+ """Drop the active client. For tests."""
80
+ global _client # noqa: PLW0603
81
+ if _client is not None:
82
+ _client.shutdown(0.1)
83
+ _client = None
84
+
85
+
86
+ trace = make_trace(get_client)
87
+ span = make_span(get_client)
88
+ tool = make_tool(span)
89
+
90
+
91
+ @contextlib.contextmanager
92
+ def start_trace(name: str, **kwargs: Any) -> Iterator[Any]:
93
+ """Context-manager form of `@trace`."""
94
+ with get_client().trace(name, **kwargs) as recorder:
95
+ yield recorder
96
+
97
+
98
+ @contextlib.contextmanager
99
+ def start_span(name: str, **kwargs: Any) -> Iterator[Any]:
100
+ """Context-manager form of `@span`."""
101
+ with get_client().span(name, **kwargs) as recorder:
102
+ yield recorder
103
+
104
+
105
+ @contextlib.contextmanager
106
+ def capture(name: str = "task", **kwargs: Any) -> Iterator[list[Trace]]:
107
+ """Record a trace and hand it back rather than only exporting it.
108
+
109
+ This is how an instrumented task feeds the local evaluation engine: the captured
110
+ `Trace` is what trajectory policies are evaluated against.
111
+
112
+ with proofstep.capture("classify") as captured:
113
+ result = await classify(example.input)
114
+ return proofstep.Captured(output=result, trace=captured[0])
115
+ """
116
+ sink: list[Trace] = []
117
+ with get_client().trace(name, **kwargs) as recorder:
118
+ yield sink
119
+ if recorder is not NOOP:
120
+ sink.append(recorder.snapshot())
121
+
122
+
123
+ def set_metadata(**values: Any) -> None:
124
+ if (active := current_trace()) is not None:
125
+ active.set_metadata(**values)
126
+
127
+
128
+ def set_tags(**values: str) -> None:
129
+ if (active := current_trace()) is not None:
130
+ active.set_tags(**values)
131
+
132
+
133
+ def set_state(**values: Any) -> None:
134
+ """Record explicit workflow state for `final_state` policy rules."""
135
+ if (active := current_trace()) is not None:
136
+ active.set_state(**values)
137
+
138
+
139
+ def record_event(name: str, **attributes: Any) -> None:
140
+ if (active := current_span()) is not None:
141
+ active.record_event(name, **attributes)
142
+
143
+
144
+ def set_attributes(**values: Any) -> None:
145
+ if (active := current_span()) is not None:
146
+ active.set_attributes(**values)
147
+
148
+
149
+ def flush(timeout: float | None = None) -> bool:
150
+ return bool(get_client().flush(timeout))
151
+
152
+
153
+ def shutdown(timeout: float | None = None) -> None:
154
+ get_client().shutdown(timeout)
155
+
156
+
157
+ __all__ = [
158
+ "NOOP",
159
+ "CaptureMode",
160
+ "Captured",
161
+ "Client",
162
+ "Config",
163
+ "SpanRecorder",
164
+ "SpanType",
165
+ "Status",
166
+ "Trace",
167
+ "TraceRecorder",
168
+ "capture",
169
+ "configure",
170
+ "current_span",
171
+ "current_trace",
172
+ "extract",
173
+ "flush",
174
+ "get_client",
175
+ "init",
176
+ "inject",
177
+ "propagate",
178
+ "record_event",
179
+ "redaction",
180
+ "reset",
181
+ "sampled",
182
+ "set_attributes",
183
+ "set_metadata",
184
+ "set_state",
185
+ "set_tags",
186
+ "shutdown",
187
+ "span",
188
+ "start_span",
189
+ "start_trace",
190
+ "tool",
191
+ "trace",
192
+ ]
proofstep/client.py ADDED
@@ -0,0 +1,209 @@
1
+ """The client: owns configuration, sampling, the exporter, and span creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import contextlib
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from proofstep import context as ctx
12
+ from proofstep.config import Config
13
+ from proofstep.exporter import Exporter
14
+ from proofstep.recorder import SpanRecorder, TraceRecorder, new_trace_id
15
+ from proofstep.safety import NOOP, log_once, never_raises
16
+ from proofstep_types import SpanType, Status, Trace
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Captured:
21
+ """A task's return value paired with the trace it produced.
22
+
23
+ Shaped to match what the evaluation runner already looks for (`.output` and
24
+ `.trace`), so an instrumented task drops into a suite with no adapter and no
25
+ dependency from the engine back to the SDK.
26
+ """
27
+
28
+ output: Any
29
+ trace: Trace
30
+
31
+
32
+ def sampled(trace_id: str, rate: float) -> bool:
33
+ """Deterministic head sampling on the trace id.
34
+
35
+ Hash-mod rather than a coin flip per span, so a sampled trace is captured
36
+ *whole*. A half-recorded trajectory is worse than none: the policy engine would
37
+ read the gaps as evidence.
38
+ """
39
+ if rate >= 1.0:
40
+ return True
41
+ if rate <= 0.0:
42
+ return False
43
+ return (int(trace_id[:8], 16) / 0xFFFFFFFF) < rate
44
+
45
+
46
+ class Client:
47
+ def __init__(
48
+ self, config: Config | None = None, *, transport: Any = None, **overrides: object
49
+ ) -> None:
50
+ self.config = config or Config.from_env(**overrides)
51
+ self.exporter = Exporter(self.config, transport=transport)
52
+ self._atexit_registered = False
53
+
54
+ # ------------------------------------------------------------------- tracing
55
+
56
+ @never_raises(default=NOOP)
57
+ def start_trace(
58
+ self,
59
+ name: str,
60
+ *,
61
+ trace_id: str | None = None,
62
+ parent_span_id: str | None = None,
63
+ metadata: dict[str, Any] | None = None,
64
+ ) -> TraceRecorder | Any:
65
+ if not self.config.records:
66
+ return NOOP
67
+ identifier = trace_id or new_trace_id()
68
+ recorder = TraceRecorder(
69
+ name,
70
+ config=self.config,
71
+ trace_id=identifier,
72
+ parent_span_id=parent_span_id,
73
+ sampled=sampled(identifier, self.config.sample_rate),
74
+ )
75
+ if metadata:
76
+ recorder.set_metadata(**metadata)
77
+ self._register_atexit()
78
+ return recorder
79
+
80
+ @never_raises(default=NOOP)
81
+ def start_span(
82
+ self,
83
+ name: str,
84
+ *,
85
+ span_type: SpanType | str = SpanType.CUSTOM,
86
+ tool_name: str | None = None,
87
+ trace: TraceRecorder | None = None,
88
+ parent: SpanRecorder | None = None,
89
+ ) -> SpanRecorder | Any:
90
+ if not self.config.records:
91
+ return NOOP
92
+
93
+ active_trace = trace or ctx.current_trace()
94
+ if active_trace is None:
95
+ # An orphan span is more confusing than a synthetic root, and losing it
96
+ # entirely is worse than both. Create a trace so the span has somewhere
97
+ # to live, then emit it when the span closes.
98
+ #
99
+ # The common cause is a raw ThreadPoolExecutor: Python does not copy
100
+ # contextvars across threads, so the worker sees no active trace. Say so,
101
+ # because the fix (`proofstep.propagate`) is not discoverable otherwise.
102
+ log_once(
103
+ "client.orphan_span",
104
+ f"span {name!r} was created with no active trace and has been recorded "
105
+ "as its own trace. If this is a thread, wrap the callable with "
106
+ "proofstep.propagate() to keep it attached to its parent.",
107
+ )
108
+ active_trace = self.start_trace(name)
109
+ if active_trace is NOOP:
110
+ return NOOP
111
+ ctx.set_trace(active_trace)
112
+
113
+ active_parent = parent or ctx.current_span()
114
+ recorder = SpanRecorder(
115
+ name,
116
+ trace=active_trace,
117
+ span_type=SpanType(span_type),
118
+ parent_span_id=active_parent.span_id if active_parent else None,
119
+ tool_name=tool_name,
120
+ depth=(active_parent.depth + 1) if active_parent else 0,
121
+ )
122
+ if not active_trace.register(recorder):
123
+ return NOOP
124
+ return recorder
125
+
126
+ @contextlib.contextmanager
127
+ def trace(self, name: str, **kwargs: Any) -> Iterator[Any]:
128
+ recorder = self.start_trace(name, **kwargs)
129
+ if recorder is NOOP:
130
+ yield NOOP
131
+ return
132
+
133
+ token = ctx.set_trace(recorder)
134
+ try:
135
+ yield recorder
136
+ except BaseException as exc:
137
+ recorder.status = Status.ERROR
138
+ recorder.set_metadata(error=f"{type(exc).__name__}: {exc}"[:500])
139
+ raise
140
+ finally:
141
+ recorder.end()
142
+ ctx.reset_trace(token)
143
+ self.emit(recorder)
144
+
145
+ @contextlib.contextmanager
146
+ def span(self, name: str, **kwargs: Any) -> Iterator[Any]:
147
+ had_trace = ctx.current_trace() is not None
148
+ recorder = self.start_span(name, **kwargs)
149
+ if recorder is NOOP:
150
+ yield NOOP
151
+ return
152
+
153
+ token = ctx.set_span(recorder)
154
+ try:
155
+ yield recorder
156
+ except BaseException as exc:
157
+ # Record and re-raise, untouched. The traceback the user sees must be
158
+ # exactly the one their code produced.
159
+ if isinstance(exc, Exception):
160
+ recorder.set_error(exc)
161
+ else:
162
+ recorder.status = Status.ERROR
163
+ raise
164
+ finally:
165
+ recorder.end()
166
+ ctx.reset_span(token)
167
+ if not had_trace:
168
+ self._close_implicit_trace()
169
+
170
+ def _close_implicit_trace(self) -> None:
171
+ """Emit a trace `start_span` created on the caller's behalf.
172
+
173
+ Without this the span is recorded into a trace nobody ever ends, and the
174
+ data vanishes silently — the exact failure mode the SDK promises to avoid.
175
+ """
176
+ implicit = ctx.current_trace()
177
+ if implicit is None:
178
+ return
179
+ implicit.end()
180
+ self.emit(implicit)
181
+ ctx.set_trace(None)
182
+
183
+ # -------------------------------------------------------------------- export
184
+
185
+ @never_raises()
186
+ def emit(self, recorder: TraceRecorder) -> None:
187
+ """Finish a trace: snapshot it and hand it to the exporter."""
188
+ if not self.config.records:
189
+ return
190
+ keep = recorder.sampled or (
191
+ self.config.always_sample_on_error and recorder.status is Status.ERROR
192
+ )
193
+ if not keep:
194
+ return
195
+ self.exporter.submit(recorder.snapshot())
196
+
197
+ @never_raises(default=False)
198
+ def flush(self, timeout: float | None = None) -> bool:
199
+ return bool(self.exporter.flush(timeout))
200
+
201
+ @never_raises()
202
+ def shutdown(self, timeout: float | None = None) -> None:
203
+ self.exporter.shutdown(timeout)
204
+
205
+ def _register_atexit(self) -> None:
206
+ if self._atexit_registered:
207
+ return
208
+ self._atexit_registered = True
209
+ atexit.register(self.shutdown, self.config.shutdown_timeout_s)
proofstep/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """SDK configuration.
2
+
3
+ Precedence: explicit arguments, then environment variables, then defaults. Env vars
4
+ matter more than they look — they are how a deployment turns capture down, or off,
5
+ without a code change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from proofstep_types import CaptureMode
15
+
16
+ DEFAULT_ENDPOINT = "http://localhost:8000"
17
+
18
+ # Deliberately conservative. A telemetry library that quietly buffers unbounded
19
+ # work, or ships megabyte payloads by default, becomes the outage.
20
+ DEFAULT_MAX_BUFFERED_SPANS = 10_000
21
+ DEFAULT_BATCH_SIZE = 512
22
+ DEFAULT_FLUSH_INTERVAL_S = 2.0
23
+ DEFAULT_MAX_FIELD_BYTES = 256 * 1024
24
+ DEFAULT_MAX_SPAN_BYTES = 1024 * 1024
25
+ DEFAULT_MAX_BATCH_BYTES = 5 * 1024 * 1024
26
+
27
+
28
+ def _env_bool(name: str, default: bool) -> bool:
29
+ raw = os.environ.get(name)
30
+ if raw is None:
31
+ return default
32
+ return raw.strip().lower() not in ("0", "false", "no", "off", "")
33
+
34
+
35
+ def _env_float(name: str, default: float) -> float:
36
+ raw = os.environ.get(name)
37
+ try:
38
+ return float(raw) if raw is not None else default
39
+ except ValueError:
40
+ return default
41
+
42
+
43
+ def _env_int(name: str, default: int) -> int:
44
+ raw = os.environ.get(name)
45
+ try:
46
+ return int(raw) if raw is not None else default
47
+ except ValueError:
48
+ return default
49
+
50
+
51
+ @dataclass
52
+ class Config:
53
+ """Resolved SDK settings."""
54
+
55
+ api_key: str | None = None
56
+ endpoint: str = DEFAULT_ENDPOINT
57
+ project: str | None = None
58
+ environment: str = "development"
59
+ capture_mode: CaptureMode = CaptureMode.REDACTED
60
+ sample_rate: float = 1.0
61
+ always_sample_on_error: bool = True
62
+
63
+ enabled: bool = True
64
+ export: bool = True
65
+ """When False the SDK records in-process but never sends. This is what `--local`
66
+ uses, and what makes the whole engine usable before anyone has an account."""
67
+
68
+ max_buffered_spans: int = DEFAULT_MAX_BUFFERED_SPANS
69
+ batch_size: int = DEFAULT_BATCH_SIZE
70
+ flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S
71
+ max_field_bytes: int = DEFAULT_MAX_FIELD_BYTES
72
+ max_span_bytes: int = DEFAULT_MAX_SPAN_BYTES
73
+ max_batch_bytes: int = DEFAULT_MAX_BATCH_BYTES
74
+ max_spans_per_trace: int = 10_000
75
+
76
+ export_timeout_s: float = 10.0
77
+ max_retries: int = 5
78
+ shutdown_timeout_s: float = 5.0
79
+
80
+ spool_dir: Path | None = None
81
+ """Where to persist batches the API refused. Off by default; on in CI, where a
82
+ lost run is a lost signal rather than a monitoring gap."""
83
+
84
+ git_commit: str | None = None
85
+ service_name: str | None = None
86
+ redact_keys: list[str] = field(default_factory=list)
87
+ debug: bool = False
88
+
89
+ @classmethod
90
+ def from_env(cls, **overrides: object) -> Config:
91
+ """Build a config from the environment, then apply explicit overrides."""
92
+ capture_raw = os.environ.get("PROOFSTEP_CAPTURE_MODE", "").strip().lower()
93
+ try:
94
+ capture = CaptureMode(capture_raw) if capture_raw else CaptureMode.REDACTED
95
+ except ValueError:
96
+ capture = CaptureMode.REDACTED
97
+
98
+ spool = os.environ.get("PROOFSTEP_SPOOL_DIR")
99
+
100
+ config = cls(
101
+ api_key=os.environ.get("PROOFSTEP_API_KEY"),
102
+ endpoint=os.environ.get("PROOFSTEP_ENDPOINT", DEFAULT_ENDPOINT).rstrip("/"),
103
+ project=os.environ.get("PROOFSTEP_PROJECT"),
104
+ environment=os.environ.get("PROOFSTEP_ENVIRONMENT", "development"),
105
+ capture_mode=capture,
106
+ sample_rate=_env_float("PROOFSTEP_SAMPLE_RATE", 1.0),
107
+ enabled=_env_bool("PROOFSTEP_ENABLED", default=True),
108
+ export=_env_bool("PROOFSTEP_EXPORT", default=True),
109
+ max_buffered_spans=_env_int("PROOFSTEP_MAX_BUFFERED_SPANS", DEFAULT_MAX_BUFFERED_SPANS),
110
+ batch_size=_env_int("PROOFSTEP_BATCH_SIZE", DEFAULT_BATCH_SIZE),
111
+ flush_interval_s=_env_float("PROOFSTEP_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL_S),
112
+ spool_dir=Path(spool) if spool else None,
113
+ git_commit=os.environ.get("PROOFSTEP_GIT_COMMIT") or _git_sha(),
114
+ service_name=os.environ.get("PROOFSTEP_SERVICE_NAME"),
115
+ debug=_env_bool("PROOFSTEP_DEBUG", default=False),
116
+ )
117
+
118
+ for key, value in overrides.items():
119
+ if value is None:
120
+ continue
121
+ if not hasattr(config, key):
122
+ msg = f"unknown Proofstep setting {key!r}"
123
+ raise TypeError(msg)
124
+ setattr(config, key, value)
125
+
126
+ config.capture_mode = CaptureMode(config.capture_mode)
127
+ config.sample_rate = min(1.0, max(0.0, config.sample_rate))
128
+ return config
129
+
130
+ @property
131
+ def records(self) -> bool:
132
+ return self.enabled and self.capture_mode is not CaptureMode.DISABLED
133
+
134
+ @property
135
+ def sends(self) -> bool:
136
+ return self.records and self.export and bool(self.api_key)
137
+
138
+ @property
139
+ def stores_payloads(self) -> bool:
140
+ return self.capture_mode.stores_payloads
141
+
142
+
143
+ def _git_sha() -> str | None:
144
+ """Read the commit from CI environment variables only.
145
+
146
+ Deliberately does not shell out to git: a telemetry import must not fork a
147
+ subprocess, and in a container the repo usually is not there anyway.
148
+ """
149
+ for name in ("GITHUB_SHA", "GIT_COMMIT", "CI_COMMIT_SHA", "VERCEL_GIT_COMMIT_SHA"):
150
+ if value := os.environ.get(name):
151
+ return value
152
+ return None
proofstep/context.py ADDED
@@ -0,0 +1,74 @@
1
+ """Context propagation.
2
+
3
+ `contextvars`, not thread-locals. `asyncio.create_task` and `TaskGroup` copy the
4
+ current context automatically, so spans created inside concurrent children attach to
5
+ the right parent with no user action — which is the whole reason to use contextvars
6
+ here, since an agent framework spawns tasks constantly.
7
+
8
+ Threads are the exception: Python does not copy contextvars across them, so
9
+ `propagate()` is provided and its necessity is documented rather than hidden.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextvars
15
+ import functools
16
+ from collections.abc import Callable
17
+ from typing import TYPE_CHECKING, Any, TypeVar
18
+
19
+ if TYPE_CHECKING:
20
+ from proofstep.recorder import SpanRecorder, TraceRecorder
21
+
22
+ T = TypeVar("T")
23
+
24
+ _current_trace: contextvars.ContextVar[TraceRecorder | None] = contextvars.ContextVar(
25
+ "proofstep_trace", default=None
26
+ )
27
+ _current_span: contextvars.ContextVar[SpanRecorder | None] = contextvars.ContextVar(
28
+ "proofstep_span", default=None
29
+ )
30
+
31
+
32
+ def current_trace() -> TraceRecorder | None:
33
+ return _current_trace.get()
34
+
35
+
36
+ def current_span() -> SpanRecorder | None:
37
+ return _current_span.get()
38
+
39
+
40
+ def set_trace(trace: TraceRecorder | None) -> contextvars.Token[TraceRecorder | None]:
41
+ return _current_trace.set(trace)
42
+
43
+
44
+ def set_span(span: SpanRecorder | None) -> contextvars.Token[SpanRecorder | None]:
45
+ return _current_span.set(span)
46
+
47
+
48
+ def reset_trace(token: contextvars.Token[TraceRecorder | None]) -> None:
49
+ _current_trace.reset(token)
50
+
51
+
52
+ def reset_span(token: contextvars.Token[SpanRecorder | None]) -> None:
53
+ _current_span.reset(token)
54
+
55
+
56
+ def propagate(fn: Callable[..., T]) -> Callable[..., T]: # noqa: UP047 — SDK targets py3.10
57
+ """Carry the current context into a callable that will run on another thread.
58
+
59
+ `ThreadPoolExecutor` does not copy contextvars, so a span created inside a
60
+ worker would otherwise attach to nothing and appear as an orphan.
61
+
62
+ pool.submit(proofstep.propagate(do_work), arg)
63
+ """
64
+ context = contextvars.copy_context()
65
+
66
+ @functools.wraps(fn)
67
+ def wrapper(*args: Any, **kwargs: Any) -> T:
68
+ return context.run(fn, *args, **kwargs)
69
+
70
+ return wrapper
71
+
72
+
73
+ def snapshot() -> tuple[TraceRecorder | None, SpanRecorder | None]:
74
+ return _current_trace.get(), _current_span.get()