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/__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()
|