Sponsored Content
Skip to content

Commit d331801

Browse files
Keniel Maldonadoclaude
authored andcommitted
Add Sentry observability to audit and eval pipelines
Breadcrumbs on capture-layer parse failures, manual AI spans for Ollama, per-case spans with scoring data in eval runner, malformed-case warnings. Zero-cost when SENTRY_DSN is unset. 38 tests pass, 1 xfailed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1af008d commit d331801

5 files changed

Lines changed: 191 additions & 81 deletions

File tree

β€Žagents/semantic_proposer.pyβ€Ž

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import urllib.request
77
from typing import Callable
88

9+
import sentry_sdk
10+
911
from agents.semantic_confirmer import ALLOWED_RELATION_TYPES
1012

1113

@@ -83,6 +85,16 @@ def _json_from_text(text: str) -> dict:
8385
try:
8486
parsed = json.loads(_strip_code_fence(text))
8587
except json.JSONDecodeError as error:
88+
sentry_sdk.add_breadcrumb(
89+
category="capture_layer",
90+
message="proposer output failed JSON parse",
91+
level="error",
92+
data={
93+
"raw_length": len(text),
94+
"raw_preview": text[:200],
95+
"starts_with_fence": text.strip().startswith("```"),
96+
},
97+
)
8698
raise SemanticProposerError("proposer returned non-json output") from error
8799
if isinstance(parsed, list):
88100
parsed = {"proposals": parsed}
@@ -96,6 +108,12 @@ def _normalize_proposal(raw: dict) -> dict:
96108
raise SemanticProposerError("proposal must be an object")
97109
missing = PROPOSER_SCHEMA_FIELDS - set(raw)
98110
if missing:
111+
sentry_sdk.add_breadcrumb(
112+
category="capture_layer",
113+
message=f"proposal missing required fields: {sorted(missing)}",
114+
level="warning",
115+
data={"proposal_keys": sorted(raw.keys())},
116+
)
99117
raise SemanticProposerError(f"proposal missing required field(s): {sorted(missing)}")
100118
relation_type = str(raw["type"]).strip()
101119
if relation_type not in ALLOWED_RELATION_TYPES:
@@ -186,33 +204,38 @@ def call_anthropic(prompt: str) -> str:
186204
def call_local_llama(prompt: str) -> str:
187205
# HTTP API instead of `ollama run` subprocess: the CLI path corrupted
188206
# captured output with ANSI/spinner bytes (2026-07-01 eval attempt #1).
189-
request = urllib.request.Request(
190-
OLLAMA_API_URL,
191-
data=json.dumps(
192-
{
193-
"model": OLLAMA_MODEL,
194-
"prompt": prompt,
195-
"stream": False,
196-
}
197-
).encode("utf-8"),
198-
headers={"content-type": "application/json"},
199-
method="POST",
200-
)
201-
try:
202-
with urllib.request.urlopen(request, timeout=120) as response:
203-
payload = json.loads(response.read().decode("utf-8"))
204-
except urllib.error.URLError as error:
205-
raise SemanticProposerError(
206-
f"local llama proposer call failed: network error: {str(error.reason)[:240]}"
207-
) from error
208-
except TimeoutError as error:
209-
raise SemanticProposerError("local llama proposer call failed: timeout") from error
210-
except json.JSONDecodeError as error:
211-
raise SemanticProposerError("local llama proposer call failed: invalid json response") from error
212-
text = str(payload.get("response", "")).strip()
213-
if not text:
214-
raise SemanticProposerError("local llama proposer returned no text")
215-
return text
207+
with sentry_sdk.start_span(op="ai.chat_completions.create", name="ollama.generate") as span:
208+
span.set_data("ai.model_id", OLLAMA_MODEL)
209+
span.set_data("ai.provider", "ollama")
210+
span.set_data("ai.input_messages", [{"role": "user", "content": prompt[:200] + "..."}])
211+
request = urllib.request.Request(
212+
OLLAMA_API_URL,
213+
data=json.dumps(
214+
{
215+
"model": OLLAMA_MODEL,
216+
"prompt": prompt,
217+
"stream": False,
218+
}
219+
).encode("utf-8"),
220+
headers={"content-type": "application/json"},
221+
method="POST",
222+
)
223+
try:
224+
with urllib.request.urlopen(request, timeout=120) as response:
225+
payload = json.loads(response.read().decode("utf-8"))
226+
except urllib.error.URLError as error:
227+
raise SemanticProposerError(
228+
f"local llama proposer call failed: network error: {str(error.reason)[:240]}"
229+
) from error
230+
except TimeoutError as error:
231+
raise SemanticProposerError("local llama proposer call failed: timeout") from error
232+
except json.JSONDecodeError as error:
233+
raise SemanticProposerError("local llama proposer call failed: invalid json response") from error
234+
text = str(payload.get("response", "")).strip()
235+
if not text:
236+
raise SemanticProposerError("local llama proposer returned no text")
237+
span.set_data("ai.response_chars", len(text))
238+
return text
216239

