saccade 0.1.0__tar.gz
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.
- saccade-0.1.0/PKG-INFO +382 -0
- saccade-0.1.0/README.md +373 -0
- saccade-0.1.0/pyproject.toml +87 -0
- saccade-0.1.0/setup.cfg +4 -0
- saccade-0.1.0/src/saccade/__init__.py +38 -0
- saccade-0.1.0/src/saccade/primitives/__init__.py +39 -0
- saccade-0.1.0/src/saccade/primitives/bus.py +44 -0
- saccade-0.1.0/src/saccade/primitives/events.py +113 -0
- saccade-0.1.0/src/saccade/primitives/projectors.py +731 -0
- saccade-0.1.0/src/saccade/primitives/span.py +230 -0
- saccade-0.1.0/src/saccade/primitives/trace.py +48 -0
- saccade-0.1.0/src/saccade.egg-info/PKG-INFO +382 -0
- saccade-0.1.0/src/saccade.egg-info/SOURCES.txt +14 -0
- saccade-0.1.0/src/saccade.egg-info/dependency_links.txt +1 -0
- saccade-0.1.0/src/saccade.egg-info/requires.txt +2 -0
- saccade-0.1.0/src/saccade.egg-info/top_level.txt +1 -0
saccade-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: saccade
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Saccade: Tracing and observability library for AI agents
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pydantic>=2.4
|
|
8
|
+
Requires-Dist: python-ulid>=2.4
|
|
9
|
+
|
|
10
|
+
# Saccade
|
|
11
|
+
|
|
12
|
+
A tracing and observability library for AI agents with built-in metrics.
|
|
13
|
+
|
|
14
|
+
**Status:** v0.1.0 | **Tests:** 204 passing
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## What is Saccade?
|
|
19
|
+
|
|
20
|
+
Saccade provides primitives for tracing agent execution, capturing metrics (tokens, cost, latency), and analyzing execution patterns through flexible projections.
|
|
21
|
+
|
|
22
|
+
**Core Philosophy:**
|
|
23
|
+
- Event-driven architecture with immutable events
|
|
24
|
+
- Zero-config observability via `Span` context managers
|
|
25
|
+
- Multiple views of the same trace (tree, graph, cost, state, timeline)
|
|
26
|
+
- Built-in metric tracking for tokens, cost, and latency
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install saccade
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### Basic Tracing
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from saccade import Trace, Span, project_tree
|
|
40
|
+
|
|
41
|
+
with Trace() as trace:
|
|
42
|
+
with Span("agent", kind="agent") as agent:
|
|
43
|
+
result = do_some_work()
|
|
44
|
+
agent.set_output(result)
|
|
45
|
+
|
|
46
|
+
tree = project_tree(trace.events)
|
|
47
|
+
print(f"Total tokens: {tree.total_tokens.input}")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Streaming with Metrics
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from saccade import Trace, Span, TokenMetrics, CostMetrics, project_cost
|
|
54
|
+
|
|
55
|
+
with Trace() as trace:
|
|
56
|
+
with Span("llm_call", kind="llm") as llm:
|
|
57
|
+
# Simulate streaming
|
|
58
|
+
for chunk in ["Hello", " ", "world"]:
|
|
59
|
+
llm.stream(chunk)
|
|
60
|
+
|
|
61
|
+
llm.set_output("Hello world")
|
|
62
|
+
llm.set_metrics(
|
|
63
|
+
tokens=TokenMetrics(input=50, output=4),
|
|
64
|
+
cost=CostMetrics(usd=0.002)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
cost_view = project_cost(trace.events)
|
|
68
|
+
print(f"Cost: ${cost_view.total_cost.usd}")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Nested Spans and Relations
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from saccade import Trace, Span, project_tree
|
|
75
|
+
|
|
76
|
+
with Trace() as trace:
|
|
77
|
+
with Span("agent", kind="agent") as agent:
|
|
78
|
+
with Span("planning", kind="llm") as planning:
|
|
79
|
+
planning.set_output("Plan created")
|
|
80
|
+
|
|
81
|
+
with Span("tool_execution", kind="tool") as tool:
|
|
82
|
+
tool.set_output("Tool result")
|
|
83
|
+
|
|
84
|
+
tree = project_tree(trace.events)
|
|
85
|
+
|
|
86
|
+
# Traverse tree
|
|
87
|
+
for root in tree.roots:
|
|
88
|
+
print(f"Span: {root.name}")
|
|
89
|
+
for child in root.children:
|
|
90
|
+
print(f" └─ {child.name}")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Real-Time Event Streaming
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from saccade import Trace, Span, EventType
|
|
97
|
+
|
|
98
|
+
with Trace() as trace:
|
|
99
|
+
# Subscribe to live events
|
|
100
|
+
def on_event(event):
|
|
101
|
+
if event.type == EventType.CHUNK:
|
|
102
|
+
print(f"[STREAMING] {event.chunk}")
|
|
103
|
+
elif event.type == EventType.ERROR:
|
|
104
|
+
print(f"[ERROR] {event.error}")
|
|
105
|
+
|
|
106
|
+
trace.subscribe(on_event)
|
|
107
|
+
|
|
108
|
+
with Span("llm", kind="llm") as llm:
|
|
109
|
+
for chunk in ["Hello", " world"]:
|
|
110
|
+
llm.stream(chunk)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Public API
|
|
114
|
+
|
|
115
|
+
### Core Classes
|
|
116
|
+
|
|
117
|
+
| Class | Description |
|
|
118
|
+
|-------|-------------|
|
|
119
|
+
| `Trace` | Entry point for tracing. Creates a `TraceBus` and manages context. |
|
|
120
|
+
| `Span` | Context manager for tracing operations. Emits events, tracks metrics. |
|
|
121
|
+
| `TraceBus` | Collects events and notifies subscribers. (Internal, accessible via `saccade.primitives`) |
|
|
122
|
+
|
|
123
|
+
### Event Types
|
|
124
|
+
|
|
125
|
+
| Type | Description |
|
|
126
|
+
|------|-------------|
|
|
127
|
+
| `TraceEvent` | Immutable record of a state change in a span. |
|
|
128
|
+
| `EventType` | Enum of event types: `START`, `CHUNK`, `OUTPUT`, `SUCCESS`, `ERROR`, `CANCEL` |
|
|
129
|
+
| `Relation` | Enum of relation types: `CONTEXT`, `DATAFLOW` |
|
|
130
|
+
|
|
131
|
+
### Metric Types
|
|
132
|
+
|
|
133
|
+
All metric types support addition (`+`) for aggregation:
|
|
134
|
+
|
|
135
|
+
| Type | Fields |
|
|
136
|
+
|------|--------|
|
|
137
|
+
| `TokenMetrics` | `input`, `output`, `reasoning`, `cached`, `cache_write` |
|
|
138
|
+
| `CostMetrics` | `usd` (Decimal) |
|
|
139
|
+
| `LatencyMetrics` | `total_ms`, `time_to_first_token_ms`, `has_clock_skew` |
|
|
140
|
+
| `OperationMeta` | `model`, `provider`, `host`, `kind`, `correlation_id` |
|
|
141
|
+
|
|
142
|
+
### Projectors
|
|
143
|
+
|
|
144
|
+
Transform events into different views:
|
|
145
|
+
|
|
146
|
+
| Function | Returns | Description |
|
|
147
|
+
|----------|----------|-------------|
|
|
148
|
+
| `project_tree(events)` | `TreeView` | Hierarchical tree using "context" relations |
|
|
149
|
+
| `project_graph(events)` | `GraphView` | Directed graph with all relations |
|
|
150
|
+
| `project_cost(events)` | `CostView` | Aggregated cost and token metrics |
|
|
151
|
+
| `project_state(events, at_timestamp)` | `StateView` | Snapshot of state at a specific time |
|
|
152
|
+
| `project_timeline(events)` | `TimelineView` | Chronological view with temporal grouping |
|
|
153
|
+
|
|
154
|
+
## Projector Usage Examples
|
|
155
|
+
|
|
156
|
+
### Tree View
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from saccade import Trace, Span, project_tree
|
|
160
|
+
|
|
161
|
+
with Trace() as trace:
|
|
162
|
+
with Span("parent") as p:
|
|
163
|
+
with Span("child1"):
|
|
164
|
+
pass
|
|
165
|
+
with Span("child2"):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
tree = project_tree(trace.events)
|
|
169
|
+
root = tree.roots[0]
|
|
170
|
+
|
|
171
|
+
print(f"Root: {root.name}")
|
|
172
|
+
print(f"Children: {[c.name for c in root.children]}")
|
|
173
|
+
print(f"Total tokens: {tree.total_tokens.input}")
|
|
174
|
+
print(f"Peak context: {tree.peak_context}")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Graph View
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from saccade import Trace, Span, Relation, project_graph
|
|
181
|
+
|
|
182
|
+
with Trace() as trace:
|
|
183
|
+
with Span("a") as a:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
with Span("b") as b:
|
|
187
|
+
b.relate("dataflow", a.id)
|
|
188
|
+
|
|
189
|
+
graph = project_graph(trace.events)
|
|
190
|
+
|
|
191
|
+
# Find nodes by name
|
|
192
|
+
node_a = graph.find_by_name("a")
|
|
193
|
+
node_b = graph.find_by_name("b")
|
|
194
|
+
|
|
195
|
+
# Get edges by type
|
|
196
|
+
dataflow_edges = graph.edges_by_type(Relation.DATAFLOW)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Cost View
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from saccade import Trace, Span, TokenMetrics, CostMetrics, project_cost
|
|
203
|
+
|
|
204
|
+
with Trace() as trace:
|
|
205
|
+
with Span("llm1") as s1:
|
|
206
|
+
s1.set_metrics(
|
|
207
|
+
tokens=TokenMetrics(input=100, output=20),
|
|
208
|
+
cost=CostMetrics(usd=0.01)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
with Span("llm2") as s2:
|
|
212
|
+
s2.set_metrics(
|
|
213
|
+
tokens=TokenMetrics(input=50, output=10),
|
|
214
|
+
cost=CostMetrics(usd=0.005)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
cost = project_cost(trace.events)
|
|
218
|
+
print(f"Total cost: ${cost.total_cost.usd}")
|
|
219
|
+
print(f"Total input tokens: {cost.total_tokens.input}")
|
|
220
|
+
print(f"Cost per 1k input: ${cost.cost_per_1k_input}")
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Timeline View
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from saccade import Trace, Span, project_timeline
|
|
227
|
+
|
|
228
|
+
with Trace() as trace:
|
|
229
|
+
with Span("a"):
|
|
230
|
+
pass
|
|
231
|
+
with Span("b"):
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
timeline = project_timeline(trace.events)
|
|
235
|
+
|
|
236
|
+
# Group by time windows (e.g., 1 second)
|
|
237
|
+
for bucket in timeline.by_seconds(1.0):
|
|
238
|
+
print(f"Time {bucket.start_time}-{bucket.end_time}: {len(bucket.events)} events")
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### State View (Snapshot)
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from saccade import Trace, Span, project_state
|
|
245
|
+
import time
|
|
246
|
+
|
|
247
|
+
with Trace() as trace:
|
|
248
|
+
with Span("a"):
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
mid_point = time.time()
|
|
252
|
+
|
|
253
|
+
with Span("b"):
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# Snapshot at mid_point - only sees "a"
|
|
257
|
+
state = project_state(trace.events, at_timestamp=mid_point)
|
|
258
|
+
print(f"Active spans at snapshot: {[s.name for s in state.active_spans]}")
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Advanced Usage
|
|
262
|
+
|
|
263
|
+
### Custom Relations
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from saccade import Trace, Span, project_graph
|
|
267
|
+
|
|
268
|
+
with Trace() as trace:
|
|
269
|
+
with Span("task_a") as a:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
with Span("task_b") as b:
|
|
273
|
+
# Declare dependency
|
|
274
|
+
b.relate("depends_on", a.id)
|
|
275
|
+
|
|
276
|
+
graph = project_graph(trace.events)
|
|
277
|
+
depends_edges = graph.edges_by_type("depends_on")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Span Kinds
|
|
281
|
+
|
|
282
|
+
Saccade provides constants for common span kinds:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
from saccade import Span, SpanKind
|
|
286
|
+
|
|
287
|
+
with Span("agent", kind=SpanKind.AGENT):
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
with Span("llm", kind=SpanKind.LLM):
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
with Span("tool", kind=SpanKind.TOOL):
|
|
294
|
+
pass
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Available kinds:
|
|
298
|
+
- `SpanKind.AGENT` - Agent execution
|
|
299
|
+
- `SpanKind.LLM` - LLM generation
|
|
300
|
+
- `SpanKind.TOOL` - Tool execution
|
|
301
|
+
- `SpanKind.EMBEDDING` - Embedding generation
|
|
302
|
+
- `SpanKind.RETRIEVAL` - Retrieval operation
|
|
303
|
+
|
|
304
|
+
### Error Handling
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from saccade import Trace, Span, EventType
|
|
308
|
+
|
|
309
|
+
with Trace() as trace:
|
|
310
|
+
trace.subscribe(lambda e: print(f"{e.type}: {e.name}"))
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
with Span("failing_span"):
|
|
314
|
+
raise ValueError("Something went wrong")
|
|
315
|
+
except ValueError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# Events captured:
|
|
319
|
+
# 1. START
|
|
320
|
+
# 2. ERROR (with error message)
|
|
321
|
+
# 3. Latency included on error event
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Partial Results on Error
|
|
325
|
+
|
|
326
|
+
When a span fails, if `set_output()` was called before the error, the output is preserved:
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
with Span("partial_success") as s:
|
|
330
|
+
s.set_output("Partial result")
|
|
331
|
+
# More work that fails
|
|
332
|
+
raise ValueError("Failed")
|
|
333
|
+
|
|
334
|
+
# ERROR event is emitted, but OUTPUT event comes first
|
|
335
|
+
# Projection will show the partial output
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Testing
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
# Run all tests
|
|
342
|
+
pytest tests/
|
|
343
|
+
|
|
344
|
+
# Run with coverage
|
|
345
|
+
pytest --cov=src --cov-report=html
|
|
346
|
+
|
|
347
|
+
# Run specific marker groups
|
|
348
|
+
pytest -m unit
|
|
349
|
+
pytest -m integration
|
|
350
|
+
pytest -m e2e
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Documentation
|
|
354
|
+
|
|
355
|
+
- **[TYPES.md](docs/TYPES.md)** - Complete type specification
|
|
356
|
+
- **[DECISIONS.md](docs/DECISIONS.md)** - Architectural decisions
|
|
357
|
+
- **[DESIGN_VALIDATION.md](docs/DESIGN_VALIDATION.md)** - Design validation scenarios
|
|
358
|
+
|
|
359
|
+
## Design Principles
|
|
360
|
+
|
|
361
|
+
1. **Event-First**: State is derived purely from an append-only log of immutable `TraceEvent`s
|
|
362
|
+
2. **Generic Relations**: Spans declare relationships as `{type: [span_ids]}`. The projector interprets them
|
|
363
|
+
3. **Auto-Captured Context**: The "context" relation is automatically added from execution stack
|
|
364
|
+
4. **Synchronous Emission**: Events are emitted immediately to subscribers (no queue, no background tasks)
|
|
365
|
+
5. **User Manages Lifecycle**: Memory and persistence are the user's responsibility
|
|
366
|
+
|
|
367
|
+
## Limitations
|
|
368
|
+
|
|
369
|
+
| Limitation | Workaround |
|
|
370
|
+
|------------|------------|
|
|
371
|
+
| Single-process only | Use OpenTelemetry for distributed tracing |
|
|
372
|
+
| Not thread-safe | Use asyncio only |
|
|
373
|
+
| In-memory only | Export events periodically: `[e.model_dump() for e in trace.events]` |
|
|
374
|
+
| No built-in persistence | Serialize to JSON, save to database, etc. |
|
|
375
|
+
|
|
376
|
+
## License
|
|
377
|
+
|
|
378
|
+
MIT
|
|
379
|
+
|
|
380
|
+
## Contributing
|
|
381
|
+
|
|
382
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|