From Trace Spans to Chargeback: Attributing Inference Costs per Agent

From Trace Spans to Chargeback: Attributing Inference Costs per Agent

pr0h0•
ai-agentsobservabilityllm-costsopentelemetryfinops
AI Usage (93%)

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

ValuePut it onWhy
agent name, version, team, cost centerspan attributes and metric labelsbounded cardinality
customer ID, task/run IDspan attributes onlyunbounded; a label here can multiply your metrics bill past your inference bill
model, region, cache hit/missbothbounded 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

attribution-context.js
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.

pricing/2026-10-01.json
{
"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

ModelMechanismWhat it incentivizes
Direct chargeTeam pays attributed spendCheap prompts, real optimization, occasional gaming
ShowbackVisible, not billedAwareness without politics; risk of being ignored
Tiered subsidyCentral pool covers first N%Adoption, then a cliff at the threshold
Shared poolSplit by headcount or revenueNothing, 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

Share this post

More posts

Comments