217240

218241
def propose_authority_changes(

β€Žaudit_cli.pyβ€Ž

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,40 @@
11
from __future__ import annotations
22

33
import json
4+
import os
45
import sys
56
from pathlib import Path
67

8+
import sentry_sdk
9+
710
from audit_pipeline import run_audit
811

912

13+
def _init_sentry() -> None:
14+
dsn = os.environ.get("SENTRY_DSN")
15+
if not dsn:
16+
return
17+
sentry_sdk.init(
18+
dsn=dsn,
19+
traces_sample_rate=1.0,
20+
send_default_pii=True,
21+
enable_logs=True,
22+
release=os.environ.get("SENTRY_RELEASE", "memory-authority-auditor@0.1.0"),
23+
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
24+
)
25+
26+
1027
def main() -> int:
1128
if len(sys.argv) != 2:
1229
print("Usage: python3 audit_cli.py <memory-file>", file=sys.stderr)
1330
return 2
1431

32+
_init_sentry()
33+
1534
path = Path(sys.argv[1])
16-
result = run_audit(path.read_text(encoding="utf-8"))
35+
with sentry_sdk.start_transaction(op="audit.run", name="audit pipeline") as txn:
36+
txn.set_data("input_file", str(path))
37+
result = run_audit(path.read_text(encoding="utf-8"))
1738
print(json.dumps(result, indent=2))
1839
return 0
1940

β€Žaudit_pipeline.pyβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import sentry_sdk
4+
35
from agents.memory_extractor import extract_memories
46
from agents.authority_classifier import classify_items
57
from agents.conflict_detector import detect_conflicts
@@ -8,6 +10,7 @@
810
from agents.authority_mapper import authority_map
911

1012

13+
@sentry_sdk.trace
1114
def run_audit(text: str) -> dict:
1215
items = extract_memories(text)
1316
item_dicts = [item.to_dict() for item in items]

β€Žpath_a_eval_runner.pyβ€Ž

Lines changed: 115 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
import argparse
44
import json
5+
import os
56
from datetime import datetime, timezone
67
from pathlib import Path
78
from typing import Callable
89

10+
import sentry_sdk
11+
912
from agents.memory_extractor import extract_memories
1013
from agents.semantic_confirmer import ALLOWED_RELATION_TYPES, CONFIDENCE_THRESHOLD, confirm_authority_changes
1114
from agents.semantic_proposer import (
@@ -233,51 +236,72 @@ def run_engine(engine: str, cases: list[dict]) -> dict:
233236
remove_citation_false_fires = 0
234237

235238
for case in cases:
236-
items = _items(case)
237-
prompt = build_authority_change_prompt(items)
238-
raw_output = ""
239-
malformed_reasons: list[str] = []
240-
try:
241-
raw_output = provider(prompt)
242-
proposals, malformed_reasons = _parse_case_output(raw_output)
243-
except Exception as error: # per-case malformed/degraded bucket, never abort the run
244-
proposals = []
245-
malformed_reasons = [f"{type(error).__name__}: {error}"]
246-
247-
if malformed_reasons:
248-
malformed_count += 1
249-
250-
confirmed = confirm_authority_changes(proposals, items)
251-
score = _score_confirmed(case, confirmed)
252-
no_confirmer = _score_remove_confirmer(case, proposals)
253-
no_citation = _score_confirmed(case, _confirm_without_citation(proposals, items))
254-
lexical = _lexical_result(case)
255-
256-
if score["expected_positive"]:
257-
positives += 1
258-
positives_caught += int(score["caught"])
259-
positives_caught_direction += int(score["direction_caught"])
260-
else:
261-
negatives += 1
262-
negatives_passed += int(score["negative_passed"])
263-
remove_confirmer_false_fires += int(no_confirmer["would_false_fire_without_confirmation"])
264-
remove_citation_false_fires += int(not no_citation["negative_passed"])
265-
266-
case_results.append({
267-
"case_id": case["id"],
268-
"class": case["class"],
269-
"malformed": bool(malformed_reasons),
270-
"malformed_reasons": malformed_reasons,
271-
"proposal_count": len(proposals),
272-
"proposals": proposals,
273-
"confirmed_findings": confirmed["findings"],
274-
"needs_human_judgment": confirmed["needs_human_judgment"],
275-
"score": score,
276-
"ablation_remove_confirmer": no_confirmer,
277-
"ablation_remove_citation_requirement": no_citation,
278-
"lexical_baseline_current_detector": lexical,
279-
"raw_output": raw_output,
280-
})
239+
with sentry_sdk.start_span(op="eval.case", name=f"case:{case['id']}") as case_span:
240+
case_span.set_data("case_id", case["id"])
241+
case_span.set_data("case_class", case["class"])
242+
case_span.set_data("engine", engine)
243+
items = _items(case)
244+
prompt = build_authority_change_prompt(items)
245+
raw_output = ""
246+
malformed_reasons: list[str] = []
247+
try:
248+
raw_output = provider(prompt)
249+
proposals, malformed_reasons = _parse_case_output(raw_output)
250+
except Exception as error: # per-case malformed/degraded bucket, never abort the run
251+
proposals = []
252+
malformed_reasons = [f"{type(error).__name__}: {error}"]
253+
sentry_sdk.capture_exception(error)
254+
255+
if malformed_reasons:
256+
malformed_count += 1
257+
case_span.set_data("malformed", True)
258+
case_span.set_data("malformed_reasons", malformed_reasons)
259+
sentry_sdk.add_breadcrumb(
260+
category="eval",
261+
message=f"proposer output malformed: {case['id']}",
262+
level="warning",
263+
data={
264+
"case_id": case["id"],
265+
"engine": engine,
266+
"reasons": malformed_reasons,
267+
},
268+
)
269+
270+
confirmed = confirm_authority_changes(proposals, items)
271+
score = _score_confirmed(case, confirmed)
272+
no_confirmer = _score_remove_confirmer(case, proposals)
273+
no_citation = _score_confirmed(case, _confirm_without_citation(proposals, items))
274+
lexical = _lexical_result(case)
275+
276+
if score["expected_positive"]:
277+
positives += 1
278+
positives_caught += int(score["caught"])
279+
positives_caught_direction += int(score["direction_caught"])
280+
else:
281+
negatives += 1
282+
negatives_passed += int(score["negative_passed"])
283+
remove_confirmer_false_fires += int(no_confirmer["would_false_fire_without_confirmation"])
284+
remove_citation_false_fires += int(not no_citation["negative_passed"])
285+
286+
case_span.set_data("score", score)
287+
case_span.set_data("proposal_count", len(proposals))
288+
case_span.set_data("confirmed_count", len(confirmed["findings"]))
289+
290+
case_results.append({
291+
"case_id": case["id"],
292+
"class": case["class"],
293+
"malformed": bool(malformed_reasons),
294+
"malformed_reasons": malformed_reasons,
295+
"proposal_count": len(proposals),
296+
"proposals": proposals,
297+
"confirmed_findings": confirmed["findings"],
298+
"needs_human_judgment": confirmed["needs_human_judgment"],
299+
"score": score,
300+
"ablation_remove_confirmer": no_confirmer,
301+
"ablation_remove_citation_requirement": no_citation,
302+
"lexical_baseline_current_detector": lexical,
303+
"raw_output": raw_output,
304+
})
281305

282306
return {
283307
"engine": engine,
@@ -347,27 +371,65 @@ def _write_markdown(result: dict, path: Path) -> None:
347371
path.write_text("\n".join(lines), encoding="utf-8")
348372

349373

374+
def _init_sentry() -> None:
375+
dsn = os.environ.get("SENTRY_DSN")
376+
if not dsn:
377+
return
378+
sentry_sdk.init(
379+
dsn=dsn,
380+
traces_sample_rate=1.0,
381+
send_default_pii=True,
382+
enable_logs=True,
383+
release=os.environ.get("SENTRY_RELEASE", "memory-authority-auditor@0.1.0"),
384+
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
385+
)
386+
387+
350388
def main() -> int:
351389
parser = argparse.ArgumentParser()
352390
parser.add_argument("--engines", default="anthropic,local_llama3.2")
353391
parser.add_argument("--output-dir", default=str(ARTIFACT_DIR))
354392
parser.add_argument("--fixture", default=str(FIXTURE))
355393
args = parser.parse_args()
356394

395+
_init_sentry()
396+
357397
run_id = _utc_slug()
358398
output_dir = Path(args.output_dir)
359399
output_dir.mkdir(parents=True, exist_ok=True)
360400
fixture_path = Path(args.fixture)
361401
cases = _cases(fixture_path)
362402
engines = [engine.strip() for engine in args.engines.split(",") if engine.strip()]
363-
result = {
364-
"run_id": run_id,
365-
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
366-
"fixture": str(fixture_path),
367-
"scoring_rules": SCORING_RULES,
368-
"engines": [run_engine(engine, cases) for engine in engines],
369-
"boundary": "No public claim from this artifact until Ka'el and Fable re-verify.",
370-
}
403+
404+
with sentry_sdk.start_transaction(op="eval.run", name="path_a_eval") as txn:
405+
txn.set_data("run_id", run_id)
406+
txn.set_data("fixture", str(fixture_path))
407+
txn.set_data("engines", engines)
408+
txn.set_data("case_count", len(cases))
409+
410+
engine_results = []
411+
for engine in engines:
412+
with sentry_sdk.start_span(op="eval.engine", name=f"engine:{engine}") as eng_span:
413+
eng_span.set_data("engine", engine)
414+
eng_result = run_engine(engine, cases)
415+
eng_span.set_data("summary", eng_result["summary"])
416+
malformed = eng_result["summary"]["malformed_cases"]
417+
if malformed > 0:
418+
sentry_sdk.capture_message(
419+
f"engine {engine} completed with {malformed}/{len(cases)} malformed cases",
420+
level="warning",
421+
)
422+
engine_results.append(eng_result)
423+
424+
result = {
425+
"run_id": run_id,
426+
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
427+
"fixture": str(fixture_path),
428+
"scoring_rules": SCORING_RULES,
429+
"engines": engine_results,
430+
"boundary": "No public claim from this artifact until Ka'el and Fable re-verify.",
431+
}
432+
371433
json_path = output_dir / f"path_a_eval_{run_id}.json"
372434
md_path = output_dir / f"path_a_eval_{run_id}.md"
373435
json_path.write_text(json.dumps(result, indent=2), encoding="utf-8")

β€Žrequirements.txtβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sentry-sdk[anthropic]>=2.0

0 commit comments

Comments
Β (0)