Skip to main content

See exactly what context each LLM call consumed - local-first context lineage for LLM apps.

Project description

ctxlineage logo

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.

CI Coverage PyPI Python License

Call anatomy view — what filled the context window, segment by segment

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.

Every command is also available as ctxl. ctxlineage report --json prints the normalized report data to stdout instead of writing HTML, if you want to pipe it somewhere.

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.

Ten-second tour: Overview, call anatomy, session chain, lineage graph with click-to-trace, dark mode

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

It gates real LLM calls. A suite that mocks its provider — the usual way to keep tests deterministic and free — stubs the call site, so init()'s patch never runs and there is nothing to record. The run is green because nothing was gated, not because your context is under budget. The plugin says so — note: 0 of N test(s) produced a gateable LLM call (provider mocked? this plugin gates real calls) — rather than passing in silence. To actually gate, add one test that makes a real call (a nano model, well under a cent), or run ctxlineage test / ctxlineage import over a recorded run.

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

Imported events land in .ctxlineage/ (retarget with -d/--dir), alongside anything init() already captured. Re-importing a session already in the log is refused rather than silently double-counting it — pass --dir to import a copy elsewhere. 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.

What this means for ctxlineage test. Segment-level contracts need exact segments, so they gate only on native init() capture — not on an import, where segments_complete is false. A window_budget with segment= skips imported calls (and says why, naming native capture as the fix) rather than scoring a fraction as the whole. The whole-prompt window_budget still gates an import, because it reads the provider's own usage. So: use import to explore the anatomy and the whole-prompt budget; use native capture to gate segments.

Querying the report from an agent (MCP server)

The same recorded artifact is exposed to MCP-speaking clients (Claude Code, Claude Desktop, …) through a read-only server, so an agent can ask about a run instead of you eyeballing the HTML. It reads the same local events.jsonl; nothing is transmitted.

pip install "ctxlineage[mcp]"     # the server ships in the mcp extra
ctxlineage-mcp --dir .ctxlineage  # stdio transport; -d defaults to .ctxlineage

Point an MCP client at it — e.g. for Claude Code:

// .mcp.json  (or your client's MCP config)
{
  "mcpServers": {
    "ctxlineage": { "command": "ctxlineage-mcp", "args": ["--dir", ".ctxlineage"] }
  }
}

Four tools, all read-only:

Tool Returns
list_sessions Recorded sessions + global stats (call/error counts, tag match rate); ids to feed the other tools, never prompt bodies
get_call One call's full anatomy — segments, usage, output, timing (long text truncated unless full_content=true)
get_lineage Lineage for one node id (a call or a context element): what it consumed, edges in/out, everything downstream it influenced
generate_report Builds the static HTML report (refuses to write inside the read-only event-log dir)

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 usage counts 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"]). Each redact_fields entry is a dotted path into the recorded event payload (request.messages.content masks the content of every recorded message); the matched values are replaced before anything is written to disk.

Status

v0.2.1 — 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 -p session 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

Apache-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ctxlineage-0.2.1.tar.gz (999.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ctxlineage-0.2.1-py3-none-any.whl (80.4 kB view details)

Uploaded Python 3

File details

Details for the file ctxlineage-0.2.1.tar.gz.

File metadata

  • Download URL: ctxlineage-0.2.1.tar.gz
  • Upload date:
  • Size: 999.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctxlineage-0.2.1.tar.gz
Algorithm Hash digest
SHA256 0671442c5e1ca6971c9315ec005a3365c69a5eaba9bb0005e646409ba1c0e3d3
MD5 add37ddfb77ea4264f6d06c94b00aa68
BLAKE2b-256 72d17274f13e101820f4f8ee470e51576135dedfd4eaa467979593c2c95cfab4

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctxlineage-0.2.1.tar.gz:

Publisher: release.yml on ctxlineage/ctxlineage

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ctxlineage-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: ctxlineage-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 80.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ctxlineage-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 9649e75d6d023c54eb7a465500909b1ded4f6e31bebf03118be39e1c7f5c6ef2
MD5 e9039219d4dfbbc112b2d5b5149b0824
BLAKE2b-256 9d44fe1bd3d473cb39ff07920524e8329a914017ed96207629e5b2e6a7a48976

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctxlineage-0.2.1-py3-none-any.whl:

Publisher: release.yml on ctxlineage/ctxlineage

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page