
From Trace Spans to Chargeback: Attributing Inference Costs per Agent
Introduction — the invoice arrives before the attribution does
An LLM provider invoice tells you what you spent. It does not tell you which agent, which team, or which customer spent it. This post is a practical walkthrough of closing that gap: propagating agent, team, and customer identity through OpenTelemetry trace spans so inference usage per agent can be metered, budgeted, alerted on, and charged back — and doing it without igniting the turf war that usually follows the first surprising bill.
The timing here is not accidental. On October 12, 2026, three announcements landed within a few hours of each other. HPCwire reported that HCLTech and Anthropic are partnering to expand enterprise adoption of agentic AI. Startup Fortune published a piece on structuring an AI agent chargeback model without starting a turf war. Moomoo reported Intel accelerating open infrastructure for the agentic AI era.
Only one of those is something you can fix in a repository, and it is the one that rarely gets a ticket: when the provider invoice lands, someone has to answer which agent, which team, which customer. Most teams cannot. That is why chargeback debates turn into turf wars — the argument is not about policy, it is about missing data.
My position is blunt: meter first, mandate later. A chargeback model layered on top of unattributable spend is just an org chart with worse error bars. The seed material here is thin — a partnership announcement and a headline with a summary line — so the vendor-side claims below are reported as reported, and the instrumentation design is mine, marked as untested where I have not run it.
Why agent workloads break request-based cost attribution
One user action fans out into many billable calls
A single "summarize this account" click can produce a router call, three retrieval calls, a planner call, four sub-agent calls, and a synthesis call. Seven to fifteen billable inferences from one HTTP request. Request-based attribution assigns all of them to whoever owned that route, which is usually the platform team — so the platform team subsidizes every product team's token burn. Adoption decays the moment that subsidy is discovered.
Internal components spend money no human requested
Retries, reflection loops, "verify your answer" passes, and tool-calling round trips are all billed. None of them appear in the user-facing request. If your attribution only covers the entry point, the largest cost center in the system — internal orchestration — is invisible by construction.
Token counts are estimates until the provider invoice lands
Usage reported in an API response is authoritative for that call, but the invoice is authoritative for the month. Cache discounts, batch pricing, and reconciliation adjustments only show up at billing time. Treat span-derived numbers as an estimate that must be reconciled against the invoice, not as ground truth.
Pick the attribution dimensions before you pick the tooling
The four keys worth carrying: agent, team, customer, cost center
app.agent.name, app.team, app.customer_id, app.cost_center. Version the agent (app.agent.version) — otherwise you cannot tell whether a cost jump came from traffic or from a prompt change.
Identity enters at the request edge, not at the LLM call site
The call site does not know who is paying. Resolve identity once, at the edge (gateway, job producer, queue consumer), and propagate it. Any component that can start an LLM call must receive attribution, including cron jobs and evaluation harnesses. Cron jobs are where attribution goes to die.
Cardinality decisions — span attributes versus metric labels, and why customer ID must never be a label
| Value | Put it on | Why |
|---|---|---|
| agent name, version, team, cost center | span attributes and metric labels | bounded cardinality |
| customer ID, task/run ID | span attributes only | unbounded; a label here can multiply your metrics bill past your inference bill |
| model, region, cache hit/miss | both | bounded and needed for slicing |
Customer-level spend belongs in a warehouse query over span data, not in a Prometheus label. This is the single most common mistake I see in cost dashboards, and it is expensive in both directions.
Propagating agent identity with OpenTelemetry trace spans
Use resource attributes for agent identity and version, span attributes for per-call economics
Resource attributes describe the process: service name, agent name, agent version, deployment environment. Span attributes describe the call: model, token counts, cache behavior, task ID. Mix these up and you cannot aggregate by agent without joining on span data.
Keeping attribution intact across async boundaries, queues, and sub-agent handoffs
Two hard rules. First, serialized message headers carry the attribution tuple, not just a trace ID — a trace ID alone tells you the request, not the payer. Second, the consumer re-binds context at dequeue from those headers rather than relying on propagation magic.
A small middleware that binds attribution into context
import { AsyncLocalStorage } from "node:async_hooks";
export const attribution = new AsyncLocalStorage();
export function withAttribution(headers, fn) {
const ctx = {
agent: headers["x-agent-name"] ?? "unknown-agent",
agentVersion: process.env.AGENT_VERSION ?? "dev",
team: headers["x-team"] ?? "unattributed",
customerId: headers["x-customer-id"] ?? null,
costCenter: headers["x-cost-center"] ?? "unattributed",
taskId: headers["x-task-id"] ?? null,
};
return attribution.run(ctx, fn);
}
// Queue producer: carry the tuple, do not rely on trace propagation alone.
export function attributionHeaders() {
const ctx = attribution.getStore() ?? {};
return {
"x-agent-name": ctx.agent ?? "unknown-agent",
"x-agent-version": ctx.agentVersion ?? "dev",
"x-team": ctx.team ?? "unattributed",
"x-customer-id": ctx.customerId ?? "",
"x-cost-center": ctx.costCenter ?? "unattributed",
"x-task-id": ctx.taskId ?? "",
};
}AsyncLocalStorage survives await, timers, and promise chains. It does not survive a worker_thread, a child process, or a setImmediate boundary created outside the context — rebind explicitly at those edges.
What a complete inference span should carry
{
"name": "gen_ai.chat",
"attributes": {
"gen_ai.system": "anthropic",
"gen_ai.request.model": "model-a",
"gen_ai.usage.input_tokens": 8421,
"gen_ai.usage.output_tokens": 611,
"gen_ai.usage.cache_read.input_tokens": 7200,
"gen_ai.usage.cache_creation.input_tokens": 0,
"app.agent.name": "invoice-reconciler",
"app.agent.version": "2.4.1",
"app.team": "finance-eng",
"app.customer_id": "cust_8812",
"app.cost_center": "cc-441",
"app.task_id": "run_01J9...",
"app.attempt": 1,
"app.call_id": "call_01J9..."
},
"parentSpanId": "a1b2c3d4e5f60718",
"traceId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
"status": { "code": "OK" }
}
I have not re-verified the stability level of the OpenTelemetry GenAI semantic conventions for this post; those attributes have moved between spec versions. Treat the names above as the shape you need, and pin whichever version you target.
Handling tool calls that call back into the agent
A tool that invokes another agent produces a child span. Two rules prevent double counting: aggregate cost from leaf inference spans only, and give every logical model call a stable app.call_id so a retry is summed, not double-counted. Sum every span with token attributes instead, and a retry pattern with app.attempt=1,2,3 will silently triple that customer's reported spend.
Turning trace spans into dollars
Capturing input, output, cached-read, and cache-write token counts per span
Four counters, not two. Record only input and output, and cache behavior disappears — your estimate drifts upward against a cache-heavy invoice.
The pricing join: keeping a versioned price table
Keep prices in a versioned file or table with an effective date, and store the price-version on the span at write time. When prices change mid-month, you need to know which rate applied to which call.
{
"version": "2026-10-01",
"currency": "USD",
"unit": "per_million_tokens",
"models": {
"model-a": { "input": 3.00, "output": 15.00, "cacheRead": 0.30, "cacheWrite": 3.75 }
}
}Those numbers are illustrative placeholders, not current published prices. The mechanism is the part that matters: cost = (input*in + output*out + cacheRead*cr + cacheWrite*cw) / 1e6.
Cache pricing changes the ranking of who is expensive
A cached read at one-tenth of input price means a "cheap on paper" agent with poor prompt caching can outspend a premium-model agent with a stable prefix. A cost-per-token dashboard will rank them backwards. Report cost per task and cache-read ratio side by side; the pair is diagnostic, either alone is not.
Reconciling against the invoice
Run this as a monthly job, not a vibe check:
## est.csv: model,month,estimated_usd (from span rollups)
## invoice.csv: model,month,invoiced_usd
$ join -t, -1 1 -2 1 est.csv invoice.csv | awk -F, \
'{d=($4-$3)/$4; printf "%s %s drift=%.2f%%\n", $1, $2, d*100}'
model-a 2026-10 drift=2.41%
model-b 2026-10 drift=11.08%
Set a threshold (3% is a reasonable start) and page someone when a model exceeds it. Drift above threshold on one model almost always means one of three things: uncounted retries, a cache-miss regression, or spans dropped by sampling. That is the whole point of the check — it turns an accounting argument into a debugging ticket.
Budgets, alerts, and the feedback loops that make them useful
Pre-flight guards versus post-hoc alerting
Pre-flight guards block or downgrade: reject a request that would exceed a customer's daily budget, or route it to a cheaper model with a logged reason. Post-hoc alerting tells you on Thursday that Tuesday was expensive. Use both, but never make the guard silent — an unlogged downgrade looks like a quality regression.
Alert on cost per completed task, not raw monthly spend
Monthly spend alerts fire on success. Cost per resolved ticket catches the failure mode that matters: an agent that got more expensive while getting no more correct. Pair it with cost per retry, or you will discover the loop only after the quarter closes.
Track unattributed spend as a first-class metric
Percentage of spend with no app.team or app.customer_id. Own it explicitly. It is the honest measure of instrumentation coverage; a chargeback model with 30% unattributed spend gets argued about, not followed.
Chargeback policies that don't punish adoption
Allocating shared platform costs
Embeddings, router overhead, retrieval infrastructure, and eval harnesses benefit everyone. Allocate them by a declared rule — proportional to each team's attributed inference spend, or a flat platform fee — and publish the rule. Silent allocation is what starts the turf war the Startup Fortune piece describes.
Charge for waste and retries, not experiments
Split the ledger into production, experiment, and retry/waste. Charge the first, subsidize the second from a central bucket, and surface the third as a quality metric rather than a bill. Bill teams for the retries caused by your flaky tool, and they will stop using agents.
Allocation models compared
| Model | Mechanism | What it incentivizes |
|---|---|---|
| Direct charge | Team pays attributed spend | Cheap prompts, real optimization, occasional gaming |
| Showback | Visible, not billed | Awareness without politics; risk of being ignored |
| Tiered subsidy | Central pool covers first N% | Adoption, then a cliff at the threshold |
| Shared pool | Split by headcount or revenue | Nothing, but no arguments; hides the expensive consumer |
Who owns the unattributed bucket
One named owner — usually the platform team — with a published dispute path: a monthly review, a two-week window to claim or contest a charge, and an escalation only if the claim exceeds a defined amount. If every dispute escalates to leadership, the model is already dead.
Failure modes that break attribution in production
Sampling kills cost numbers
Head sampling is fine for latency. It is wrong for money, because spend is not uniformly distributed — the expensive tail is exactly what a probabilistic sampler throws away. Do not rely on sampled spans for the ledger. Emit cost as an unsampled record (log or metric) alongside the span, or use a tail sampler that always keeps gen_ai.* spans.
Retries, streaming aborts, and duplicated spans
A stream that aborts mid-response is still billed for input tokens. Record usage in a finally block. Deduplicate on (trace_id, span_id) and sum by app.call_id + app.attempt. I have seen (and caused) dashboards that double-count because a reconnect produced a second span with the same call_id.
Context loss at the queue boundary
If the consumer does not rebind from headers, that work is silently reclassified as unattributed. Silent is the problem: nothing errors, the metric just quietly grows. Alert on the unattributed rate, not only the absolute value, so a new queue that forgets to propagate headers shows up within a day.
What the source material confirms and what remains inference
Confirmed from the seed
Reported on October 12, 2026: HCLTech and Anthropic announced a partnership to expand enterprise adoption of agentic AI (HPCwire); Startup Fortune published a piece on structuring an AI agent chargeback model without starting a turf war; Intel is reported to be accelerating open infrastructure for the agentic AI era (Moomoo). The direction — enterprise agent deployment getting real budget scrutiny — is stated in the seed material. The content of those announcements beyond the headlines was not available to me, so I have not characterized it further.
Clearly marked as untested inference
The instrumentation design above is a proposal, not a tested artifact for this post: the middleware, span shape, and pricing join are illustrative. I have not run the reconciliation script against a real invoice, and the pricing numbers are placeholders. Any organizational outcome — whether tiered subsidy changes behavior, whether disputes resolve cleanly — is speculation. I would want a full billing cycle of data before defending any allocation model as effective.
Conclusion — meter first, mandate later
The org conflict described in the chargeback discourse sits downstream of a data problem. If you can answer "which agent, which team, which customer" from span data, and your estimate tracks the invoice within a few percent, the policy conversation becomes an engineering one. If you cannot, no policy survives contact with the first surprising invoice. Carry the four keys from the request edge, count four token types, never sample the ledger, and treat unattributed spend as a bug with an owner.
Further Reading
- HCLTech and Anthropic Partner to Expand Enterprise Adoption of Agentic AI — HPCwire, via Google News
- How to Structure an AI Agent Chargeback Model Without Starting a Turf War — Startup Fortune, via Google News
- OpenTelemetry GenAI semantic conventions — the attribute namespace referenced above; check the current stability level before pinning attribute names
- Node.js
AsyncLocalStoragedocumentation — the context mechanism used in the middleware example


