Skip to content

17 min read

Attribution as write-time plumbing

On this page

The asymmetry this chapter rests on is between two kinds of decision. A write-side decision is one about what a row contains and when it is written, and it is distributed across every call site in the system. A read-side decision is one about how recorded data is aggregated, displayed and alerted on, and it lives in one place and operates on data already sitting there. Rollups, dashboards, digests and alert thresholds are read-side and can be changed on a Thursday. Correlation ids, tenant context, the four token counters, the applied rate and the status column are write-side, and changing them means touching code that is spread through the whole application.

That distinction is the argument for building the plumbing before launch, when it is nearly free, rather than when the analytics are wanted, which is when it is not. The asymmetry is not only that the work costs more later. Read-side work done late produces exactly the artefact it would have produced early — the same chart, over the same data, on a later Thursday. A write side retrofitted late produces a strictly worse one, because it can only begin recording on the day it lands, and every month in front of it stays unattributable however much is spent on the retrofit.

The value is not in scope where the call happens

"Not in scope" is doing more work in that heading than it looks, and it is worth spelling out, because the shape it describes is not a mistake anybody made. A function can read three kinds of value: what it was passed, what it closed over, and what it can reach through one of those. Nothing else in the process is visible to it. Which values it is passed is decided by its parameter list, and a parameter list is chosen by what the function does — a step runner takes a step, a planner takes a query, and neither of them has any use for a tenant identifier.

Refactoring pushes hard in that direction. Every round of it asks what a function actually needs, and a parameter that no line of the body reads is the easiest thing in a review to remove. So the tenant is dropped at the first frame that does not use it for its own work, which is usually the first frame below the request handler. The consequence is the part that matters: once a frame stops taking the value, no frame beneath it can obtain the value at all. It is not awkward to reach, it is absent from everything reachable at that point in the stack.

Attribution then wants that value at the deepest frame in the call tree, the one that issues the model request — the point furthest from the only place the value was ever held. Six months of ordinary refactoring therefore produces call stacks like this one, with the tenant identifier at the top and the model call at the bottom, and it produces them by working correctly rather than by anybody being careless:

python
# api/routes.py
async def post_research(request):
    tenant = request.state.tenant          # <- the value we need
    return await run_research(request.json["query"])

# services/research.py
async def run_research(query: str):
    plan = await plan_steps(query)
    results = await asyncio.gather(*[run_step(s) for s in plan.steps])
    return await synthesise(query, results)

# services/steps.py
async def run_step(step: Step):
    return await llm.complete(SYSTEM_STEP, step.prompt)   # <- where we need it

Threading tenant from the first frame to the fourth means changing the signature of run_research, plan_steps, run_step and synthesise, plus every one of their callers, plus every test that constructs them, plus the other entry points that call run_research for their own reasons and now have to produce a tenant they may not have. Multiply by every call site in the codebase. The cost is not the feature, it is the diff surface.

That multiplication is the reason the estimate on this work is always wrong in the same direction. Adding a column to a table is one change to one artefact. Threading a value through a stack is one change per edge in the call graph that the value has to cross, and the call graph is the thing that grew while nobody was measuring it. Nothing about the difficulty rises; the count does.

The version that costs nothing is the same code with an ambient context established once at the boundary, which is a five-line change in one file when the boundary is the only place that knows the value anyway:

python
# api/routes.py
async def post_research(request):
    with call_context(tenant_id=request.state.tenant,
                      user_id=request.state.user,
                      feature="research"):
        return await run_research(request.json["query"])

The three middle frames change by zero lines, now and forever. What makes that possible is worth knowing rather than taking on trust, because it is also what decides where the technique stops working. A parameter travels down a stack because somebody hands it over at each step. An ambient context does not travel at all: the value is bound to the execution rather than to a frame, held in a side table the runtime keys by the current logical thread of execution, and a lookup anywhere below simply reads whatever the nearest enclosing binding put there. In Python that machinery is contextvars; the with block binds the values on entry and restores the previous binding on exit, so no frame in between has to know the binding happened.

Concurrency is where that would ordinarily break, and it does not break here for a specific reason: when a coroutine or task is created, the current context is copied into it. The steps launched by the asyncio.gather above therefore each start with their own copy carrying the same tenant, and one of them rebinding a value cannot disturb its siblings. The rule to carry is that the context follows the execution wherever the runtime creates it, and the runtime creates every one of those tasks inside one process.

It follows immediately that the context does not cross a process boundary, and this is the limit the technique has rather than a defect in an implementation. A queue hop is a serialised message, and a message carries its own bytes and nothing else — there is no side table on the other side of a broker to copy into. A consumer therefore starts with an empty context every time. The only way it gets a tenant is if the enqueuing side wrote the identifiers into the payload and the consumer re-established them on the way in, which is what the queue-payload line in the pre-launch table below is paying for. A team that installs ambient context, watches the synchronous calls attribute correctly and concludes the job is done will find the asynchronous tail attributed to nobody — and the asynchronous tail is where the deferred, expensive work went.

