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.

@@ -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
+
@@ -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.
@@ -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