Saccade: Tracing and observability library for AI agents
Project description
Saccade
A tracing and observability library for AI agents with built-in metrics.
Status: v0.1.0 | Tests: 204 passing
What is Saccade?
Saccade provides primitives for tracing agent execution, capturing metrics (tokens, cost, latency), and analyzing execution patterns through flexible projections.
Core Philosophy:
- Event-driven architecture with immutable events
- Zero-config observability via
Spancontext managers - Multiple views of the same trace (tree, graph, cost, state, timeline)
- Built-in metric tracking for tokens, cost, and latency
Installation
pip install saccade
Quick Start
Basic Tracing
from saccade import Trace, Span, project_tree
with Trace() as trace:
with Span("agent", kind="agent") as agent:
result = do_some_work()
agent.set_output(result)
tree = project_tree(trace.events)
print(f"Total tokens: {tree.total_tokens.input}")
Streaming with Metrics
from saccade import Trace, Span, TokenMetrics, CostMetrics, project_cost
with Trace() as trace:
with Span("llm_call", kind="llm") as llm:
# Simulate streaming
for chunk in ["Hello", " ", "world"]:
llm.stream(chunk)
llm.set_output("Hello world")
llm.set_metrics(
tokens=TokenMetrics(input=50, output=4),
cost=CostMetrics(usd=0.002)
)
cost_view = project_cost(trace.events)
print(f"Cost: ${cost_view.total_cost.usd}")
Nested Spans and Relations
from saccade import Trace, Span, project_tree
with Trace() as trace:
with Span("agent", kind="agent") as agent:
with Span("planning", kind="llm") as planning:
planning.set_output("Plan created")
with Span("tool_execution", kind="tool") as tool:
tool.set_output("Tool result")
tree = project_tree(trace.events)
# Traverse tree
for root in tree.roots:
print(f"Span: {root.name}")
for child in root.children:
print(f" └─ {child.name}")
Real-Time Event Streaming
from saccade import Trace, Span, EventType
with Trace() as trace:
# Subscribe to live events
def on_event(event):
if event.type == EventType.CHUNK:
print(f"[STREAMING] {event.chunk}")
elif event.type == EventType.ERROR:
print(f"[ERROR] {event.error}")
trace.subscribe(on_event)
with Span("llm", kind="llm") as llm:
for chunk in ["Hello", " world"]:
llm.stream(chunk)
Public API
Core Classes
| Class | Description |
|---|---|
Trace |
Entry point for tracing. Creates a TraceBus and manages context. |
Span |
Context manager for tracing operations. Emits events, tracks metrics. |
TraceBus |
Collects events and notifies subscribers. (Internal, accessible via saccade.primitives) |
Event Types
| Type | Description |
|---|---|
TraceEvent |
Immutable record of a state change in a span. |
EventType |
Enum of event types: START, CHUNK, OUTPUT, SUCCESS, ERROR, CANCEL |
Relation |
Enum of relation types: CONTEXT, DATAFLOW |
Metric Types
All metric types support addition (+) for aggregation:
| Type | Fields |
|---|---|
TokenMetrics |
input, output, reasoning, cached, cache_write |
CostMetrics |
usd (Decimal) |
LatencyMetrics |
total_ms, time_to_first_token_ms, has_clock_skew |
OperationMeta |
model, provider, host, kind, correlation_id |
Projectors
Transform events into different views:
| Function | Returns | Description |
|---|---|---|
project_tree(events) |
TreeView |
Hierarchical tree using "context" relations |
project_graph(events) |
GraphView |
Directed graph with all relations |
project_cost(events) |
CostView |
Aggregated cost and token metrics |
project_state(events, at_timestamp) |
StateView |
Snapshot of state at a specific time |
project_timeline(events) |
TimelineView |
Chronological view with temporal grouping |
Projector Usage Examples
Tree View
from saccade import Trace, Span, project_tree
with Trace() as trace:
with Span("parent") as p:
with Span("child1"):
pass
with Span("child2"):
pass
tree = project_tree(trace.events)
root = tree.roots[0]
print(f"Root: {root.name}")
print(f"Children: {[c.name for c in root.children]}")
print(f"Total tokens: {tree.total_tokens.input}")
print(f"Peak context: {tree.peak_context}")
Graph View
from saccade import Trace, Span, Relation, project_graph
with Trace() as trace:
with Span("a") as a:
pass
with Span("b") as b:
b.relate("dataflow", a.id)
graph = project_graph(trace.events)
# Find nodes by name
node_a = graph.find_by_name("a")
node_b = graph.find_by_name("b")
# Get edges by type
dataflow_edges = graph.edges_by_type(Relation.DATAFLOW)
Cost View
from saccade import Trace, Span, TokenMetrics, CostMetrics, project_cost
with Trace() as trace:
with Span("llm1") as s1:
s1.set_metrics(
tokens=TokenMetrics(input=100, output=20),
cost=CostMetrics(usd=0.01)
)
with Span("llm2") as s2:
s2.set_metrics(
tokens=TokenMetrics(input=50, output=10),
cost=CostMetrics(usd=0.005)
)
cost = project_cost(trace.events)
print(f"Total cost: ${cost.total_cost.usd}")
print(f"Total input tokens: {cost.total_tokens.input}")
print(f"Cost per 1k input: ${cost.cost_per_1k_input}")
Timeline View
from saccade import Trace, Span, project_timeline
with Trace() as trace:
with Span("a"):
pass
with Span("b"):
pass
timeline = project_timeline(trace.events)
# Group by time windows (e.g., 1 second)
for bucket in timeline.by_seconds(1.0):
print(f"Time {bucket.start_time}-{bucket.end_time}: {len(bucket.events)} events")
State View (Snapshot)
from saccade import Trace, Span, project_state
import time
with Trace() as trace:
with Span("a"):
pass
mid_point = time.time()
with Span("b"):
pass
# Snapshot at mid_point - only sees "a"
state = project_state(trace.events, at_timestamp=mid_point)
print(f"Active spans at snapshot: {[s.name for s in state.active_spans]}")
Advanced Usage
Custom Relations
from saccade import Trace, Span, project_graph
with Trace() as trace:
with Span("task_a") as a:
pass
with Span("task_b") as b:
# Declare dependency
b.relate("depends_on", a.id)
graph = project_graph(trace.events)
depends_edges = graph.edges_by_type("depends_on")
Span Kinds
Saccade provides constants for common span kinds:
from saccade import Span, SpanKind
with Span("agent", kind=SpanKind.AGENT):
pass
with Span("llm", kind=SpanKind.LLM):
pass
with Span("tool", kind=SpanKind.TOOL):
pass
Available kinds:
SpanKind.AGENT- Agent executionSpanKind.LLM- LLM generationSpanKind.TOOL- Tool executionSpanKind.EMBEDDING- Embedding generationSpanKind.RETRIEVAL- Retrieval operation
Error Handling
from saccade import Trace, Span, EventType
with Trace() as trace:
trace.subscribe(lambda e: print(f"{e.type}: {e.name}"))
try:
with Span("failing_span"):
raise ValueError("Something went wrong")
except ValueError:
pass
# Events captured:
# 1. START
# 2. ERROR (with error message)
# 3. Latency included on error event
Partial Results on Error
When a span fails, if set_output() was called before the error, the output is preserved:
with Span("partial_success") as s:
s.set_output("Partial result")
# More work that fails
raise ValueError("Failed")
# ERROR event is emitted, but OUTPUT event comes first
# Projection will show the partial output
Testing
# Run all tests
pytest tests/
# Run with coverage
pytest --cov=src --cov-report=html
# Run specific marker groups
pytest -m unit
pytest -m integration
pytest -m e2e
Documentation
- TYPES.md - Complete type specification
- DECISIONS.md - Architectural decisions
- DESIGN_VALIDATION.md - Design validation scenarios
Design Principles
- Event-First: State is derived purely from an append-only log of immutable
TraceEvents - Generic Relations: Spans declare relationships as
{type: [span_ids]}. The projector interprets them - Auto-Captured Context: The "context" relation is automatically added from execution stack
- Synchronous Emission: Events are emitted immediately to subscribers (no queue, no background tasks)
- User Manages Lifecycle: Memory and persistence are the user's responsibility
Limitations
| Limitation | Workaround |
|---|---|
| Single-process only | Use OpenTelemetry for distributed tracing |
| Not thread-safe | Use asyncio only |
| In-memory only | Export events periodically: [e.model_dump() for e in trace.events] |
| No built-in persistence | Serialize to JSON, save to database, etc. |
License
MIT
Contributing
See CONTRIBUTING.md for guidelines.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file saccade-0.1.0.tar.gz.
File metadata
- Download URL: saccade-0.1.0.tar.gz
- Upload date:
- Size: 16.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e8832e856b9eaa2f1554bc2982c2dc8774842d20701b7889ddaf4ce83b35c4bc
|
|
| MD5 |
bf60060405b2b8e71455fcd5b17e31d8
|
|
| BLAKE2b-256 |
7adba34b92d938cae5c1a116a0891afb3c5f361f526adbcc7e43c7bbd8dccbae
|
File details
Details for the file saccade-0.1.0-py3-none-any.whl.
File metadata
- Download URL: saccade-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9381998c889cb3f1ba57ea58a869b36309c2e581ae9e677e4e8840d586d8f924
|
|
| MD5 |
df95f92dff383b6a57281e99c8190da2
|
|
| BLAKE2b-256 |
5de005ce38a3d43e3a04ca102f587dfb568dc4f323bb3d36af60d2530fb4ea6e
|