Done on day one, all of this is a boundary decision made when there is exactly one boundary. Done in month seven, it is the same decision made when there are eleven boundaries, several of them queue consumers whose payload schema now needs a migration and a compatibility window, because messages enqueued before the deploy do not carry the context.

There is a second cost, and it is the one that cannot be bought back at any price. Spend already recorded without the identifiers will never be attributable to a tenant, a user or a feature, because the information required to attribute it existed only in the process memory of a request that finished months ago. When the pricing question arrives — is the enterprise tier profitable at the current mix, and which customers are underwater — the answer starts from the day the plumbing landed.

The belief that makes people comfortable deferring this is that the data can be reassembled later from what was already logged. It is a reasonable-sounding belief: the request log has the tenant on it, the cost row has the tokens on it, both were written, and joining two tables is what a warehouse is for. What it misses is that a join needs a key the two sides share, and these two share only a timestamp. A timestamp is enough to pair a request with a model call when one request is in flight at a time, and it is enough for nothing at all under concurrency, because dozens of model calls and dozens of requests overlapping inside the same second admit every possible pairing and the log does not say which. The identifier had to be written at the moment both facts were true together, and that moment has passed.

That claim is worth scoping rather than repeating flat, because it does have exceptions and they are checkable ones. Where a process handles a single tenant at a time, where requests are serialised, or where a trace id was already being stamped onto both the request log and the client's own log lines, the join is real and the backfill works. Those are the three conditions to make somebody demonstrate before accepting a plan that defers attribution, and demonstrating them takes one query. What cannot be accepted is the general form of the belief, which is that logs written for one purpose can be made to answer a question nobody was recording an answer to.

What fits in three weeks before launch

The honest version of the argument is a sequencing argument. Building the full stack pre-launch competes directly with launch, and most of it is unnecessary before there is traffic: a per-tenant dashboard with four tenants on it is a chart nobody opens, and an anomaly alert needs weeks of baseline before it can do anything but page. What has to exist on day one is only the part that is impossible to add later.

Ship before launchWhy it cannot waitEstimate
Correlation id + ambient context at every entry pointBoundaries multiply; retrofitting touches every frame1.5 d
Context carried in queue and job payloadsAsync tails are unattributable without it, and payloads need a migration later1 d
Instrumented client wrapper: four token counters, usage_status, finally block, own retry loopProvider usage objects are not recoverable after the fact1 d
Price written onto the row from a versioned rate cardRate cards change; history cannot be repriced correctly afterwards1 d
The fact table and a non-blocking writerRows not written are rows that never existed1.5 d
Nightly reconciliation and a residual rowBounds the error from the first week rather than the first quarter1 d

The estimates add up the way estimates in that table have to: two rows at 1.5 days and four at 1, which is 3 plus 4, or seven engineer-days in total, spread across three calendar weeks alongside launch. The reason it is seven rather than seventy is that every line is being done at the moment there is one of the thing it touches — one entry point, one client wrapper, one queue producer. The same six lines in month seven are each multiplied by however many of that thing now exist, which is the argument of the section above restated as a schedule. Everything else waits:

DeferCost of adding it later
Hourly rollup tablesA scheduled job over data you already have — half a day, any time
Per-tenant and per-feature dashboardsQueries against an existing table — hours
Unit-cost alerting with per-feature baselinesNeeds weeks of history to be useful, so building it early is wasted anyway
Ranked per-tenant spend digestA query and a cron entry
Chargeback (billing a tenant or an internal team for the model spend it caused), invoicing, cost-aware routingProduct decisions that should not be made before you have data
Per-tenant spend capsA separate enforcement mechanism, deliberately not part of accounting

The asymmetry between those two tables generalises past this topic. Spend the pre-launch budget on the write side, where deferral compounds, and postpone the read side, where it does not.

One detail in the writer is worth getting right on day one because it is also awkward to change later. The cost row must not sit on the request's critical path, meaning the chain of work a user is waiting on before a response can go back. Write it to a bounded in-memory queue and flush in batches from a background task; on overflow, drop and increment a counter rather than blocking or growing without limit. A dropped row is a small, measured contribution to the reconciliation residual, which is a category of error there is already machinery for. A blocking write that adds 4 ms to every model call, or an unbounded queue that turns a database hiccup into an out-of-memory kill, are both worse trades, and both are the kind of thing discovered in an outage rather than a review.

The first week with the table

The way to find out whether the plumbing is right is to ask it the questions it was built for, before anyone depends on the answers. Four queries in the first week find most of what is broken.

Group by correlation_id on a feature you know fans out, and count the groups with exactly one row. The logic is that a feature which always makes several model calls per user action can only produce a one-row group if some of its calls were filed under a different id, so a single-row group is not a rare event to be investigated case by case — it is a structural statement that propagation failed somewhere. On a research-style feature that number should be zero, and anything above zero is a propagation hole. It is almost always a queue consumer that minted a fresh id instead of re-entering the one in the payload, and that follows directly from the boundary the ambient context cannot cross: in-process hops inherit the id for free, so the hops that can break are exactly the ones that serialise.

