Skip to main content

spandrift

Local-first trace analysis for multi-agent LLM systems.

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:

  • which agent actually cost the money?
  • did the same tool/agent accidentally get called twice?
  • are retries going crazy?
  • did this PR make the workflow slower or more expensive?

It's not meant to replace Langfuse, Phoenix, Logfire, etc. If you're already sending traces to one of those, keep doing that.

spandrift just reads the same OTel spans and runs a smaller set of checks that are more specific to agent workflows.

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.

Install

pip install spandrift

Python 3.11+ is required.

The concurrency-safe attribution stuff depends on asyncio.TaskGroup and the context= argument on create_task, which were both added in 3.11.

Quickstart

spandrift analyze demo/trace.json
╭──────────────────── Spandrift Analysis: head_trace.json ─────────────────────╮
│ Total spans: 15    Total cost: $0.0152                                       │
│ Wall-clock: 234ms   Compute time: 775ms                                      │
╰──────────────────────────────────────────────────────────────────────────────╯
                                Per-Agent Rollup
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃               ┃           ┃            ┃         ┃           ┃        Tokens ┃
┃ Agent         ┃      Cost ┃ Wall-clock ┃ Compute ┃ LLM calls ┃      (in/out) ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ FactChecker   │ $0.002075 │       85ms │   159ms │         1 │       350/120 │
│ Orchestrator  │ $0.000000 │      234ms │   234ms │         0 │           0/0 │
│ ResearchAgent │ $0.006800 │      149ms │   255ms │         2 │      1.0k/420 │
│ WriterAgent   │ $0.006325 │       74ms │   126ms │         1 │       850/420 │
└───────────────┴───────────┴────────────┴─────────┴───────────┴───────────────┘

                      Execution Waterfall & Concurrency
 Span / Task         Timeline (░=TTFT, █=Exec)     Duration  TTFT       Cost
 Orchestrator        ████████████████████████████     234ms     —          —
  ├─ ResearchAgent   ████████                          74ms     —          —
  │  ├─ chat gpt-4o    ░░░██                           42ms  21ms  $0.003400
  │  └─ web_search          █                          11ms     —          —
  ├─ FactChecker     ██████████                        85ms     —          —
  │  ├─ chat gpt-4o   ░░██                             31ms  21ms  $0.002075
  │  ├─ web_search        █                            11ms     —          —
  │  ├─ web_search         █                           10ms     —          —
  │  ├─ web_search          █                          11ms     —          —
  │  └─ web_search           ██                        11ms     —          —
  ├─ ResearchAgent             █████████               75ms     —          —
  │  ├─ chat gpt-4o              ░░░██                 42ms  21ms  $0.003400
  │  └─ web_search                    ██               11ms     —          —
  └─ WriterAgent                        ████████       74ms     —          —
     └─ chat gpt-4o                       ░░░░██       52ms  31ms  $0.006325

⚠ Duplicate Calls
  ResearchAgent called 2× with identical input  wasted: $0.003400

⚠ Retry/Loop Storms
  web_search: 4 calls with identical input

You can also compare two runs:

spandrift diff base.json head.json --cost-threshold 0.10 --exit-code

That makes it usable in CI too. If the cost delta crosses the threshold, the command can fail the step.

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:

pull-requests: write

There's also:

spandrift listen

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.

What it catches

Cost + latency per agent

Costs and timing are rolled up per agent, including workflows with concurrent branches.

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.

Duplicate calls

If the same agent or tool gets called twice with the same input in one run, it'll flag it.

This has caught more dumb orchestration bugs than I expected.

Retry storms

It also looks for repeated calls with identical or near-identical input.

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.

TTFT outliers

TTFT = time to first token.

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.

How it works

For code that isn't already OTel-instrumented, there are two decorators:

@profile_agent
@profile_tool

They wrap async functions and emit spans in the same general shape as the other supported instrumentation.

A surprisingly annoying part of building this was getting attribution right with concurrency.

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.

spandrift uses contextvars.ContextVar instead.

asyncio copies context when tasks are created, so each concurrent task keeps the right agent context without having to manually pass IDs everywhere.

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.

The analysis side uses Polars.

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.

A bug I found while building it

The cost calculation ended up being slightly more annoying than expected because of cached tokens.

OpenTelemetry's:

gen_ai.usage.input_tokens

is the total input token count, including cached input.

So if you take that value and then add cache-read/cache-write token counts separately, you're double-counting part of the bill.

I originally did exactly that.

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.

Known trade-offs

Duplicate/retry detection needs the input text to actually exist in the span.

Usually that's either:

input.value

or:

gen_ai.input.messages

The @profile_agent path always captures it, but bare LLM spans from really minimal OTel instrumentation sometimes don't.

If the input isn't there, spandrift doesn't try to guess. That span just isn't checked for duplicate/retry similarity.

Pricing is also a static table right now, not a live API lookup.

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:

SPANDRIFT_PRICES_PATH

There's also a LangChain/LangGraph adapter under spandrift.adapters.

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.

Why I made this

I mostly wanted something I could run locally on traces after changing an agent workflow and immediately answer stuff like:

why did this run suddenly cost 40% more?

or

why did this tool execute 6 times?

General observability tools are way more capable overall, but sometimes I just want those answers without digging through a tracing UI.

That's basically the point of spandrift.

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.

License

MIT © Aarav Kolgaonkar

Metadata

Release files for spandrift 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spandrift 0.1.0
File Size Uploaded
spandrift-0.1.0.tar.gz 49.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for spandrift 0.1.0
File Interpreter ABI Platform
spandrift-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 86.2 kB

Release files / spandrift-0.1.0.tar.gz

Download URL spandrift-0.1.0.tar.gz
Size 49.9 kB
Tags Source
SHA-256 checksum
How to use checksums
bf5c287550fda2cba7f1744c0864ef11a0ec0275bd4f8cf9a8c323a24ba34cb4
BLAKE2b-256 checksum
How to use checksums
c22c6f936271e3e433d96366c9ebabdf2ba6fd0eb807ba7a4f2100d8b7f8feb0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

Release files / spandrift-0.1.0-py3-none-any.whl

Download URL spandrift-0.1.0-py3-none-any.whl
Size 36.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b6d30d6515d5062b8be460a821813bad08b304efcd7f3797d65efbcec72ef332
BLAKE2b-256 checksum
How to use checksums
47e5262dc25320b6dbc6997cce7a802bef74c3f66a1f9068cc88805c7d48d27e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.2

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page