See exactly what context each LLM call consumed - local-first context lineage for LLM apps.
Project description
ctxlineage
See exactly what context each LLM call consumed — and how it flowed into the next.
Two lines of code turn your app's LLM calls into a single static HTML report: the anatomy of every context window, and the lineage of every context element. No server, no database, no account. Your prompts never leave your machine.
Not another trace viewer: ctxlineage makes your runtime context an engineered artifact — decomposed, provenance-tracked, and (v0.2) testable in CI.
Quickstart
pip install ctxlineage
import ctxlineage
ctxlineage.init() # auto-instruments the openai and anthropic SDKs
# ... run your app: every LLM call is recorded to .ctxlineage/events.jsonl
ctxlineage report --open # one self-contained HTML file
That's the whole integration. Streaming, async, tool calls — captured.
Add .ctxlineage/ to your .gitignore.
What you get
| View | Question it answers |
|---|---|
| Overview | Which calls are heaviest? How close to the window limit? Did my tags match? |
| Calls | What actually filled this call's context window — and at what token cost? |
| Chain | How did each output become the next call's input? Where do agent loops accumulate context? |
| Graph | Where did each context element come from (vector DB, prompt file, memory) and which downstream calls did it influence? |
Think dbt docs generate, for your context windows: calls are functions,
context elements are typed inputs with provenance, outputs flow into
downstream inputs.
Tagging (optional, unlocks lineage)
Everything above works with zero tagging. Label your context assembly and the report upgrades from role-based heuristics to real, provenance-carrying segments:
with ctxlineage.span("answer_user_query") as span:
span.tag("rag_chunks", docs, source="qdrant:products_v2", transform="top_k(8)")
span.tag("memory", user_profile, source="memory:user_prefs")
resp = client.chat.completions.create(...)
Tagged content is matched back into the actual prompts (exact → partial → honestly-untagged, with the match rate displayed — never fabricated).
Testing context in CI (ctxlineage test)
The same recorded artifact is a substrate for deterministic assertions — no
LLM judge, no eval dataset. Write a ctxlineage.toml:
[[assert.window_budget]]
max_pct = 80 # no call may exceed 80% of the model's window
[[assert.window_budget]]
segment = "tool_defs" # a segment kind, or a tag name
max_pct = 20 # tool definitions may not eat >20% of the window
[[assert.grounded]]
tag = "rag_chunks" # every rag_chunks tag must land in the window
warn_dead = true # advisory: flag chunks nothing downstream consumed
ctxlineage test # exits non-zero on a hard-gate failure → CI gate
A rule only gates where its evidence is exact, which decides what can fail your build:
| Gates on | Because | |
|---|---|---|
window_budget |
any call, no tagging needed | token counts and the model window are deterministic from capture alone |
grounded presence |
tagged content only | the tag() is your declaration, so "it never reached the window" is exact |
grounded dead-context |
nothing — advisory | "nothing downstream used it" is read off inferred lineage edges |
Gating on inference is how you get a flaky gate, so ctxlineage won't do it: an untagged run warns instead of failing, and anything that couldn't be evaluated (an unknown model window, a segment name that never appeared) is reported as skipped or warned — never silently passed.
segment selects on the kinds the pipeline really produces — system, user,
assistant, tool, tool_defs — or any tag name.
Try it in 30 seconds (no API key)
python examples/rag_app.py --mock # records a real run, fully offline
ctxlineage test -c examples/ctxlineage.toml # → All 3 assertion(s) over 3 call(s) passed
examples/ctxlineage.toml is a commented reference
config; CI runs exactly the two commands above, so it cannot drift from the code.
Wiring it into CI
ctxlineage test gates a recorded run, so CI needs one. It does not have to
be a live one — assertions read the JSONL, not the network:
# .github/workflows/context.yml
- run: pip install ctxlineage
- run: pytest tests/ # your existing suite, with ctxlineage.init() in it
- run: ctxlineage test # fails the build on a hard-gate breach
Where the events come from is your choice, and the trade is real:
| Deterministic? | Cost | |
|---|---|---|
| Your existing tests, with mocked/replayed responses | yes — start here | none |
| A live run against the real API | no (temperature, model drift) | tokens |
ctxlineage import --from claude-code |
yes (a recorded session) | none |
Prompt assembly is usually deterministic even when the model's replies are
not — which is the point: a mocked run still proves what your code put in the
window. The examples take this route (--mock replays canned responses), and
so can your test suite. Live runs need a statistical gate (run N times, assert a
pass rate) rather than a single hard gate — that is not built yet, so keep hard
gates on recorded runs.
Inside pytest, per test (pytest --ctxlineage)
The two-step wiring above keeps the events and the assertions in separate
places, and can only ever fail the whole run — "some call in this log blew the
budget". If your suite already runs the app under ctxlineage.init(), the
bundled pytest plugin evaluates the same ctxlineage.toml inside the suite
and fails the test whose call breached it:
pytest --ctxlineage
FAILED tests/test_agent.py::test_long_loop
FAIL window_budget: ... is 91.4% of the 200,000-token window, over the 80% budget
Per-test attribution is the whole reason to prefer it over ctxlineage test;
that is the one thing the CLI cannot do. Everything else is identical — same
config, same tier rule (a warning never gates, an unevaluated call is reported
as skipped, never a green test). It is inert until you pass --ctxlineage
(installing ctxlineage never changes a suite's behaviour), or you can commit the
choice:
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--ctxlineage"
The plugin owns ctxlineage.init() into a throwaway temp dir for the run, unless
your app already called init() — then it uses your app's directory and leaves
capture alone. ctxlineage test is still the tool for a recorded run produced
outside pytest (a script, a --mock example, ctxlineage import), and for a
grounded tag declared once for the whole run rather than inside one test.
| Gate granularity | Recorded run comes from | |
|---|---|---|
ctxlineage test |
the whole run | anywhere — a script, an example, an import |
pytest --ctxlineage |
the individual test | the suite itself, as it runs |
Importing coding-agent sessions (ctxlineage import)
Claude Code and claude -p are separate, non-Python processes, so
ctxlineage.init() can't patch them. But they already write a session
transcript to disk — so ctxlineage reads that local file. Nothing is proxied,
injected, or sent anywhere; it is the same local-first bargain as capture.
ctxlineage import --from claude-code # newest session for this directory
ctxlineage import --from claude-code --session <id>
ctxlineage import --from claude-code path/to/session.jsonl
ctxlineage import --from claude-code --dry-run # show what it would do, write nothing
ctxlineage report --open # renders like any captured session
The transcript becomes ordinary events.jsonl, so the four views, ctxlineage test, and the MCP server all work on it unchanged.
What a transcript cannot tell you. This is reconstruction, not capture, and the gaps are real — the report is honest about them rather than inventing numbers:
| Token counts | The API's own usage, reconstructed from the transcript — not estimated |
| Segment sizes | Estimated (the prompt text is rebuilt from the conversation tree) |
| System prompt, tool definitions | Not preserved — they were sent and cost tokens, but the transcript never records them |
| Reasoning text | Not preserved — kept as a signature with the text stripped |
| Request params, duration, stream flag | Not preserved |
So an imported call's segments account for only part of its real prompt: the
system prompt and tool definitions alone can be tens of thousands of tokens.
ctxlineage import prints the coverage it achieved, and the unaccounted
remainder is recorded per call. Live capture via ctxlineage.init() remains the
complete picture; import is the bridge to processes you cannot patch.
Principles
- Local-first, zero-server. The artifact is one HTML file that opens offline. Capture is an append-only local JSONL. Nothing is ever transmitted.
- Non-intrusive by default.
init()and you're done; tags are progressive enhancement. The capture layer never breaks your app — failures degrade to warnings, not exceptions. - Honest data. Real
usagecounts preferred over estimates, estimates labeled, unmatched tags shown as unmatched, inference caps disclosed.
Scope & limits
Designed as a per-run, dev-time artifact. Comfortable up to ~5,000 calls
(~15 MB report); usable to ~20,000 calls (~65 MB). Reports contain full prompt
text — treat them like logs with sensitive data (see SECURITY.md).
Before sharing, mask secrets with ctxlineage report --redact "sk-[A-Za-z0-9]+"
(repeatable regex; the report discloses what was redacted), or keep them out of
the log entirely with ctxlineage.init(redact_fields=["request.messages.content"]).
Status
v0.2.0 — the capture layer (openai + anthropic), the four-view report, the span/tag lineage pipeline, the read-only MCP server, and runnable examples, plus:
ctxlineage test— deterministic context contracts (window_budget,grounded) that gate a recorded run in CI, and a pytest plugin (pytest --ctxlineage) that attributes a breach to the test that caused it.ctxlineage import --from claude-code— bring a Claude Code /claude -psession into the same report, honest about what a transcript cannot preserve.
See the CHANGELOG and the issues.
Contributing
See CONTRIBUTING.md — DCO sign-off, hermetic tests, deliberately small maintenance surface (off-roadmap issues may be closed).
License
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 ctxlineage-0.2.0.tar.gz.
File metadata
- Download URL: ctxlineage-0.2.0.tar.gz
- Upload date:
- Size: 991.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e90d703d0a2172bf330e204f5a431a3beb9457d5fabf77a129381e92f93debe5
|
|
| MD5 |
fa194472ca1dab753554dcba840cda82
|
|
| BLAKE2b-256 |
e5e0f6e773198f63e902cba9240658a261fe7fe1ab41d642b985147bbdd88ad5
|
Provenance
The following attestation bundles were made for ctxlineage-0.2.0.tar.gz:
Publisher:
release.yml on ctxlineage/ctxlineage
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ctxlineage-0.2.0.tar.gz -
Subject digest:
e90d703d0a2172bf330e204f5a431a3beb9457d5fabf77a129381e92f93debe5 - Sigstore transparency entry: 2192460902
- Sigstore integration time:
-
Permalink:
ctxlineage/ctxlineage@ef13f9179b816da8708fec45a43809d9139f3dee -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ctxlineage
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ef13f9179b816da8708fec45a43809d9139f3dee -
Trigger Event:
push
-
Statement type:
File details
Details for the file ctxlineage-0.2.0-py3-none-any.whl.
File metadata
- Download URL: ctxlineage-0.2.0-py3-none-any.whl
- Upload date:
- Size: 77.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d8c20ee4cf0f393bda83c860ffff2c6279eca4325c19356ea60adda7d546e5e6
|
|
| MD5 |
b687508995c13f36d3c5f788a357b176
|
|
| BLAKE2b-256 |
f513044c9304d5ba45b21727a3e20c0a5efb20d083f6a49a5099340f26181100
|
Provenance
The following attestation bundles were made for ctxlineage-0.2.0-py3-none-any.whl:
Publisher:
release.yml on ctxlineage/ctxlineage
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ctxlineage-0.2.0-py3-none-any.whl -
Subject digest:
d8c20ee4cf0f393bda83c860ffff2c6279eca4325c19356ea60adda7d546e5e6 - Sigstore transparency entry: 2192460916
- Sigstore integration time:
-
Permalink:
ctxlineage/ctxlineage@ef13f9179b816da8708fec45a43809d9139f3dee -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ctxlineage
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ef13f9179b816da8708fec45a43809d9139f3dee -
Trigger Event:
push
-
Statement type: