obzerve 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.
- obzerve/__init__.py +7 -0
- obzerve/_version.py +3 -0
- obzerve/attributes.py +61 -0
- obzerve/cli.py +188 -0
- obzerve/client.py +98 -0
- obzerve/delivery.py +139 -0
- obzerve/evals.py +156 -0
- obzerve/instrumentation/__init__.py +117 -0
- obzerve/instrumentation/_common.py +312 -0
- obzerve/instrumentation/anthropic.py +279 -0
- obzerve/instrumentation/huggingface_hub.py +218 -0
- obzerve/instrumentation/openai.py +270 -0
- obzerve/instrumentation/transformers.py +163 -0
- obzerve/observer.py +621 -0
- obzerve/payloads.py +109 -0
- obzerve/py.typed +0 -0
- obzerve/replay.py +224 -0
- obzerve-0.1.0.dist-info/METADATA +170 -0
- obzerve-0.1.0.dist-info/RECORD +22 -0
- obzerve-0.1.0.dist-info/WHEEL +4 -0
- obzerve-0.1.0.dist-info/entry_points.txt +2 -0
- obzerve-0.1.0.dist-info/licenses/LICENSE +202 -0
obzerve/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Obzerve Python SDK: trace AI-agent workflows with OpenTelemetry."""
|
|
2
|
+
|
|
3
|
+
from obzerve.attributes import SpanKind
|
|
4
|
+
from obzerve.delivery import DeliveryStats
|
|
5
|
+
from obzerve.observer import Observer, ObzerveSpan, __version__
|
|
6
|
+
|
|
7
|
+
__all__ = ["DeliveryStats", "Observer", "ObzerveSpan", "SpanKind", "__version__"]
|
obzerve/_version.py
ADDED
obzerve/attributes.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Span attribute keys shared by the SDK and the Obzerve server.
|
|
2
|
+
|
|
3
|
+
AI-specific keys follow the OpenTelemetry GenAI semantic conventions where one
|
|
4
|
+
exists; Obzerve-specific keys live under the ``obzerve.`` namespace.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Obzerve
|
|
8
|
+
SPAN_KIND = "obzerve.span.kind"
|
|
9
|
+
RETRY_COUNT = "obzerve.retry.count"
|
|
10
|
+
RETRY_ATTEMPT = "obzerve.retry.attempt"
|
|
11
|
+
INPUT = "obzerve.input"
|
|
12
|
+
OUTPUT = "obzerve.output"
|
|
13
|
+
INPUT_TRUNCATED = "obzerve.input.truncated"
|
|
14
|
+
OUTPUT_TRUNCATED = "obzerve.output.truncated"
|
|
15
|
+
INPUT_REDACTED = "obzerve.input.redacted"
|
|
16
|
+
SDK_VERSION = "obzerve.sdk.version"
|
|
17
|
+
|
|
18
|
+
# Replay: recording (set on replayable operations)
|
|
19
|
+
REPLAY_KEY = "obzerve.replay.key" # "<name>#<occurrence>" within the trace
|
|
20
|
+
REPLAY_INPUT_HASH = "obzerve.replay.input_hash"
|
|
21
|
+
REPLAY_COMPLETE = "obzerve.replay.complete" # the recorded output round-trips exactly
|
|
22
|
+
REPLAY_INCOMPLETE_REASON = "obzerve.replay.incomplete_reason"
|
|
23
|
+
REPLAY_SIDE_EFFECT = "obzerve.replay.side_effect"
|
|
24
|
+
REPLAY_ERROR_CLASS = "obzerve.replay.error_class" # "module:QualName" of a raised exception
|
|
25
|
+
# Replay: replaying
|
|
26
|
+
REPLAY_SOURCE = "obzerve.replay.source" # "live" or "recorded"
|
|
27
|
+
REPLAY_INPUT_MATCHED = "obzerve.replay.input_matched"
|
|
28
|
+
REPLAY_OF = "obzerve.replay.of" # on the root span: the trace being replayed
|
|
29
|
+
REPLAY_MODE = "obzerve.replay.mode"
|
|
30
|
+
REPLAY_LABEL = "obzerve.replay.label"
|
|
31
|
+
|
|
32
|
+
# Versioned configuration and evaluation (root span)
|
|
33
|
+
PROMPT_NAME = "obzerve.prompt.name"
|
|
34
|
+
PROMPT_VERSION = "obzerve.prompt.version"
|
|
35
|
+
EVAL_RUN_ID = "obzerve.eval.run_id"
|
|
36
|
+
EVAL_CASE_ID = "obzerve.eval.case_id"
|
|
37
|
+
|
|
38
|
+
# OpenTelemetry GenAI semantic conventions
|
|
39
|
+
GEN_AI_PROVIDER = "gen_ai.provider.name"
|
|
40
|
+
GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
|
|
41
|
+
GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
|
|
42
|
+
GEN_AI_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
|
43
|
+
GEN_AI_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
|
44
|
+
# Input tokens served from the provider's prompt cache (a subset of input tokens).
|
|
45
|
+
GEN_AI_CACHED_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
|
|
46
|
+
|
|
47
|
+
# OpenTelemetry general conventions
|
|
48
|
+
ERROR_TYPE = "error.type"
|
|
49
|
+
SERVICE_NAME = "service.name"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SpanKind:
|
|
53
|
+
"""Logical operation types understood by the dashboard."""
|
|
54
|
+
|
|
55
|
+
AGENT = "agent"
|
|
56
|
+
LLM = "llm"
|
|
57
|
+
TOOL = "tool"
|
|
58
|
+
RETRIEVAL = "retrieval"
|
|
59
|
+
CHAIN = "chain"
|
|
60
|
+
HANDOFF = "handoff"
|
|
61
|
+
FUNCTION = "function"
|
obzerve/cli.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``obzerve`` command line: turn traces into regression cases, compare eval runs,
|
|
2
|
+
and gate releases in CI (exit code 1 when the gate fails).
|
|
3
|
+
|
|
4
|
+
Reads ``OBZERVE_ENDPOINT`` and ``OBZERVE_API_KEY`` from the environment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from obzerve.client import ObzerveAPIError, ObzerveClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client(args: argparse.Namespace) -> ObzerveClient:
|
|
18
|
+
return ObzerveClient(args.endpoint, args.api_key)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pct(value: float | None) -> str:
|
|
22
|
+
return "–" if value is None else f"{value:+.1f}%"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _money(value: float | None) -> str:
|
|
26
|
+
return "–" if value is None else f"${value:.6f}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_case_add(args: argparse.Namespace) -> int:
|
|
30
|
+
assertions: list[dict] = []
|
|
31
|
+
if args.no_error:
|
|
32
|
+
assertions.append({"type": "no_error"})
|
|
33
|
+
if args.contains_all:
|
|
34
|
+
assertions.append({"type": "contains_all", "values": args.contains_all})
|
|
35
|
+
if args.contains_any:
|
|
36
|
+
assertions.append({"type": "contains_any", "values": args.contains_any})
|
|
37
|
+
if args.not_contains:
|
|
38
|
+
assertions.append({"type": "not_contains", "values": args.not_contains})
|
|
39
|
+
if args.regex:
|
|
40
|
+
assertions.append({"type": "regex", "pattern": args.regex})
|
|
41
|
+
if args.max_latency_ms:
|
|
42
|
+
assertions.append({"type": "max_latency_ms", "value": args.max_latency_ms})
|
|
43
|
+
for raw in args.assertion or []:
|
|
44
|
+
assertions.append(json.loads(raw))
|
|
45
|
+
if not assertions:
|
|
46
|
+
print("add at least one assertion (e.g. --no-error)", file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
case = _client(args).create_case(
|
|
49
|
+
args.dataset, args.trace_id, args.name, assertions, args.notes, args.mode
|
|
50
|
+
)
|
|
51
|
+
print(
|
|
52
|
+
f"case {case['id']} added to {args.dataset!r} ({case['replayability']}, "
|
|
53
|
+
f"{case['recordings']} recorded operations, replayed {case['replay_policy']})"
|
|
54
|
+
)
|
|
55
|
+
for reason in case["replay_reasons"]:
|
|
56
|
+
print(f" note: {reason}")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _print_comparison(cmp: dict) -> None:
|
|
61
|
+
b, c = cmp["baseline"], cmp["candidate"]
|
|
62
|
+
rows = [
|
|
63
|
+
("label", b["label"], c["label"]),
|
|
64
|
+
("prompt", _prompt(b), _prompt(c)),
|
|
65
|
+
(
|
|
66
|
+
"pass rate",
|
|
67
|
+
f"{b['pass_rate']:.0%} ({b['passed']}/{b['cases']})",
|
|
68
|
+
f"{c['pass_rate']:.0%} ({c['passed']}/{c['cases']})",
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"est. cost / case",
|
|
72
|
+
_money(b["estimated_cost_per_case_usd"]),
|
|
73
|
+
_money(c["estimated_cost_per_case_usd"]),
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"output tokens / case",
|
|
77
|
+
_num(b["output_tokens_per_case"]),
|
|
78
|
+
_num(c["output_tokens_per_case"]),
|
|
79
|
+
),
|
|
80
|
+
("p95 latency", _ms(b["latency_p95_ms"]), _ms(c["latency_p95_ms"])),
|
|
81
|
+
]
|
|
82
|
+
width = max(len(r[1]) for r in rows) + 2
|
|
83
|
+
print(f"{'':22}{'baseline':{width}}candidate")
|
|
84
|
+
for name, x, y in rows:
|
|
85
|
+
print(f"{name:22}{x:{width}}{y}")
|
|
86
|
+
print(
|
|
87
|
+
f"\nregressions: {cmp['regressions']} fixes: {cmp['fixes']} "
|
|
88
|
+
f"cost/case {_pct(cmp['cost_per_case_delta_pct'])} "
|
|
89
|
+
f"p95 {_pct(cmp['latency_p95_delta_pct'])}"
|
|
90
|
+
)
|
|
91
|
+
if not cmp["comparable"]:
|
|
92
|
+
print("NOT COMPARABLE: " + "; ".join(cmp["reasons"]))
|
|
93
|
+
for case in cmp["cases"]:
|
|
94
|
+
if case["change"] in ("regression", "fix", "still_failing"):
|
|
95
|
+
why = f" — {'; '.join(case['failed_assertions'])}" if case["failed_assertions"] else ""
|
|
96
|
+
print(f" {case['change']:13} {case['case_name']}{why}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _prompt(run: dict) -> str:
|
|
100
|
+
p = run.get("prompt")
|
|
101
|
+
return f"{p['name']} v{p['version']}" if p else "–"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _num(v: float | None) -> str:
|
|
105
|
+
return "–" if v is None else f"{v:.0f}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ms(v: float | None) -> str:
|
|
109
|
+
return "–" if v is None else f"{v:.0f} ms"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
113
|
+
_print_comparison(_client(args).compare_runs(args.baseline, args.candidate))
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_gate(args: argparse.Namespace) -> int:
|
|
118
|
+
result = _client(args).gate(
|
|
119
|
+
args.baseline,
|
|
120
|
+
args.candidate,
|
|
121
|
+
max_regressions=args.max_regressions,
|
|
122
|
+
min_pass_rate=args.min_pass_rate,
|
|
123
|
+
max_pass_rate_drop=args.max_pass_rate_drop,
|
|
124
|
+
max_cost_increase_pct=args.max_cost_increase_pct,
|
|
125
|
+
max_p95_latency_increase_pct=args.max_p95_latency_increase_pct,
|
|
126
|
+
)
|
|
127
|
+
_print_comparison(result["comparison"])
|
|
128
|
+
print()
|
|
129
|
+
for check in result["checks"]:
|
|
130
|
+
print(f" [{'PASS' if check['passed'] else 'FAIL'}] {check['name']}: {check['detail']}")
|
|
131
|
+
print("\nGATE " + ("PASSED" if result["passed"] else "FAILED"))
|
|
132
|
+
return 0 if result["passed"] else 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv: list[str] | None = None) -> int:
|
|
136
|
+
parser = argparse.ArgumentParser(prog="obzerve", description=__doc__.split("\n\n")[0])
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--endpoint", default=os.environ.get("OBZERVE_ENDPOINT", "http://localhost:8000")
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument("--api-key", default=os.environ.get("OBZERVE_API_KEY"))
|
|
141
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
142
|
+
|
|
143
|
+
case = sub.add_parser("case", help="Regression cases").add_subparsers(dest="sub", required=True)
|
|
144
|
+
add = case.add_parser("add", help="Turn a trace into a regression case")
|
|
145
|
+
add.add_argument("trace_id")
|
|
146
|
+
add.add_argument("--dataset", required=True)
|
|
147
|
+
add.add_argument("--name", required=True)
|
|
148
|
+
add.add_argument("--notes")
|
|
149
|
+
add.add_argument(
|
|
150
|
+
"--mode",
|
|
151
|
+
choices=["recorded", "llm-live", "live"],
|
|
152
|
+
default="llm-live",
|
|
153
|
+
help="recorded: re-run the recorded failure exactly; llm-live: model live (default)",
|
|
154
|
+
)
|
|
155
|
+
add.add_argument("--no-error", action="store_true", help="the run must not raise")
|
|
156
|
+
add.add_argument("--contains-all", nargs="+", metavar="TERM")
|
|
157
|
+
add.add_argument("--contains-any", nargs="+", metavar="TERM")
|
|
158
|
+
add.add_argument("--not-contains", nargs="+", metavar="TERM")
|
|
159
|
+
add.add_argument("--regex", help="output must match (case-insensitive)")
|
|
160
|
+
add.add_argument("--max-latency-ms", type=float)
|
|
161
|
+
add.add_argument("--assertion", action="append", metavar="JSON", help="raw assertion spec")
|
|
162
|
+
add.set_defaults(fn=cmd_case_add)
|
|
163
|
+
|
|
164
|
+
ev = sub.add_parser("eval", help="Eval runs").add_subparsers(dest="sub", required=True)
|
|
165
|
+
compare = ev.add_parser("compare", help="Compare a candidate run with a baseline run")
|
|
166
|
+
compare.add_argument("baseline")
|
|
167
|
+
compare.add_argument("candidate")
|
|
168
|
+
compare.set_defaults(fn=cmd_compare)
|
|
169
|
+
gate = ev.add_parser("gate", help="Fail (exit 1) if the candidate does not meet the criteria")
|
|
170
|
+
gate.add_argument("baseline")
|
|
171
|
+
gate.add_argument("candidate")
|
|
172
|
+
gate.add_argument("--max-regressions", type=int, default=0)
|
|
173
|
+
gate.add_argument("--min-pass-rate", type=float)
|
|
174
|
+
gate.add_argument("--max-pass-rate-drop", type=float)
|
|
175
|
+
gate.add_argument("--max-cost-increase-pct", type=float)
|
|
176
|
+
gate.add_argument("--max-p95-latency-increase-pct", type=float)
|
|
177
|
+
gate.set_defaults(fn=cmd_gate)
|
|
178
|
+
|
|
179
|
+
args = parser.parse_args(argv)
|
|
180
|
+
try:
|
|
181
|
+
return args.fn(args)
|
|
182
|
+
except ObzerveAPIError as exc:
|
|
183
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
sys.exit(main())
|
obzerve/client.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""HTTP client for Obzerve's API: replay bundles, prompt versions, eval datasets and runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from obzerve.replay import ReplayBundle
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ObzerveAPIError(Exception):
|
|
14
|
+
def __init__(self, status: int, detail: Any):
|
|
15
|
+
super().__init__(f"HTTP {status}: {detail}")
|
|
16
|
+
self.status = status
|
|
17
|
+
self.detail = detail
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ObzerveClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
endpoint: str = "http://localhost:8000",
|
|
24
|
+
api_key: str | None = None,
|
|
25
|
+
timeout_s: float = 30.0,
|
|
26
|
+
):
|
|
27
|
+
self.endpoint = endpoint.rstrip("/")
|
|
28
|
+
self.timeout_s = timeout_s
|
|
29
|
+
self._session = requests.Session()
|
|
30
|
+
if api_key:
|
|
31
|
+
self._session.headers["Authorization"] = f"Bearer {api_key}"
|
|
32
|
+
|
|
33
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
34
|
+
resp = self._session.request(
|
|
35
|
+
method, f"{self.endpoint}{path}", timeout=self.timeout_s, **kwargs
|
|
36
|
+
)
|
|
37
|
+
if resp.status_code >= 400:
|
|
38
|
+
try:
|
|
39
|
+
detail = resp.json().get("detail", resp.text)
|
|
40
|
+
except ValueError:
|
|
41
|
+
detail = resp.text
|
|
42
|
+
raise ObzerveAPIError(resp.status_code, detail)
|
|
43
|
+
return resp.json() if resp.content else None
|
|
44
|
+
|
|
45
|
+
# Replay
|
|
46
|
+
def replay_bundle(self, trace_id: str) -> ReplayBundle:
|
|
47
|
+
return ReplayBundle.from_json(self._request("GET", f"/api/v1/traces/{trace_id}/replay"))
|
|
48
|
+
|
|
49
|
+
def compare_traces(self, a: str, b: str) -> dict[str, Any]:
|
|
50
|
+
return self._request("GET", "/api/v1/trace-comparison", params={"a": a, "b": b})
|
|
51
|
+
|
|
52
|
+
# Prompt versions
|
|
53
|
+
def register_prompt(self, name: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
54
|
+
"""Register a prompt/model configuration; identical content returns the same version."""
|
|
55
|
+
return self._request(
|
|
56
|
+
"POST", f"/api/v1/prompts/{quote(name, safe='')}/versions", json={"config": config}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Datasets and cases
|
|
60
|
+
def dataset(
|
|
61
|
+
self, name: str, *, version: int | None = None, include_bundles: bool = False
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
params: dict[str, Any] = {"include_bundles": str(include_bundles).lower()}
|
|
64
|
+
if version is not None:
|
|
65
|
+
params["version"] = version
|
|
66
|
+
return self._request("GET", f"/api/v1/datasets/{quote(name, safe='')}", params=params)
|
|
67
|
+
|
|
68
|
+
def create_case(
|
|
69
|
+
self,
|
|
70
|
+
dataset: str,
|
|
71
|
+
trace_id: str,
|
|
72
|
+
name: str,
|
|
73
|
+
assertions: list[dict[str, Any]],
|
|
74
|
+
notes: str | None = None,
|
|
75
|
+
replay_policy: str = "llm-live",
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
return self._request(
|
|
78
|
+
"POST",
|
|
79
|
+
f"/api/v1/datasets/{quote(dataset, safe='')}/cases",
|
|
80
|
+
json={
|
|
81
|
+
"trace_id": trace_id,
|
|
82
|
+
"name": name,
|
|
83
|
+
"assertions": assertions,
|
|
84
|
+
"notes": notes,
|
|
85
|
+
"replay_policy": replay_policy,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Eval runs
|
|
90
|
+
def submit_run(self, run: dict[str, Any]) -> dict[str, Any]:
|
|
91
|
+
return self._request("POST", "/api/v1/eval-runs", json=run)
|
|
92
|
+
|
|
93
|
+
def compare_runs(self, baseline: str, candidate: str) -> dict[str, Any]:
|
|
94
|
+
return self._request("GET", f"/api/v1/eval-runs/compare/{baseline}/{candidate}")
|
|
95
|
+
|
|
96
|
+
def gate(self, baseline: str, candidate: str, **criteria: Any) -> dict[str, Any]:
|
|
97
|
+
params = {k: v for k, v in criteria.items() if v is not None}
|
|
98
|
+
return self._request("GET", f"/api/v1/eval-runs/gate/{baseline}/{candidate}", params=params)
|
obzerve/delivery.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Bounded, accounted span delivery.
|
|
2
|
+
|
|
3
|
+
Wraps OpenTelemetry's ``BatchSpanProcessor`` and exporter so that every ended
|
|
4
|
+
span is counted as exactly one of: exported, failed (the backend rejected it
|
|
5
|
+
or was unreachable after the exporter's retries), dropped (the buffer was
|
|
6
|
+
full), or still pending. The buffer bound is enforced here, before the batch
|
|
7
|
+
processor's own queue, so drops are counted precisely.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from opentelemetry.context import Context
|
|
19
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
|
|
20
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("obzerve")
|
|
23
|
+
|
|
24
|
+
_WARN_INTERVAL_S = 60.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DeliveryStats:
|
|
29
|
+
"""Telemetry delivery counters since the Observer was created."""
|
|
30
|
+
|
|
31
|
+
spans_ended: int
|
|
32
|
+
spans_exported: int
|
|
33
|
+
spans_failed: int
|
|
34
|
+
spans_dropped: int
|
|
35
|
+
spans_pending: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Counters:
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.lock = threading.Lock()
|
|
41
|
+
self.ended = 0
|
|
42
|
+
self.exported = 0
|
|
43
|
+
self.failed = 0
|
|
44
|
+
self.dropped = 0
|
|
45
|
+
self.pending = 0
|
|
46
|
+
self._last_warning = 0.0
|
|
47
|
+
|
|
48
|
+
def warn(self, message: str, *args: object) -> None:
|
|
49
|
+
"""Log at most once per interval, so a dead backend cannot flood the agent's logs."""
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
if now - self._last_warning >= _WARN_INTERVAL_S:
|
|
52
|
+
self._last_warning = now
|
|
53
|
+
logger.warning(message, *args)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AccountingExporter(SpanExporter):
|
|
57
|
+
def __init__(self, inner: SpanExporter, counters: _Counters):
|
|
58
|
+
self._inner = inner
|
|
59
|
+
self._counters = counters
|
|
60
|
+
|
|
61
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
62
|
+
try:
|
|
63
|
+
result = self._inner.export(spans)
|
|
64
|
+
except Exception: # an exporter bug must not kill the export thread
|
|
65
|
+
logger.exception("Obzerve exporter raised")
|
|
66
|
+
result = SpanExportResult.FAILURE
|
|
67
|
+
c = self._counters
|
|
68
|
+
with c.lock:
|
|
69
|
+
c.pending -= len(spans)
|
|
70
|
+
if result is SpanExportResult.SUCCESS:
|
|
71
|
+
c.exported += len(spans)
|
|
72
|
+
else:
|
|
73
|
+
c.failed += len(spans)
|
|
74
|
+
failed = c.failed
|
|
75
|
+
if result is not SpanExportResult.SUCCESS:
|
|
76
|
+
c.warn("Obzerve could not deliver %d spans (%d failed in total)", len(spans), failed)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def shutdown(self) -> None:
|
|
80
|
+
self._inner.shutdown()
|
|
81
|
+
|
|
82
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
83
|
+
return self._inner.force_flush(timeout_millis)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AccountingProcessor(SpanProcessor):
|
|
87
|
+
"""Admits a span to the batch processor only while fewer than
|
|
88
|
+
``max_pending`` spans are buffered or being exported."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
exporter: SpanExporter,
|
|
93
|
+
*,
|
|
94
|
+
max_queue_size: int,
|
|
95
|
+
max_export_batch_size: int,
|
|
96
|
+
schedule_delay_ms: int,
|
|
97
|
+
export_timeout_s: float,
|
|
98
|
+
):
|
|
99
|
+
self.counters = _Counters()
|
|
100
|
+
self._max_pending = max_queue_size
|
|
101
|
+
# The inner queue holds at most ``max_pending`` spans, so it never drops on its own.
|
|
102
|
+
self._batch = BatchSpanProcessor(
|
|
103
|
+
AccountingExporter(exporter, self.counters),
|
|
104
|
+
max_queue_size=max_queue_size,
|
|
105
|
+
max_export_batch_size=min(max_export_batch_size, max_queue_size),
|
|
106
|
+
schedule_delay_millis=schedule_delay_ms,
|
|
107
|
+
export_timeout_millis=export_timeout_s * 1000,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
111
|
+
self._batch.on_start(span, parent_context)
|
|
112
|
+
|
|
113
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
114
|
+
if not span.context.trace_flags.sampled:
|
|
115
|
+
return
|
|
116
|
+
c = self.counters
|
|
117
|
+
with c.lock:
|
|
118
|
+
c.ended += 1
|
|
119
|
+
admitted = c.pending < self._max_pending
|
|
120
|
+
if admitted:
|
|
121
|
+
c.pending += 1
|
|
122
|
+
else:
|
|
123
|
+
c.dropped += 1
|
|
124
|
+
dropped = c.dropped
|
|
125
|
+
if admitted:
|
|
126
|
+
self._batch.on_end(span)
|
|
127
|
+
else:
|
|
128
|
+
c.warn("Obzerve telemetry buffer full; dropping spans (%d dropped in total)", dropped)
|
|
129
|
+
|
|
130
|
+
def stats(self) -> DeliveryStats:
|
|
131
|
+
c = self.counters
|
|
132
|
+
with c.lock:
|
|
133
|
+
return DeliveryStats(c.ended, c.exported, c.failed, c.dropped, c.pending)
|
|
134
|
+
|
|
135
|
+
def shutdown(self) -> None:
|
|
136
|
+
self._batch.shutdown()
|
|
137
|
+
|
|
138
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
139
|
+
return self._batch.force_flush(timeout_millis)
|
obzerve/evals.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Evaluation runs: replay every case of a dataset against a candidate and
|
|
2
|
+
check its assertions.
|
|
3
|
+
|
|
4
|
+
Each case carries the recorded input and dependencies of the trace it was
|
|
5
|
+
made from, and its own replay policy: ``recorded`` to re-run a recorded failure
|
|
6
|
+
exactly, or ``llm-live`` to test the model/prompt against recorded tools and
|
|
7
|
+
retrieval. ``run_eval`` replays each case, applies its deterministic
|
|
8
|
+
assertions, and submits the results with everything needed to judge whether
|
|
9
|
+
two runs are comparable: dataset version, evaluator version, prompt version,
|
|
10
|
+
replay policy, and configuration.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from obzerve import attributes as attrs
|
|
25
|
+
from obzerve.client import ObzerveClient
|
|
26
|
+
from obzerve.observer import Observer
|
|
27
|
+
from obzerve.replay import POLICIES, ReplayBundle, ReplayPolicy
|
|
28
|
+
|
|
29
|
+
# Bump whenever an assertion's semantics change: runs with different evaluator
|
|
30
|
+
# versions are not compared.
|
|
31
|
+
EVALUATOR_VERSION = "obzerve-assertions/1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Outcome:
|
|
36
|
+
output: str | None
|
|
37
|
+
error: str | None
|
|
38
|
+
latency_ms: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def output_text(value: Any) -> str | None:
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
return value
|
|
46
|
+
try:
|
|
47
|
+
return json.dumps(value, ensure_ascii=False, default=repr)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return repr(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def evaluate(assertion: dict[str, Any], outcome: Outcome) -> tuple[bool, str]:
|
|
53
|
+
"""Apply one assertion; returns ``(passed, explanation)``."""
|
|
54
|
+
kind = assertion["type"]
|
|
55
|
+
text = (outcome.output or "").lower()
|
|
56
|
+
if kind == "no_error":
|
|
57
|
+
return outcome.error is None, "completed without error" if outcome.error is None else (
|
|
58
|
+
f"raised {outcome.error}"
|
|
59
|
+
)
|
|
60
|
+
if kind == "max_latency_ms":
|
|
61
|
+
limit = assertion["value"]
|
|
62
|
+
return outcome.latency_ms <= limit, f"took {outcome.latency_ms:.0f} ms (limit {limit:g})"
|
|
63
|
+
if outcome.error is not None:
|
|
64
|
+
return False, f"no output: raised {outcome.error}"
|
|
65
|
+
if kind == "contains_all":
|
|
66
|
+
missing = [v for v in assertion["values"] if v.lower() not in text]
|
|
67
|
+
return not missing, "contains all terms" if not missing else f"missing {missing}"
|
|
68
|
+
if kind == "contains_any":
|
|
69
|
+
found = [v for v in assertion["values"] if v.lower() in text]
|
|
70
|
+
need = assertion.get("min_matches", 1)
|
|
71
|
+
return len(found) >= need, f"found {len(found)} of required {need}: {found}"
|
|
72
|
+
if kind == "not_contains":
|
|
73
|
+
present = [v for v in assertion["values"] if v.lower() in text]
|
|
74
|
+
return not present, "none present" if not present else f"contains {present}"
|
|
75
|
+
if kind == "regex":
|
|
76
|
+
matched = re.search(assertion["pattern"], outcome.output or "", re.IGNORECASE) is not None
|
|
77
|
+
want = assertion.get("must_match", True)
|
|
78
|
+
return matched == want, f"pattern {'matched' if matched else 'did not match'}"
|
|
79
|
+
if kind == "max_output_chars":
|
|
80
|
+
n, limit = len(outcome.output or ""), assertion["value"]
|
|
81
|
+
return n <= limit, f"{n} chars (limit {limit})"
|
|
82
|
+
return False, f"unknown assertion type {kind!r} for {EVALUATOR_VERSION}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_eval(
|
|
86
|
+
observer: Observer,
|
|
87
|
+
client: ObzerveClient,
|
|
88
|
+
dataset: str,
|
|
89
|
+
target: Callable[[Any], Any],
|
|
90
|
+
*,
|
|
91
|
+
label: str,
|
|
92
|
+
policy: ReplayPolicy | None = None,
|
|
93
|
+
prompt_version: dict[str, Any] | None = None,
|
|
94
|
+
config: dict[str, Any] | None = None,
|
|
95
|
+
on_case: Callable[[dict[str, Any], dict[str, Any]], None] | None = None,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Run every active case of ``dataset`` through ``target(root_input)`` and
|
|
98
|
+
submit the results. ``policy`` overrides every case's own replay policy
|
|
99
|
+
(default: per case). ``prompt_version`` is the dict returned by
|
|
100
|
+
``client.register_prompt``. Returns the stored run (with its summary)."""
|
|
101
|
+
data = client.dataset(dataset, include_bundles=True)
|
|
102
|
+
if not data["cases"]:
|
|
103
|
+
raise ValueError(f"dataset {dataset!r} has no active cases")
|
|
104
|
+
started = datetime.now(timezone.utc)
|
|
105
|
+
run_id = str(uuid.uuid4())
|
|
106
|
+
replay_attrs: dict[str, Any] = {attrs.EVAL_RUN_ID: run_id}
|
|
107
|
+
if prompt_version:
|
|
108
|
+
replay_attrs[attrs.PROMPT_NAME] = prompt_version["name"]
|
|
109
|
+
replay_attrs[attrs.PROMPT_VERSION] = str(prompt_version["version"])
|
|
110
|
+
|
|
111
|
+
results = []
|
|
112
|
+
for case in data["cases"]:
|
|
113
|
+
bundle = ReplayBundle.from_json(case["bundle"])
|
|
114
|
+
case_attrs = {**replay_attrs, attrs.EVAL_CASE_ID: case["id"]}
|
|
115
|
+
t0 = time.perf_counter()
|
|
116
|
+
output = error = None
|
|
117
|
+
case_policy = policy or POLICIES[case.get("replay_policy", "llm-live")]
|
|
118
|
+
with observer.replay(bundle, case_policy, label=label, attributes=case_attrs) as session:
|
|
119
|
+
try:
|
|
120
|
+
output = output_text(target(bundle.root_input))
|
|
121
|
+
except Exception as exc: # a failing case is a result, not a crash
|
|
122
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
123
|
+
outcome = Outcome(output, error, (time.perf_counter() - t0) * 1000)
|
|
124
|
+
verdicts = []
|
|
125
|
+
for assertion in case["assertions"]:
|
|
126
|
+
passed, detail = evaluate(assertion, outcome)
|
|
127
|
+
verdicts.append({"type": assertion["type"], "passed": passed, "detail": detail})
|
|
128
|
+
result = {
|
|
129
|
+
"case_id": case["id"],
|
|
130
|
+
"trace_id": session.root_trace_id,
|
|
131
|
+
"passed": all(v["passed"] for v in verdicts),
|
|
132
|
+
"assertions": verdicts,
|
|
133
|
+
"output": (output or "")[:200_000] or None,
|
|
134
|
+
"error": error,
|
|
135
|
+
"latency_ms": outcome.latency_ms,
|
|
136
|
+
}
|
|
137
|
+
results.append(result)
|
|
138
|
+
if on_case:
|
|
139
|
+
on_case(case, result)
|
|
140
|
+
|
|
141
|
+
observer.flush()
|
|
142
|
+
return client.submit_run(
|
|
143
|
+
{
|
|
144
|
+
"id": run_id,
|
|
145
|
+
"dataset": dataset,
|
|
146
|
+
"dataset_version": data["version"],
|
|
147
|
+
"evaluator_version": EVALUATOR_VERSION,
|
|
148
|
+
"label": label,
|
|
149
|
+
"replay_policy": policy.name if policy else "per-case",
|
|
150
|
+
"prompt_version_id": prompt_version["id"] if prompt_version else None,
|
|
151
|
+
"config": config or {},
|
|
152
|
+
"started_at": started.isoformat(),
|
|
153
|
+
"finished_at": datetime.now(timezone.utc).isoformat(),
|
|
154
|
+
"results": results,
|
|
155
|
+
}
|
|
156
|
+
)
|