Skip to content

Commit 07eeeca

Browse files
authored
Merge pull request #206 from SentienceAPI/improve_tracing
improve tracing with automated upload if opt-in; auto step_start
2 parents aff1fdf + 3d870c2 commit 07eeeca

File tree

6 files changed

+443
-4
lines changed

6 files changed

+443
-4
lines changed

sentience/agent_runtime.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,13 +312,17 @@ async def get_url(self) -> str:
312312
self._cached_url = url
313313
return url
314314

315-
async def snapshot(self, **kwargs: Any) -> Snapshot:
315+
async def snapshot(self, emit_trace: bool = True, **kwargs: Any) -> Snapshot:
316316
"""
317317
Take a snapshot of the current page state.
318318
319319
This updates last_snapshot which is used as context for assertions.
320+
When emit_trace=True (default), automatically emits a 'snapshot' trace event
321+
with screenshot_base64 for Sentience Studio visualization.
320322
321323
Args:
324+
emit_trace: If True (default), emit a 'snapshot' trace event with screenshot.
325+
Set to False to disable automatic trace emission.
322326
**kwargs: Override default snapshot options for this call.
323327
Common options:
324328
- limit: Maximum elements to return
@@ -328,6 +332,15 @@ async def snapshot(self, **kwargs: Any) -> Snapshot:
328332
329333
Returns:
330334
Snapshot of current page state
335+
336+
Example:
337+
>>> # Default: snapshot with auto-emit trace event
338+
>>> snapshot = await runtime.snapshot()
339+
340+
>>> # Disable auto-emit for manual control
341+
>>> snapshot = await runtime.snapshot(emit_trace=False)
342+
>>> # Later, manually emit if needed:
343+
>>> tracer.emit_snapshot(snapshot, step_id=runtime.step_id)
331344
"""
332345
# Check if using legacy browser (backward compat)
333346
if hasattr(self, "_legacy_browser") and hasattr(self, "_legacy_page"):
@@ -337,6 +350,9 @@ async def snapshot(self, **kwargs: Any) -> Snapshot:
337350
if self._step_pre_snapshot is None:
338351
self._step_pre_snapshot = self.last_snapshot
339352
self._step_pre_url = self.last_snapshot.url
353+
# Auto-emit trace for legacy path too
354+
if emit_trace and self.last_snapshot is not None:
355+
self._emit_snapshot_trace(self.last_snapshot)
340356
return self.last_snapshot
341357

342358
# Use backend-agnostic snapshot
@@ -356,8 +372,33 @@ async def snapshot(self, **kwargs: Any) -> Snapshot:
356372
self._step_pre_url = self.last_snapshot.url
357373
if not skip_captcha_handling:
358374
await self._handle_captcha_if_needed(self.last_snapshot, source="gateway")
375+
376+
# Auto-emit snapshot trace event for Studio visualization
377+
if emit_trace and self.last_snapshot is not None:
378+
self._emit_snapshot_trace(self.last_snapshot)
379+
359380
return self.last_snapshot
360381

382+
def _emit_snapshot_trace(self, snapshot: Snapshot) -> None:
383+
"""
384+
Emit a snapshot trace event with screenshot for Studio visualization.
385+
386+
This is called automatically by snapshot() when emit_trace=True.
387+
"""
388+
if self.tracer is None:
389+
return
390+
391+
try:
392+
self.tracer.emit_snapshot(
393+
snapshot=snapshot,
394+
step_id=self.step_id,
395+
step_index=self.step_index,
396+
screenshot_format="jpeg",
397+
)
398+
except Exception:
399+
# Best-effort: don't let trace emission errors break snapshot
400+
pass
401+
361402
async def sampled_snapshot(
362403
self,
363404
*,
@@ -903,18 +944,27 @@ def _artifact_metadata(self) -> dict[str, Any]:
903944
"url": url,
904945
}
905946

