spandrift 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.
Potentially problematic release.
This version of spandrift might be problematic. Click here for more details.
- spandrift-0.1.0/.github/workflows/ci.yml +48 -0
- spandrift-0.1.0/.github/workflows/publish.yml +67 -0
- spandrift-0.1.0/.gitignore +51 -0
- spandrift-0.1.0/LICENSE +21 -0
- spandrift-0.1.0/PKG-INFO +256 -0
- spandrift-0.1.0/README.md +216 -0
- spandrift-0.1.0/action.yml +137 -0
- spandrift-0.1.0/demo/base_trace.json +415 -0
- spandrift-0.1.0/demo/head_trace.json +637 -0
- spandrift-0.1.0/demo/run_demo.py +244 -0
- spandrift-0.1.0/pyproject.toml +75 -0
- spandrift-0.1.0/report.html +71 -0
- spandrift-0.1.0/src/spandrift/__init__.py +47 -0
- spandrift-0.1.0/src/spandrift/adapters.py +138 -0
- spandrift-0.1.0/src/spandrift/analysis.py +553 -0
- spandrift-0.1.0/src/spandrift/cli.py +128 -0
- spandrift-0.1.0/src/spandrift/cost_engine.py +337 -0
- spandrift-0.1.0/src/spandrift/diff.py +156 -0
- spandrift-0.1.0/src/spandrift/ingest.py +574 -0
- spandrift-0.1.0/src/spandrift/models.py +98 -0
- spandrift-0.1.0/src/spandrift/profiler.py +215 -0
- spandrift-0.1.0/src/spandrift/report.py +423 -0
- spandrift-0.1.0/src/spandrift/server.py +165 -0
- spandrift-0.1.0/tests/fixtures/smolagents_trace.json +183 -0
- spandrift-0.1.0/tests/test_adapters.py +50 -0
- spandrift-0.1.0/tests/test_analysis.py +432 -0
- spandrift-0.1.0/tests/test_cli.py +102 -0
- spandrift-0.1.0/tests/test_cost_engine.py +139 -0
- spandrift-0.1.0/tests/test_diff.py +71 -0
- spandrift-0.1.0/tests/test_ingest.py +246 -0
- spandrift-0.1.0/tests/test_profiler.py +305 -0
- spandrift-0.1.0/tests/test_server.py +64 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: Spandrift CI Gate
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
name: Run Unit Tests
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- name: Set up Python
|
|
14
|
+
uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.11"
|
|
17
|
+
- name: Install dependencies
|
|
18
|
+
run: |
|
|
19
|
+
python -m pip install --upgrade pip
|
|
20
|
+
pip install -e ".[dev]"
|
|
21
|
+
- name: Run pytest
|
|
22
|
+
run: pytest -v
|
|
23
|
+
|
|
24
|
+
trace-regression-gate:
|
|
25
|
+
name: Trace Regression Gate (spandrift diff)
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- name: Set up Python
|
|
30
|
+
uses: actions/setup-python@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.11"
|
|
33
|
+
- name: Install spandrift
|
|
34
|
+
run: |
|
|
35
|
+
python -m pip install --upgrade pip
|
|
36
|
+
pip install -e "."
|
|
37
|
+
- name: Run Benchmark / Generate Traces
|
|
38
|
+
run: |
|
|
39
|
+
# Generate base and head traces or download benchmark artifacts
|
|
40
|
+
python demo/run_demo.py
|
|
41
|
+
- name: Check Cost and Latency Regressions
|
|
42
|
+
run: |
|
|
43
|
+
spandrift diff \
|
|
44
|
+
demo/base_trace.json \
|
|
45
|
+
demo/head_trace.json \
|
|
46
|
+
--cost-threshold 0.10 \
|
|
47
|
+
--latency-threshold 0.20 \
|
|
48
|
+
--exit-code
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
name: Build, test, and package
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.11"
|
|
21
|
+
|
|
22
|
+
- name: Install build tools and test dependencies
|
|
23
|
+
run: |
|
|
24
|
+
python -m pip install --upgrade pip build
|
|
25
|
+
pip install -e ".[dev]"
|
|
26
|
+
|
|
27
|
+
- name: Run test suite
|
|
28
|
+
run: python -m pytest tests/ -v
|
|
29
|
+
|
|
30
|
+
- name: Build package
|
|
31
|
+
run: python -m build
|
|
32
|
+
|
|
33
|
+
- name: Verify wheel installs cleanly
|
|
34
|
+
run: |
|
|
35
|
+
python -m venv /tmp/verify-venv
|
|
36
|
+
/tmp/verify-venv/bin/pip install dist/spandrift-*.whl
|
|
37
|
+
/tmp/verify-venv/bin/spandrift --help
|
|
38
|
+
/tmp/verify-venv/bin/python -c "from spandrift import __version__; print(f'spandrift {__version__}')"
|
|
39
|
+
|
|
40
|
+
- uses: actions/upload-artifact@v4
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
|
|
45
|
+
publish:
|
|
46
|
+
name: Publish to PyPI
|
|
47
|
+
needs: build
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
permissions:
|
|
50
|
+
id-token: write # Required for PyPI Trusted Publishing (OIDC)
|
|
51
|
+
environment:
|
|
52
|
+
name: pypi
|
|
53
|
+
url: https://pypi.org/p/spandrift
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/download-artifact@v4
|
|
56
|
+
with:
|
|
57
|
+
name: dist
|
|
58
|
+
path: dist/
|
|
59
|
+
|
|
60
|
+
- name: Publish to PyPI
|
|
61
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
62
|
+
# Uses Trusted Publishing (OIDC) — no PYPI_API_TOKEN needed.
|
|
63
|
+
# Configure the trusted publisher on PyPI's side:
|
|
64
|
+
# Owner: arv20
|
|
65
|
+
# Repository: spandrift
|
|
66
|
+
# Workflow: publish.yml
|
|
67
|
+
# Environment: pypi
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
ENV/
|
|
27
|
+
env/
|
|
28
|
+
|
|
29
|
+
# Testing
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
.coverage
|
|
32
|
+
htmlcov/
|
|
33
|
+
|
|
34
|
+
# macOS
|
|
35
|
+
.DS_Store
|
|
36
|
+
|
|
37
|
+
# IDEs
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.swp
|
|
41
|
+
|
|
42
|
+
# Nested sub-repos and temporary state
|
|
43
|
+
/test/
|
|
44
|
+
/tech club/
|
|
45
|
+
/the team/
|
|
46
|
+
node_modules/
|
|
47
|
+
.freebuff/
|
|
48
|
+
*.db
|
|
49
|
+
*.db-shm
|
|
50
|
+
*.db-wal
|
|
51
|
+
|
spandrift-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aarav Kolgaonkar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
spandrift-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spandrift
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first trace analysis and diagnostic engine for multi-agent LLM systems.
|
|
5
|
+
Project-URL: Homepage, https://github.com/arv20/spandrift
|
|
6
|
+
Project-URL: Repository, https://github.com/arv20/spandrift
|
|
7
|
+
Project-URL: Issues, https://github.com/arv20/spandrift/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/arv20/spandrift#readme
|
|
9
|
+
Author-email: Aarav Kolgaonkar <arv20@users.noreply.github.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agents,diagnostics,langchain,langgraph,llm,multi-agent,observability,opentelemetry,smolagents,tracing
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: click>=8.0
|
|
26
|
+
Requires-Dist: polars>=1.0
|
|
27
|
+
Requires-Dist: rich>=13.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'dev'
|
|
30
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Provides-Extra: otel
|
|
34
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
|
|
35
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'otel'
|
|
36
|
+
Provides-Extra: smolagents
|
|
37
|
+
Requires-Dist: openinference-instrumentation-smolagents>=0.1; extra == 'smolagents'
|
|
38
|
+
Requires-Dist: smolagents>=1.0; extra == 'smolagents'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# spandrift
|
|
42
|
+
|
|
43
|
+
Local-first trace analysis for multi-agent LLM systems.
|
|
44
|
+
|
|
45
|
+
Basically, spandrift reads the OpenTelemetry spans your agents are probably already producing and tries to answer a few questions that normal tracing dashboards don't really focus on:
|
|
46
|
+
|
|
47
|
+
- which agent actually cost the money?
|
|
48
|
+
- did the same tool/agent accidentally get called twice?
|
|
49
|
+
- are retries going crazy?
|
|
50
|
+
- did this PR make the workflow slower or more expensive?
|
|
51
|
+
|
|
52
|
+
It's not meant to replace Langfuse, Phoenix, Logfire, etc. If you're already sending traces to one of those, keep doing that.
|
|
53
|
+
|
|
54
|
+
spandrift just reads the same OTel spans and runs a smaller set of checks that are more specific to agent workflows.
|
|
55
|
+
|
|
56
|
+
Right now it works with spans from `openinference-instrumentation-smolagents` for smolagents, Pydantic AI's built-in instrumentation, and its own decorators for code that isn't instrumented already.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install spandrift
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Python 3.11+ is required.
|
|
65
|
+
|
|
66
|
+
The concurrency-safe attribution stuff depends on `asyncio.TaskGroup` and the `context=` argument on `create_task`, which were both added in 3.11.
|
|
67
|
+
|
|
68
|
+
## Quickstart
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
spandrift analyze demo/trace.json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
╭──────────────────── Spandrift Analysis: head_trace.json ─────────────────────╮
|
|
76
|
+
│ Total spans: 15 Total cost: $0.0152 │
|
|
77
|
+
│ Wall-clock: 234ms Compute time: 775ms │
|
|
78
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
79
|
+
Per-Agent Rollup
|
|
80
|
+
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
|
|
81
|
+
┃ ┃ ┃ ┃ ┃ ┃ Tokens ┃
|
|
82
|
+
┃ Agent ┃ Cost ┃ Wall-clock ┃ Compute ┃ LLM calls ┃ (in/out) ┃
|
|
83
|
+
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
|
|
84
|
+
│ FactChecker │ $0.002075 │ 85ms │ 159ms │ 1 │ 350/120 │
|
|
85
|
+
│ Orchestrator │ $0.000000 │ 234ms │ 234ms │ 0 │ 0/0 │
|
|
86
|
+
│ ResearchAgent │ $0.006800 │ 149ms │ 255ms │ 2 │ 1.0k/420 │
|
|
87
|
+
│ WriterAgent │ $0.006325 │ 74ms │ 126ms │ 1 │ 850/420 │
|
|
88
|
+
└───────────────┴───────────┴────────────┴─────────┴───────────┴───────────────┘
|
|
89
|
+
|
|
90
|
+
Execution Waterfall & Concurrency
|
|
91
|
+
Span / Task Timeline (░=TTFT, █=Exec) Duration TTFT Cost
|
|
92
|
+
Orchestrator ████████████████████████████ 234ms — —
|
|
93
|
+
├─ ResearchAgent ████████ 74ms — —
|
|
94
|
+
│ ├─ chat gpt-4o ░░░██ 42ms 21ms $0.003400
|
|
95
|
+
│ └─ web_search █ 11ms — —
|
|
96
|
+
├─ FactChecker ██████████ 85ms — —
|
|
97
|
+
│ ├─ chat gpt-4o ░░██ 31ms 21ms $0.002075
|
|
98
|
+
│ ├─ web_search █ 11ms — —
|
|
99
|
+
│ ├─ web_search █ 10ms — —
|
|
100
|
+
│ ├─ web_search █ 11ms — —
|
|
101
|
+
│ └─ web_search ██ 11ms — —
|
|
102
|
+
├─ ResearchAgent █████████ 75ms — —
|
|
103
|
+
│ ├─ chat gpt-4o ░░░██ 42ms 21ms $0.003400
|
|
104
|
+
│ └─ web_search ██ 11ms — —
|
|
105
|
+
└─ WriterAgent ████████ 74ms — —
|
|
106
|
+
└─ chat gpt-4o ░░░░██ 52ms 31ms $0.006325
|
|
107
|
+
|
|
108
|
+
⚠ Duplicate Calls
|
|
109
|
+
ResearchAgent called 2× with identical input wasted: $0.003400
|
|
110
|
+
|
|
111
|
+
⚠ Retry/Loop Storms
|
|
112
|
+
web_search: 4 calls with identical input
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
You can also compare two runs:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
spandrift diff base.json head.json --cost-threshold 0.10 --exit-code
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
That makes it usable in CI too. If the cost delta crosses the threshold, the command can fail the step.
|
|
122
|
+
|
|
123
|
+
There's a composite GitHub Action in `action.yml` that wraps this. If you want it to post the diff as a PR comment, the calling workflow needs:
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
pull-requests: write
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
There's also:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
spandrift listen
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
which starts a local OTLP receiver so you don't have to write a trace file first. It binds to `127.0.0.1` by default.
|
|
136
|
+
|
|
137
|
+
## What it catches
|
|
138
|
+
|
|
139
|
+
### Cost + latency per agent
|
|
140
|
+
|
|
141
|
+
Costs and timing are rolled up per agent, including workflows with concurrent branches.
|
|
142
|
+
|
|
143
|
+
This matters because if two sub-agents run at the same time through `asyncio.gather`, you obviously don't want to just add both wall-clock durations together and pretend they ran sequentially.
|
|
144
|
+
|
|
145
|
+
### Duplicate calls
|
|
146
|
+
|
|
147
|
+
If the same agent or tool gets called twice with the same input in one run, it'll flag it.
|
|
148
|
+
|
|
149
|
+
This has caught more dumb orchestration bugs than I expected.
|
|
150
|
+
|
|
151
|
+
### Retry storms
|
|
152
|
+
|
|
153
|
+
It also looks for repeated calls with identical or near-identical input.
|
|
154
|
+
|
|
155
|
+
Those get chained using exact matches or token-level similarity on the captured input text, so something retrying over and over with tiny changes doesn't just disappear into the trace.
|
|
156
|
+
|
|
157
|
+
### TTFT outliers
|
|
158
|
+
|
|
159
|
+
TTFT = time to first token.
|
|
160
|
+
|
|
161
|
+
spandrift tracks that separately from total request duration and flags outliers per model. Mostly useful for figuring out whether the actual agent got slower or whether a provider/model was just having a bad time.
|
|
162
|
+
|
|
163
|
+
## How it works
|
|
164
|
+
|
|
165
|
+
For code that isn't already OTel-instrumented, there are two decorators:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
@profile_agent
|
|
169
|
+
@profile_tool
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
They wrap async functions and emit spans in the same general shape as the other supported instrumentation.
|
|
173
|
+
|
|
174
|
+
A surprisingly annoying part of building this was getting attribution right with concurrency.
|
|
175
|
+
|
|
176
|
+
A global "current span" works fine until two agents run at the same time. Then child spans can start getting attributed to whichever agent most recently changed the global value, which is obviously bad.
|
|
177
|
+
|
|
178
|
+
spandrift uses `contextvars.ContextVar` instead.
|
|
179
|
+
|
|
180
|
+
asyncio copies context when tasks are created, so each concurrent task keeps the right agent context without having to manually pass IDs everywhere.
|
|
181
|
+
|
|
182
|
+
There's a test specifically for this too: two agents execute concurrently and both produce child spans. I wanted a test that would actually break the naive implementation instead of just testing the easy sequential case.
|
|
183
|
+
|
|
184
|
+
The analysis side uses Polars.
|
|
185
|
+
|
|
186
|
+
Most of the work is grouped batch aggregation anyway — cost by agent, latency percentiles by model, retries, duplicates, etc. A run can also get into the thousands of spans pretty quickly once agents start retrying, so using a columnar dataframe library made sense.
|
|
187
|
+
|
|
188
|
+
## A bug I found while building it
|
|
189
|
+
|
|
190
|
+
The cost calculation ended up being slightly more annoying than expected because of cached tokens.
|
|
191
|
+
|
|
192
|
+
OpenTelemetry's:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
gen_ai.usage.input_tokens
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
is the total input token count, including cached input.
|
|
199
|
+
|
|
200
|
+
So if you take that value and then add cache-read/cache-write token counts separately, you're double-counting part of the bill.
|
|
201
|
+
|
|
202
|
+
I originally did exactly that.
|
|
203
|
+
|
|
204
|
+
I caught it by comparing spandrift's numbers against `genai-prices` on a few actual Anthropic/OpenAI caching scenarios. The fix is in `cost_engine.py` and there's a regression test for it now.
|
|
205
|
+
|
|
206
|
+
## Known trade-offs
|
|
207
|
+
|
|
208
|
+
Duplicate/retry detection needs the input text to actually exist in the span.
|
|
209
|
+
|
|
210
|
+
Usually that's either:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
input.value
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
or:
|
|
217
|
+
|
|
218
|
+
```text
|
|
219
|
+
gen_ai.input.messages
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The `@profile_agent` path always captures it, but bare LLM spans from really minimal OTel instrumentation sometimes don't.
|
|
223
|
+
|
|
224
|
+
If the input isn't there, spandrift doesn't try to guess. That span just isn't checked for duplicate/retry similarity.
|
|
225
|
+
|
|
226
|
+
Pricing is also a static table right now, not a live API lookup.
|
|
227
|
+
|
|
228
|
+
`cost_engine.py` includes the date the bundled prices were last verified. If something changes before I update it, you can override the table with:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
SPANDRIFT_PRICES_PATH
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
There's also a LangChain/LangGraph adapter under `spandrift.adapters`.
|
|
235
|
+
|
|
236
|
+
That part is experimental and definitely less tested than the core path. Also, if your entire stack is LangChain, LangSmith already does a lot, so I'm not trying to pretend this replaces it.
|
|
237
|
+
|
|
238
|
+
## Why I made this
|
|
239
|
+
|
|
240
|
+
I mostly wanted something I could run locally on traces after changing an agent workflow and immediately answer stuff like:
|
|
241
|
+
|
|
242
|
+
> why did this run suddenly cost 40% more?
|
|
243
|
+
|
|
244
|
+
or
|
|
245
|
+
|
|
246
|
+
> why did this tool execute 6 times?
|
|
247
|
+
|
|
248
|
+
General observability tools are way more capable overall, but sometimes I just want those answers without digging through a tracing UI.
|
|
249
|
+
|
|
250
|
+
That's basically the point of spandrift.
|
|
251
|
+
|
|
252
|
+
It's still a young project, so issues and PRs are very welcome. If something looks wrong, there's a non-zero chance it actually is.
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
MIT © Aarav Kolgaonkar
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# spandrift
|
|
2
|
+
|
|
3
|
+
Local-first trace analysis for multi-agent LLM systems.
|
|
4
|
+
|
|
5
|
+
Basically, spandrift reads the OpenTelemetry spans your agents are probably already producing and tries to answer a few questions that normal tracing dashboards don't really focus on:
|
|
6
|
+
|
|
7
|
+
- which agent actually cost the money?
|
|
8
|
+
- did the same tool/agent accidentally get called twice?
|
|
9
|
+
- are retries going crazy?
|
|
10
|
+
- did this PR make the workflow slower or more expensive?
|
|
11
|
+
|
|
12
|
+
It's not meant to replace Langfuse, Phoenix, Logfire, etc. If you're already sending traces to one of those, keep doing that.
|
|
13
|
+
|
|
14
|
+
spandrift just reads the same OTel spans and runs a smaller set of checks that are more specific to agent workflows.
|
|
15
|
+
|
|
16
|
+
Right now it works with spans from `openinference-instrumentation-smolagents` for smolagents, Pydantic AI's built-in instrumentation, and its own decorators for code that isn't instrumented already.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install spandrift
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Python 3.11+ is required.
|
|
25
|
+
|
|
26
|
+
The concurrency-safe attribution stuff depends on `asyncio.TaskGroup` and the `context=` argument on `create_task`, which were both added in 3.11.
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
spandrift analyze demo/trace.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
╭──────────────────── Spandrift Analysis: head_trace.json ─────────────────────╮
|
|
36
|
+
│ Total spans: 15 Total cost: $0.0152 │
|
|
37
|
+
│ Wall-clock: 234ms Compute time: 775ms │
|
|
38
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
39
|
+
Per-Agent Rollup
|
|
40
|
+
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
|
|
41
|
+
┃ ┃ ┃ ┃ ┃ ┃ Tokens ┃
|
|
42
|
+
┃ Agent ┃ Cost ┃ Wall-clock ┃ Compute ┃ LLM calls ┃ (in/out) ┃
|
|
43
|
+
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
|
|
44
|
+
│ FactChecker │ $0.002075 │ 85ms │ 159ms │ 1 │ 350/120 │
|
|
45
|
+
│ Orchestrator │ $0.000000 │ 234ms │ 234ms │ 0 │ 0/0 │
|
|
46
|
+
│ ResearchAgent │ $0.006800 │ 149ms │ 255ms │ 2 │ 1.0k/420 │
|
|
47
|
+
│ WriterAgent │ $0.006325 │ 74ms │ 126ms │ 1 │ 850/420 │
|
|
48
|
+
└───────────────┴───────────┴────────────┴─────────┴───────────┴───────────────┘
|
|
49
|
+
|
|
50
|
+
Execution Waterfall & Concurrency
|
|
51
|
+
Span / Task Timeline (░=TTFT, █=Exec) Duration TTFT Cost
|
|
52
|
+
Orchestrator ████████████████████████████ 234ms — —
|
|
53
|
+
├─ ResearchAgent ████████ 74ms — —
|
|
54
|
+
│ ├─ chat gpt-4o ░░░██ 42ms 21ms $0.003400
|
|
55
|
+
│ └─ web_search █ 11ms — —
|
|
56
|
+
├─ FactChecker ██████████ 85ms — —
|
|
57
|
+
│ ├─ chat gpt-4o ░░██ 31ms 21ms $0.002075
|
|
58
|
+
│ ├─ web_search █ 11ms — —
|
|
59
|
+
│ ├─ web_search █ 10ms — —
|
|
60
|
+
│ ├─ web_search █ 11ms — —
|
|
61
|
+
│ └─ web_search ██ 11ms — —
|
|
62
|
+
├─ ResearchAgent █████████ 75ms — —
|
|
63
|
+
│ ├─ chat gpt-4o ░░░██ 42ms 21ms $0.003400
|
|
64
|
+
│ └─ web_search ██ 11ms — —
|
|
65
|
+
└─ WriterAgent ████████ 74ms — —
|
|
66
|
+
└─ chat gpt-4o ░░░░██ 52ms 31ms $0.006325
|
|
67
|
+
|
|
68
|
+
⚠ Duplicate Calls
|
|
69
|
+
ResearchAgent called 2× with identical input wasted: $0.003400
|
|
70
|
+
|
|
71
|
+
⚠ Retry/Loop Storms
|
|
72
|
+
web_search: 4 calls with identical input
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You can also compare two runs:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
spandrift diff base.json head.json --cost-threshold 0.10 --exit-code
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That makes it usable in CI too. If the cost delta crosses the threshold, the command can fail the step.
|
|
82
|
+
|
|
83
|
+
There's a composite GitHub Action in `action.yml` that wraps this. If you want it to post the diff as a PR comment, the calling workflow needs:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
pull-requests: write
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
There's also:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
spandrift listen
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
which starts a local OTLP receiver so you don't have to write a trace file first. It binds to `127.0.0.1` by default.
|
|
96
|
+
|
|
97
|
+
## What it catches
|
|
98
|
+
|
|
99
|
+
### Cost + latency per agent
|
|
100
|
+
|
|
101
|
+
Costs and timing are rolled up per agent, including workflows with concurrent branches.
|
|
102
|
+
|
|
103
|
+
This matters because if two sub-agents run at the same time through `asyncio.gather`, you obviously don't want to just add both wall-clock durations together and pretend they ran sequentially.
|
|
104
|
+
|
|
105
|
+
### Duplicate calls
|
|
106
|
+
|
|
107
|
+
If the same agent or tool gets called twice with the same input in one run, it'll flag it.
|
|
108
|
+
|
|
109
|
+
This has caught more dumb orchestration bugs than I expected.
|
|
110
|
+
|
|
111
|
+
### Retry storms
|
|
112
|
+
|
|
113
|
+
It also looks for repeated calls with identical or near-identical input.
|
|
114
|
+
|
|
115
|
+
Those get chained using exact matches or token-level similarity on the captured input text, so something retrying over and over with tiny changes doesn't just disappear into the trace.
|
|
116
|
+
|
|
117
|
+
### TTFT outliers
|
|
118
|
+
|
|
119
|
+
TTFT = time to first token.
|
|
120
|
+
|
|
121
|
+
spandrift tracks that separately from total request duration and flags outliers per model. Mostly useful for figuring out whether the actual agent got slower or whether a provider/model was just having a bad time.
|
|
122
|
+
|
|
123
|
+
## How it works
|
|
124
|
+
|
|
125
|
+
For code that isn't already OTel-instrumented, there are two decorators:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
@profile_agent
|
|
129
|
+
@profile_tool
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
They wrap async functions and emit spans in the same general shape as the other supported instrumentation.
|
|
133
|
+
|
|
134
|
+
A surprisingly annoying part of building this was getting attribution right with concurrency.
|
|
135
|
+
|
|
136
|
+
A global "current span" works fine until two agents run at the same time. Then child spans can start getting attributed to whichever agent most recently changed the global value, which is obviously bad.
|
|
137
|
+
|
|
138
|
+
spandrift uses `contextvars.ContextVar` instead.
|
|
139
|
+
|
|
140
|
+
asyncio copies context when tasks are created, so each concurrent task keeps the right agent context without having to manually pass IDs everywhere.
|
|
141
|
+
|
|
142
|
+
There's a test specifically for this too: two agents execute concurrently and both produce child spans. I wanted a test that would actually break the naive implementation instead of just testing the easy sequential case.
|
|
143
|
+
|
|
144
|
+
The analysis side uses Polars.
|
|
145
|
+
|
|
146
|
+
Most of the work is grouped batch aggregation anyway — cost by agent, latency percentiles by model, retries, duplicates, etc. A run can also get into the thousands of spans pretty quickly once agents start retrying, so using a columnar dataframe library made sense.
|
|
147
|
+
|
|
148
|
+
## A bug I found while building it
|
|
149
|
+
|
|
150
|
+
The cost calculation ended up being slightly more annoying than expected because of cached tokens.
|
|
151
|
+
|
|
152
|
+
OpenTelemetry's:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
gen_ai.usage.input_tokens
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
is the total input token count, including cached input.
|
|
159
|
+
|
|
160
|
+
So if you take that value and then add cache-read/cache-write token counts separately, you're double-counting part of the bill.
|
|
161
|
+
|
|
162
|
+
I originally did exactly that.
|
|
163
|
+
|
|
164
|
+
I caught it by comparing spandrift's numbers against `genai-prices` on a few actual Anthropic/OpenAI caching scenarios. The fix is in `cost_engine.py` and there's a regression test for it now.
|
|
165
|
+
|
|
166
|
+
## Known trade-offs
|
|
167
|
+
|
|
168
|
+
Duplicate/retry detection needs the input text to actually exist in the span.
|
|
169
|
+
|
|
170
|
+
Usually that's either:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
input.value
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
or:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
gen_ai.input.messages
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The `@profile_agent` path always captures it, but bare LLM spans from really minimal OTel instrumentation sometimes don't.
|
|
183
|
+
|
|
184
|
+
If the input isn't there, spandrift doesn't try to guess. That span just isn't checked for duplicate/retry similarity.
|
|
185
|
+
|
|
186
|
+
Pricing is also a static table right now, not a live API lookup.
|
|
187
|
+
|
|
188
|
+
`cost_engine.py` includes the date the bundled prices were last verified. If something changes before I update it, you can override the table with:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
SPANDRIFT_PRICES_PATH
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
There's also a LangChain/LangGraph adapter under `spandrift.adapters`.
|
|
195
|
+
|
|
196
|
+
That part is experimental and definitely less tested than the core path. Also, if your entire stack is LangChain, LangSmith already does a lot, so I'm not trying to pretend this replaces it.
|
|
197
|
+
|
|
198
|
+
## Why I made this
|
|
199
|
+
|
|
200
|
+
I mostly wanted something I could run locally on traces after changing an agent workflow and immediately answer stuff like:
|
|
201
|
+
|
|
202
|
+
> why did this run suddenly cost 40% more?
|
|
203
|
+
|
|
204
|
+
or
|
|
205
|
+
|
|
206
|
+
> why did this tool execute 6 times?
|
|
207
|
+
|
|
208
|
+
General observability tools are way more capable overall, but sometimes I just want those answers without digging through a tracing UI.
|
|
209
|
+
|
|
210
|
+
That's basically the point of spandrift.
|
|
211
|
+
|
|
212
|
+
It's still a young project, so issues and PRs are very welcome. If something looks wrong, there's a non-zero chance it actually is.
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT © Aarav Kolgaonkar
|