proofstep 0.1.0__tar.gz
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-0.1.0/.gitignore +57 -0
- proofstep-0.1.0/PKG-INFO +58 -0
- proofstep-0.1.0/README.md +32 -0
- proofstep-0.1.0/pyproject.toml +52 -0
- proofstep-0.1.0/src/proofstep/__init__.py +192 -0
- proofstep-0.1.0/src/proofstep/client.py +209 -0
- proofstep-0.1.0/src/proofstep/config.py +152 -0
- proofstep-0.1.0/src/proofstep/context.py +74 -0
- proofstep-0.1.0/src/proofstep/decorators.py +170 -0
- proofstep-0.1.0/src/proofstep/exporter.py +276 -0
- proofstep-0.1.0/src/proofstep/propagation.py +49 -0
- proofstep-0.1.0/src/proofstep/py.typed +0 -0
- proofstep-0.1.0/src/proofstep/recorder.py +366 -0
- proofstep-0.1.0/src/proofstep/redaction.py +22 -0
- proofstep-0.1.0/src/proofstep/safety.py +119 -0
- proofstep-0.1.0/tests/conftest.py +47 -0
- proofstep-0.1.0/tests/doubles.py +34 -0
- proofstep-0.1.0/tests/test_context.py +176 -0
- proofstep-0.1.0/tests/test_end_to_end.py +215 -0
- proofstep-0.1.0/tests/test_exporter.py +248 -0
- proofstep-0.1.0/tests/test_guarantees.py +195 -0
- proofstep-0.1.0/tests/test_redaction.py +206 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
__pycache__/
|
|
3
|
+
.coverage
|
|
4
|
+
.coverage.*
|
|
5
|
+
.docker-data/
|
|
6
|
+
.DS_Store
|
|
7
|
+
.e2e-api.log
|
|
8
|
+
.env
|
|
9
|
+
.env.*
|
|
10
|
+
.env.prod
|
|
11
|
+
# Exceptions, and they must come *after* the patterns above: git takes the last matching rule, so a
|
|
12
|
+
# negation written earlier in the file is silently overridden. That is not a hypothetical — the `!`
|
|
13
|
+
# line used to sit at the top, `.env.*` re-ignored it, and `.env.prod.example` was never committed.
|
|
14
|
+
# `scripts/init_secrets.sh` reads that file, so the first documented step of self-hosting failed for
|
|
15
|
+
# anyone who cloned the repository. It was caught by CI running the same step.
|
|
16
|
+
!.env.example
|
|
17
|
+
!.env.prod.example
|
|
18
|
+
.hypothesis/
|
|
19
|
+
.idea/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.next/
|
|
22
|
+
.proofstep/
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.turbo/
|
|
26
|
+
.venv/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
*.key
|
|
30
|
+
*.pem
|
|
31
|
+
*.proofstep.local.yaml
|
|
32
|
+
*.py[cod]
|
|
33
|
+
*.swp
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
# Database dumps. Never committed: they contain every tenant's data, and a backup in a git history
|
|
36
|
+
# Docker volumes
|
|
37
|
+
# Editors / OS
|
|
38
|
+
# is a backup with no access control and no retention.
|
|
39
|
+
# Node
|
|
40
|
+
# Proofstep local state
|
|
41
|
+
# Python
|
|
42
|
+
# Reports a local run drops in the working tree. The name comes from the suite, so the pattern has
|
|
43
|
+
# Secrets and local config — never commit these
|
|
44
|
+
# The e2e stack's server log, written next to the repo so a CI failure can print it.
|
|
45
|
+
# to cover all of them rather than the default filename only.
|
|
46
|
+
~/.proofstep/
|
|
47
|
+
backups/
|
|
48
|
+
build/
|
|
49
|
+
coverage.xml
|
|
50
|
+
credentials.json
|
|
51
|
+
dist/
|
|
52
|
+
htmlcov/
|
|
53
|
+
node_modules/
|
|
54
|
+
out/
|
|
55
|
+
proofstep-*.json
|
|
56
|
+
secrets/
|
|
57
|
+
venv/
|
proofstep-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: proofstep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Proofstep Python SDK — tracing and evaluation for AI applications and agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/IlaKhan17/proofstep
|
|
6
|
+
Project-URL: Documentation, https://github.com/IlaKhan17/proofstep/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/IlaKhan17/proofstep
|
|
8
|
+
Project-URL: Issues, https://github.com/IlaKhan17/proofstep/issues
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: proofstep-core
|
|
21
|
+
Requires-Dist: proofstep-types
|
|
22
|
+
Requires-Dist: pydantic>=2.9
|
|
23
|
+
Provides-Extra: eval
|
|
24
|
+
Requires-Dist: proofstep-trajectory; extra == 'eval'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# proofstep
|
|
28
|
+
|
|
29
|
+
**Tracing SDK** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
|
|
30
|
+
agents that knows the difference between a regression and a bad day.
|
|
31
|
+
|
|
32
|
+
Instrument an AI application or agent, and send the trace to a Proofstep server.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import proofstep
|
|
36
|
+
|
|
37
|
+
proofstep.init(endpoint="https://proofstep.internal", api_key="ps_prod_…")
|
|
38
|
+
|
|
39
|
+
with proofstep.capture("outbound") as captured:
|
|
40
|
+
proofstep.set_state(unsubscribed=False)
|
|
41
|
+
with proofstep.start_span("gmail.send", span_type="tool", tool_name="gmail.send") as span:
|
|
42
|
+
span.set_args({"to": recipient, "thread_id": thread})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`set_args` is not decoration: trajectory policies match on `args.*`, so a check whose result never
|
|
46
|
+
reaches the trace cannot be audited later.
|
|
47
|
+
|
|
48
|
+
Secrets are redacted in this process, before export. Access tokens, refresh tokens, API keys,
|
|
49
|
+
passwords, session cookies, and `Authorization` headers are never intentionally stored.
|
|
50
|
+
|
|
51
|
+
Install the evaluation extras with `pip install "proofstep[eval]"`, or the CLI with
|
|
52
|
+
`pip install proofstep-cli`.
|
|
53
|
+
|
|
54
|
+
## Documentation
|
|
55
|
+
|
|
56
|
+
Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
|
|
57
|
+
|
|
58
|
+
Apache-2.0.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# proofstep
|
|
2
|
+
|
|
3
|
+
**Tracing SDK** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
|
|
4
|
+
agents that knows the difference between a regression and a bad day.
|
|
5
|
+
|
|
6
|
+
Instrument an AI application or agent, and send the trace to a Proofstep server.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import proofstep
|
|
10
|
+
|
|
11
|
+
proofstep.init(endpoint="https://proofstep.internal", api_key="ps_prod_…")
|
|
12
|
+
|
|
13
|
+
with proofstep.capture("outbound") as captured:
|
|
14
|
+
proofstep.set_state(unsubscribed=False)
|
|
15
|
+
with proofstep.start_span("gmail.send", span_type="tool", tool_name="gmail.send") as span:
|
|
16
|
+
span.set_args({"to": recipient, "thread_id": thread})
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`set_args` is not decoration: trajectory policies match on `args.*`, so a check whose result never
|
|
20
|
+
reaches the trace cannot be audited later.
|
|
21
|
+
|
|
22
|
+
Secrets are redacted in this process, before export. Access tokens, refresh tokens, API keys,
|
|
23
|
+
passwords, session cookies, and `Authorization` headers are never intentionally stored.
|
|
24
|
+
|
|
25
|
+
Install the evaluation extras with `pip install "proofstep[eval]"`, or the CLI with
|
|
26
|
+
`pip install proofstep-cli`.
|
|
27
|
+
|
|
28
|
+
## Documentation
|
|
29
|
+
|
|
30
|
+
Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
|
|
31
|
+
|
|
32
|
+
Apache-2.0.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "proofstep"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Proofstep Python SDK — tracing and evaluation for AI applications and agents"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
# Deliberately wider than the rest of the workspace: this installs into *users'*
|
|
7
|
+
# applications, and forcing an interpreter upgrade to adopt a tracing library
|
|
8
|
+
# is a non-starter. See ADR-002.
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: Apache Software License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Software Development :: Testing",
|
|
18
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
dependencies = [
|
|
23
|
+
"proofstep-types",
|
|
24
|
+
# For the redaction pipeline, which the server shares — see proofstep/redaction.py.
|
|
25
|
+
"proofstep-core",
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"pydantic>=2.9",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
# `proofstep-core` used to be here; it is a hard dependency now (redaction lives in it), so the
|
|
32
|
+
# extra is down to the trajectory engine. Kept named `eval` so `pip install proofstep[eval]`
|
|
33
|
+
# keeps working for anyone who already wrote it.
|
|
34
|
+
eval = ["proofstep-trajectory"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/IlaKhan17/proofstep"
|
|
38
|
+
Documentation = "https://github.com/IlaKhan17/proofstep/tree/main/docs"
|
|
39
|
+
Repository = "https://github.com/IlaKhan17/proofstep"
|
|
40
|
+
Issues = "https://github.com/IlaKhan17/proofstep/issues"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/proofstep"]
|
|
48
|
+
|
|
49
|
+
[tool.uv.sources]
|
|
50
|
+
proofstep-types = { workspace = true }
|
|
51
|
+
proofstep-core = { workspace = true }
|
|
52
|
+
proofstep-trajectory = { workspace = true }
|
|
@@ -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
|
+
]
|
|
@@ -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)
|