906-
def begin_step(self, goal: str, step_index: int | None = None) -> str:
947+
def begin_step(
948+
self,
949+
goal: str,
950+
step_index: int | None = None,
951+
emit_trace: bool = True,
952+
pre_url: str | None = None,
953+
) -> str:
907954
"""
908955
Begin a new step in the verification loop.
909956
910957
This:
911958
- Generates a new step_id
912959
- Clears assertions from previous step
913960
- Increments step_index (or uses provided value)
961+
- Emits step_start trace event (optional)
914962
915963
Args:
916964
goal: Description of what this step aims to achieve
917965
step_index: Optional explicit step index (otherwise auto-increments)
966+
emit_trace: If True (default), emit step_start trace event for Studio timeline
967+
pre_url: Optional URL to record in step_start event (otherwise uses cached URL)
918968
919969
Returns:
920970
Generated step_id in format 'step-N' where N is the step index
@@ -939,6 +989,20 @@ def begin_step(self, goal: str, step_index: int | None = None) -> str:
939989
# Generate step_id in 'step-N' format for Studio compatibility
940990
self.step_id = f"step-{self.step_index}"
941991

992+
# Emit step_start trace event for Studio timeline display
993+
if emit_trace and self.tracer:
994+
try:
995+
url = pre_url or self._cached_url or ""
996+
self.tracer.emit_step_start(
997+
step_id=self.step_id,
998+
step_index=self.step_index,
999+
goal=goal,
1000+
attempt=0,
1001+
pre_url=url,
1002+
)
1003+
except Exception:
1004+
pass # Tracing must be non-fatal
1005+
9421006
return self.step_id
9431007

9441008
def assert_(

sentience/tracer_factory.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,32 @@
1818
from sentience.tracing import JsonlTraceSink, Tracer
1919

2020

21+
def _emit_run_start(
22+
tracer: Tracer,
23+
agent_type: str | None,
24+
llm_model: str | None,
25+
goal: str | None,
26+
start_url: str | None,
27+
) -> None:
28+
"""
29+
Helper to emit run_start event with available metadata.
30+
"""
31+
try:
32+
config: dict[str, Any] = {}
33+
if goal:
34+
config["goal"] = goal
35+
if start_url:
36+
config["start_url"] = start_url
37+
38+
tracer.emit_run_start(
39+
agent=agent_type or "SentienceAgent",
40+
llm_model=llm_model,
41+
config=config if config else None,
42+
)
43+
except Exception:
44+
pass # Tracing must be non-fatal
45+
46+
2147
def create_tracer(
2248
api_key: str | None = None,
2349
run_id: str | None = None,
@@ -29,6 +55,7 @@ def create_tracer(
2955
llm_model: str | None = None,
3056
start_url: str | None = None,
3157
screenshot_processor: Callable[[str], str] | None = None,
58+
auto_emit_run_start: bool = True,
3259
) -> Tracer:
3360
"""
3461
Create tracer with automatic tier detection.
@@ -56,6 +83,9 @@ def create_tracer(
5683
screenshot_processor: Optional function to process screenshots before upload.
5784
Takes base64 string, returns processed base64 string.
5885
Useful for PII redaction or custom image processing.
86+
auto_emit_run_start: If True (default), automatically emit run_start event
87+
with the provided metadata. This ensures traces have
88+
complete structure for Studio visualization.
5989
6090
Returns:
6191
Tracer configured with appropriate sink
@@ -71,6 +101,7 @@ def create_tracer(
71101
... start_url="https://amazon.com"
72102
... )
73103
>>> # Returns: Tracer with CloudTraceSink
104+
>>> # run_start event is automatically emitted
74105
>>>
75106
>>> # With screenshot processor for PII redaction
76107
>>> def redact_pii(screenshot_base64: str) -> str:
@@ -87,6 +118,10 @@ def create_tracer(
87118
>>> tracer = create_tracer(run_id="demo")
88119
>>> # Returns: Tracer with JsonlTraceSink (local-only)
89120
>>>
121+
>>> # Disable auto-emit for manual control
122+
>>> tracer = create_tracer(run_id="demo", auto_emit_run_start=False)
123+
>>> tracer.emit_run_start("MyAgent", "gpt-4o") # Manual emit
124+
>>>
90125
>>> # Use with agent
91126
>>> agent = SentienceAgent(browser, llm, tracer=tracer)
92127
>>> agent.act("Click search")
@@ -136,7 +171,7 @@ def create_tracer(
136171

137172
if upload_url:
138173
print("☁️ [Sentience] Cloud tracing enabled (Pro tier)")
139-
return Tracer(
174+
tracer = Tracer(
140175
run_id=run_id,
141176
sink=CloudTraceSink(
142177
upload_url=upload_url,
@@ -147,6 +182,10 @@ def create_tracer(
147182
),
148183
screenshot_processor=screenshot_processor,
149184
)
185+
# Auto-emit run_start for complete trace structure
186+
if auto_emit_run_start:
187+
_emit_run_start(tracer, agent_type, llm_model, goal, start_url)
188+
return tracer
150189
else:
151190
print("⚠️ [Sentience] Cloud init response missing upload_url")
152191
print(f" Response data: {data}")
@@ -204,12 +243,18 @@ def create_tracer(
204243
local_path = traces_dir / f"{run_id}.jsonl"
205244
print(f"💾 [Sentience] Local tracing: {local_path}")
206245

207-
return Tracer(
246+
tracer = Tracer(
208247
run_id=run_id,
209248
sink=JsonlTraceSink(str(local_path)),
210249
screenshot_processor=screenshot_processor,
211250
)
212251

252+
# Auto-emit run_start for complete trace structure
253+
if auto_emit_run_start:
254+
_emit_run_start(tracer, agent_type, llm_model, goal, start_url)
255+
256+
return tracer
257+
213258

214259
def _recover_orphaned_traces(api_key: str, api_url: str = SENTIENCE_API_URL) -> None:
215260
"""

sentience/tracing.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,68 @@ def emit_error(
358358
}
359359
self.emit("error", data, step_id=step_id)
360360

361+
def emit_snapshot(
362+
self,
363+
snapshot: Any,
364+
step_id: str | None = None,
365+
step_index: int | None = None,
366+
screenshot_format: str = "jpeg",
367+
) -> None:
368+
"""
369+
Emit snapshot event with screenshot for Studio visualization.
370+
371+
This method builds and emits a 'snapshot' trace event that includes:
372+
- Page URL and element data
373+
- Screenshot (if present in snapshot)
374+
- Step correlation info
375+
376+
Use this when you want screenshots to appear in the Sentience Studio timeline.
377+
378+
Args:
379+
snapshot: Snapshot object (must have 'screenshot' attribute for images)
380+
step_id: Step UUID (for correlating snapshot with a step)
381+
step_index: Step index (0-based) for Studio timeline ordering
382+
screenshot_format: Format of screenshot ("jpeg" or "png", default: "jpeg")
383+
384+
Example:
385+
>>> # After taking a snapshot with AgentRuntime
386+
>>> snapshot = await runtime.snapshot(screenshot=True)
387+
>>> tracer.emit_snapshot(snapshot, step_id=runtime.step_id, step_index=runtime.step_index)
388+
389+
>>> # Or use auto-emit (default in AgentRuntime.snapshot())
390+
>>> snapshot = await runtime.snapshot() # Auto-emits snapshot event
391+
"""
392+
if snapshot is None:
393+
return
394+
395+
try:
396+
# Import TraceEventBuilder here to avoid circular imports
397+
from .trace_event_builder import TraceEventBuilder
398+
399+
# Build the snapshot event data
400+
data = TraceEventBuilder.build_snapshot_event(snapshot, step_index=step_index)
401+
402+
# Extract and add screenshot if present
403+
screenshot_raw = getattr(snapshot, "screenshot", None)
404+
if screenshot_raw:
405+
# Extract base64 string from data URL if needed
406+
# Format: "data:image/jpeg;base64,{base64_string}"
407+
if isinstance(screenshot_raw, str) and screenshot_raw.startswith("data:image"):
408+
screenshot_base64 = (
409+
screenshot_raw.split(",", 1)[1]
410+
if "," in screenshot_raw
411+
else screenshot_raw
412+
)
413+
else:
414+
screenshot_base64 = screenshot_raw
415+
data["screenshot_base64"] = screenshot_base64
416+
data["screenshot_format"] = screenshot_format
417+
418+
self.emit("snapshot", data=data, step_id=step_id)
419+
except Exception:
420+
# Best-effort: don't let trace emission errors break the caller
421+
pass
422+
361423
def set_final_status(self, status: str) -> None:
362424
"""
363425
Set the final status of the trace run.

tests/test_agent_runtime.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ class MockTracer:
7575

7676
def __init__(self) -> None:
7777
self.events: list[dict] = []
78+
self.emit_step_start_called: bool = False
79+
self.emit_step_start_args: dict = {}
7880

7981
def emit(self, event_type: str, data: dict, step_id: str | None = None) -> None:
8082
self.events.append(
@@ -85,6 +87,23 @@ def emit(self, event_type: str, data: dict, step_id: str | None = None) -> None:
8587
}
8688
)
8789

90+
def emit_step_start(
91+
self,
92+
step_id: str,
93+
step_index: int,
94+
goal: str,
95+
attempt: int = 0,
96+
pre_url: str | None = None,
97+
) -> None:
98+
self.emit_step_start_called = True
99+
self.emit_step_start_args = {
100+
"step_id": step_id,
101+
"step_index": step_index,
102+
"goal": goal,
103+
"attempt": attempt,
104+
"pre_url": pre_url,
105+
}
106+
88107

89108
class TestAgentRuntimeInit:
90109
"""Tests for AgentRuntime initialization."""
@@ -277,6 +296,31 @@ def test_begin_step_clears_assertions(self) -> None:
277296

278297
assert runtime._assertions_this_step == []
279298

299+
def test_begin_step_emits_step_start_event(self) -> None:
300+
"""Test begin_step emits step_start trace event by default."""
301+
backend = MockBackend()
302+
tracer = MockTracer()
303+
runtime = AgentRuntime(backend=backend, tracer=tracer)
304+
305+
runtime.begin_step(goal="Test step", step_index=1)
306+
307+
# Check that emit_step_start was called
308+
assert tracer.emit_step_start_called is True
309+
assert tracer.emit_step_start_args["step_id"] == "step-1"
310+
assert tracer.emit_step_start_args["step_index"] == 1
311+
assert tracer.emit_step_start_args["goal"] == "Test step"
312+
313+
def test_begin_step_emit_trace_false(self) -> None:
314+
"""Test begin_step with emit_trace=False skips trace event."""
315+
backend = MockBackend()
316+
tracer = MockTracer()
317+
runtime = AgentRuntime(backend=backend, tracer=tracer)
318+
319+
runtime.begin_step(goal="Test step", step_index=1, emit_trace=False)
320+
321+
# Check that emit_step_start was NOT called
322+
assert tracer.emit_step_start_called is False
323+
280324

281325
class TestAgentRuntimeAssertions:
282326
"""Tests for assertion methods."""

0 commit comments

Comments
 (0)