Sum cost_usd by usage_status. If the complete share is below about 97%, the wrapper is losing usage objects somewhere, and the breakdown says where: client_timeout points at a timeout shorter than the tail of the latency distribution, stream_error at connection handling. The threshold does not sit at every row because some calls genuinely end in a state other than complete and recording them is the point of the column rather than a failure of it. It is up at 97% because those states are rare on a working system, so a share meaningfully below it is not the provider failing more often — it is the wrapper failing to write a row when the call did not end the ordinary way.

Run the reconciliation against a day that closed 48 hours ago and read the residual as a percentage. Under 3% is a working ledger. Over 10% means something structural is missing — most often an entire call path that bypasses the wrapper, which a GROUP BY model on the residual usually narrows down in one query.

And group by tenant_id with a count(DISTINCT feature) beside it. Every tenant that has used the product should appear, and any tenant showing spend on a feature it does not have access to is a context leak — usually a shared worker that reused a context object across jobs, which is the one bug in this whole area that produces confidently wrong data rather than missing data.

Accounting is not enforcement

Attribution does not control spend. A perfectly attributed ledger will tell you, with great precision and about ninety seconds of delay, that one tenant has spent $9,400 today against a $500 plan. Stopping that requires an enforcement mechanism that runs before the call, not an accounting mechanism that records it afterwards — a pre-request check against a running total, or the platform's own version of one. Anthropic's Managed Agents session budgets work exactly this way: the platform checks consumed list cost before every model request and pauses the session when the cap is reached, and the documentation is explicit that you should set a budget rather than polling usage and interrupting the session yourself, because polling loses the race.

Why it loses is worth one paragraph, because it is the same before-and-after distinction the whole chapter turns on. A poller reads a total, decides, and then acts, and between the read and the act more calls complete — so the amount that can be spent past the cap is bounded only by how much work was already in flight during that window, which on a fanned-out agent is a great deal. A check that sits in the call path has no such window: it is what admits the call, so nothing can be spent between the check and the thing the check gates. The difference is not that one is faster. It is that one of them has an ordering guarantee and the other has an interval. Attribution is the input that says where to set the caps and which tenants need them.

It will also not attribute everything, however well it is built. Some spend is genuinely shared: cache-warming requests serving every tenant on a pooled prefix, evaluation runs, a model call in a shared enrichment pipeline whose output twelve tenants read. Forcing that into a customer bucket produces a chargeback number defensible only until someone asks how it was computed. The workable convention is a small number of internal pseudo-tenants — platform, eval, shared-cache — carried as ordinary values in the tenant_id column, so shared spend is a visible line with a name rather than either a smear across customers or a hole in the total. On the systems this has been measured on, that internal bucket lands somewhere between 3% and 9% of total spend, and its size is itself worth watching.

There is a related belief worth catching before it does damage, and it is the one a dashboard encourages. A chart broken out by tenant looks like a statement about where the money went, and it is not one. It is a statement about where the money went among the rows that carried a tenant. Rows written without one are not drawn as a gap in that chart; they are not drawn at all, because a group-by has nothing to group them under, so the chart sums to less than the invoice and says nothing about the difference. That is the entire reason the reconciliation residual from chapter 4 has to sit beside every such chart rather than on a page of its own: the chart cannot report its own incompleteness, and the residual is the only figure that can.

The last thing attribution does not do by itself is survive without an owner. A cost ledger sits awkwardly between teams — platform engineering writes to it, product and finance read from it, and nobody's roadmap contains it. The observable symptom of an unowned ledger is a growing unknown bucket: a new service ships without the wrapper, its spend lands in the residual, and because the residual has no owner either, nothing happens until the number is large enough to embarrass someone. Naming an owner for the reconciliation number specifically, rather than for "cost tracking" in the abstract, is what prevents that, because the residual is a single figure that either moves or does not.

Seven days of write-side work before launch is the whole recommendation, and it buys something the same seven days cannot buy afterwards: a table whose history starts at the first request rather than at the day somebody asked. The read-side work — the rollups, the dashboards, the two alerts, the digest — is genuinely cheap whenever it is done, and doing it early mostly produces charts of four tenants and a load test.

With the plumbing in, the useful next step is to leave it alone and let it accumulate. A cost ledger's value is a function of how much history it holds: the first month is a straight line with a spike in it, the third month is where a tenant's usage pattern changes and the digest surfaces it, and the pricing conversation that needs an actual distribution rather than an average is usually a quarter out. That is the same reason the plumbing had to go in first.

Cost tracking per request, user, feature and tenant — read the current version at https://daviddominguez.dev/learn/en/cost-tracking-per-request-user-feature-and-tenant/attribution-as-write-time-plumbing

Back to top