Skip to content

Commit a8fa717

Browse files
authored
Merge pull request #161 from SentienceAPI/p3
P3
2 parents e641858 + 57f6440 commit a8fa717

File tree

3 files changed

+139
-10
lines changed

3 files changed

+139
-10
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ await runtime.enable_failure_artifacts(
104104
await runtime.record_action("CLICK")
105105
```
106106

107+
### Redaction callback (Phase 3)
108+
109+
Provide a user-defined callback to redact snapshots and decide whether to persist frames. The SDK does not implement image/video redaction.
110+
111+
```python
112+
from sentience.failure_artifacts import FailureArtifactsOptions, RedactionContext, RedactionResult
113+
114+
def redact(ctx: RedactionContext) -> RedactionResult:
115+
# Example: drop frames entirely, keep JSON only.
116+
return RedactionResult(drop_frames=True)
117+
118+
await runtime.enable_failure_artifacts(
119+
FailureArtifactsOptions(on_before_persist=redact)
120+
)
121+
```
122+
107123
**See examples:** [`examples/asserts/`](examples/asserts/)
108124

109125
## 🚀 Quick Start: Choose Your Abstraction Level

sentience/failure_artifacts.py

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ class FailureArtifactsOptions:
1717
fps: float = 0.0
1818
persist_mode: Literal["onFail", "always"] = "onFail"
1919
output_dir: str = ".sentience/artifacts"
20+
on_before_persist: Callable[[RedactionContext], RedactionResult] | None = None
21+
redact_snapshot_values: bool = True
22+
23+
24+
@dataclass
25+
class RedactionContext:
26+
run_id: str
27+
reason: str | None
28+
status: Literal["failure", "success"]
29+
snapshot: Any | None
30+
diagnostics: Any | None
31+
frame_paths: list[str]
32+
metadata: dict[str, Any]
33+
34+
35+
@dataclass
36+
class RedactionResult:
37+
snapshot: Any | None = None
38+
diagnostics: Any | None = None
39+
frame_paths: list[str] | None = None
40+
drop_frames: bool = False
2041

2142

2243
@dataclass
@@ -99,6 +120,27 @@ def _write_json_atomic(self, path: Path, data: Any) -> None:
99120
tmp_path.write_text(json.dumps(data, indent=2))
100121
tmp_path.replace(path)
101122

123+
def _redact_snapshot_defaults(self, payload: Any) -> Any:
124+
if not isinstance(payload, dict):
125+
return payload
126+
elements = payload.get("elements")
127+
if not isinstance(elements, list):
128+
return payload
129+
redacted = []
130+
for el in elements:
131+
if not isinstance(el, dict):
132+
redacted.append(el)
133+
continue
134+
input_type = (el.get("input_type") or "").lower()
135+
if input_type in {"password", "email", "tel"} and "value" in el:
136+
el = dict(el)
137+
el["value"] = None
138+
el["value_redacted"] = True
139+
redacted.append(el)
140+
payload = dict(payload)
141+
payload["elements"] = redacted
142+
return payload
143+
102144
def persist(
103145
self,
104146
*,
@@ -118,25 +160,59 @@ def persist(
118160
frames_out = run_dir / "frames"
119161
frames_out.mkdir(parents=True, exist_ok=True)
120162

121-
for frame in self._frames:
122-
shutil.copy2(frame.path, frames_out / frame.file_name)
123-
124-
self._write_json_atomic(run_dir / "steps.json", self._steps)
125-
126163
snapshot_payload = None
127164
if snapshot is not None:
128165
if hasattr(snapshot, "model_dump"):
129166
snapshot_payload = snapshot.model_dump()
130167
else:
131168
snapshot_payload = snapshot
132-
self._write_json_atomic(run_dir / "snapshot.json", snapshot_payload)
169+
if self.options.redact_snapshot_values:
170+
snapshot_payload = self._redact_snapshot_defaults(snapshot_payload)
133171

134172
diagnostics_payload = None
135173
if diagnostics is not None:
136174
if hasattr(diagnostics, "model_dump"):
137175
diagnostics_payload = diagnostics.model_dump()
138176
else:
139177
diagnostics_payload = diagnostics
178+
179+
frame_paths = [str(frame.path) for frame in self._frames]
180+
drop_frames = False
181+
182+
if self.options.on_before_persist is not None:
183+
try:
184+
result = self.options.on_before_persist(
185+
RedactionContext(
186+
run_id=self.run_id,
187+
reason=reason,
188+
status=status,
189+
snapshot=snapshot_payload,
190+
diagnostics=diagnostics_payload,
191+
frame_paths=frame_paths,
192+
metadata=metadata or {},
193+
)
194+
)
195+
if result.snapshot is not None:
196+
snapshot_payload = result.snapshot
197+
if result.diagnostics is not None:
198+
diagnostics_payload = result.diagnostics
199+
if result.frame_paths is not None:
200+
frame_paths = result.frame_paths
201+
drop_frames = result.drop_frames
202+
except Exception:
203+
drop_frames = True
204+
205+
if not drop_frames:
206+
for frame_path in frame_paths:
207+
src = Path(frame_path)
208+
if not src.exists():
209+
continue
210+
shutil.copy2(src, frames_out / src.name)
211+
212+
self._write_json_atomic(run_dir / "steps.json", self._steps)
213+
if snapshot_payload is not None:
214+
self._write_json_atomic(run_dir / "snapshot.json", snapshot_payload)
215+
if diagnostics_payload is not None:
140216
self._write_json_atomic(run_dir / "diagnostics.json", diagnostics_payload)
141217

142218
manifest = {
@@ -145,11 +221,15 @@ def persist(
145221
"status": status,
146222
"reason": reason,
147223
"buffer_seconds": self.options.buffer_seconds,
148-
"frame_count": len(self._frames),
149-
"frames": [{"file": frame.file_name, "ts": frame.ts} for frame in self._frames],
224+
"frame_count": 0 if drop_frames else len(frame_paths),
225+
"frames": (
226+
[] if drop_frames else [{"file": Path(p).name, "ts": None} for p in frame_paths]
227+
),
150228
"snapshot": "snapshot.json" if snapshot_payload is not None else None,
151229
"diagnostics": "diagnostics.json" if diagnostics_payload is not None else None,
152230
"metadata": metadata or {},
231+
"frames_redacted": not drop_frames and self.options.on_before_persist is not None,
232+
"frames_dropped": drop_frames,
153233
}
154234
self._write_json_atomic(run_dir / "manifest.json", manifest)
155235

tests/unit/test_failure_artifacts.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import json
44

5-
from sentience.failure_artifacts import FailureArtifactBuffer, FailureArtifactsOptions
5+
from sentience.failure_artifacts import (
6+
FailureArtifactBuffer,
7+
FailureArtifactsOptions,
8+
RedactionContext,
9+
RedactionResult,
10+
)
611

712

813
def test_buffer_prunes_by_time(tmp_path) -> None:
@@ -34,7 +39,14 @@ def time_fn() -> float:
3439
buf.record_step(action="CLICK", step_id="s1", step_index=1, url="https://example.com")
3540
buf.add_frame(b"frame")
3641

37-
snapshot = {"status": "success", "url": "https://example.com", "elements": []}
42+
snapshot = {
43+
"status": "success",
44+
"url": "https://example.com",
45+
"elements": [
46+
{"id": 1, "input_type": "password", "value": "secret"},
47+
{"id": 2, "input_type": "email", "value": "user@example.com"},
48+
],
49+
}
3850
diagnostics = {"confidence": 0.9, "reasons": ["ok"], "metrics": {"quiet_ms": 42}}
3951
run_dir = buf.persist(
4052
reason="assert_failed",
@@ -57,3 +69,24 @@ def time_fn() -> float:
5769
assert len(steps) == 1
5870
assert snap_json["url"] == "https://example.com"
5971
assert diag_json["confidence"] == 0.9
72+
assert snap_json["elements"][0]["value"] is None
73+
assert snap_json["elements"][0]["value_redacted"] is True
74+
assert snap_json["elements"][1]["value"] is None
75+
assert snap_json["elements"][1]["value_redacted"] is True
76+
77+
78+
def test_redaction_callback_can_drop_frames(tmp_path) -> None:
79+
opts = FailureArtifactsOptions(output_dir=str(tmp_path))
80+
81+
def redactor(ctx: RedactionContext) -> RedactionResult:
82+
return RedactionResult(drop_frames=True)
83+
84+
opts.on_before_persist = redactor
85+
buf = FailureArtifactBuffer(run_id="run-3", options=opts)
86+
buf.add_frame(b"frame")
87+
88+
run_dir = buf.persist(reason="fail", status="failure", snapshot={"status": "success"})
89+
assert run_dir is not None
90+
manifest = json.loads((run_dir / "manifest.json").read_text())
91+
assert manifest["frame_count"] == 0
92+
assert manifest["frames_dropped"] is True

0 commit comments

Comments
 (0)