Skip to content

shipit-watcher 1.5.0 — gateway attribution, and a context leak closed

Latest

Choose a tag to compare

@iamrraj iamrraj released this 05 Aug 18:32
f3b81aa

Attribution that survives a proxy — and a fix for a leak that should never have been possible in this library.

Gateway attribution

A LiteLLM proxy sits between your code and the provider, and that is where a lot of teams do cost allocation and prompt governance. existing_trace_id told the gateway which trace to join but nothing about whose call it was, so the gateway was allocating spend it could not attribute.

Attribution and prompt identity now travel with the call:

with wt.bind(company_id="acme", cost_center="support-ops"):
    litellm.completion(model="gpt-4o", messages=[...])   # via your proxy

The gateway receives the cost centre, client, user, session, channel, service and environment — plus prompt_name, prompt_version, prompt_fingerprint and prompt_registered, forwarded verbatim so a gateway in enforce mode can decide on them without a translation table.

Everything comes from the ambient context, never from a lookup afterwards. A cost centre resolved after the fact may since have changed, and an attribution that is only usually right is not one you can bill from.

On by default; wire names are yours via gateway_key_map. generation_owner decides who writes the generation record when a proxy is in the path — leave it app for a proxy you own, set it to gateway when the proxy already writes them, or every call is recorded twice and the cost doubles on paper.

Fixed: one caller's output reaching the next

ContextVar was constructed with default=TraceContext() — one object shared by every context that never set its own. TraceContext is frozen, which protects the fields but not the mutable result dict that set_output writes into:

with bind(user_id="alice") as ctx: ctx.set_output("alice's answer")
with bind(user_id="bob") as ctx:   ctx.result
# {'output': "alice's answer"}

Output recorded while no trace was active stayed in that dict, and the next unbound caller read it back. In a library whose entire purpose is per-tenant attribution and PII masking, that is the worst place in the codebase for a leak.

current_context() now returns a fresh context when nothing is bound. Reproduced before the fix, and three regression tests were checked against the old code to confirm they actually fail on it.

If you attribute per tenant, upgrade. The window is narrow — it needed output written outside a trace — but the failure mode is silent and it is the kind that only shows up in someone else's dashboard.

Housekeeping

  • CI on every pull request and push: ruff plus the full suite on 3.11, 3.12 and 3.13, and the package built and twine checked on every change rather than first at the tag.
  • Release by tag with Trusted Publishing, gated on green tests, and a check that the tag matches the version in pyproject.toml.
  • 350 → 0 lint errors. Most mechanical (Listlist, Optional[X]X | None, sorted imports), the rest by hand — zip(strict=), StrEnum, combined with statements. Tests re-run after every batch.
  • A fixture gap fixed in test_datasets: run_experiment links each result to its trace, and a tracer with no sink never opens one, so there was no trace id to link and the test read as a linking bug.
  • Workflow comments translated to English.

258 tests, green on 3.11, 3.12 and 3.13.