*Written by *Matteo Rossi.
The monthly LLM bill jumped, and nobody on the team can say which agent, which user, or which workflow caused it. The provider dashboard breaks usage down by organization, workspace, project, model, and API key. Finance tries to ask a different question: which agent, which tool, which user, which workflow, which trace, which retry, which tenant? There’s no dashboard feature that can close that gap, because the provider doesn’t know our application exists.
This is a problem of scale. A single agent or a prototype needs none of what follows: the native dashboard already answers the question when there is only one thing spending. What follows is for the moment when that is no longer true.
The first implementations used one agent and one API key, and attribution worked by elimination: since spending was the only thing being tracked, the monthly total and the agent’s cost were the same number.
Then came multi-agent orchestration: a planner, sub-agents, nested tool calls, retries, where a single user request became a tree of calls. The API key was never the problem because it never carried attribution to begin with. The problem is that application context (which agent, which tool, which workflow step made the call) exists only in memory at request time and disappears unless something captures it at the call boundary.
There are four different problems here.
The approach here is to measure at the call site and aggregate in MongoDB. Around every LLM call sits a thin metering layer, and as soon as the response arrives, it reads the token usage, enriches it with the attribution context we only know at runtime, prices it, and writes the cost once, at write time.
Token counts are never estimated from words or sentences: the response carries exact counts, and the metering layer can read them off it. Store them as separate numbers rather than one total, because they don’t cost the same: output tokens run several times the price of input, tokens read back from a prompt cache run a fraction of it, and writing that cache entry costs more than plain input. Collapse them into a single *totalTokens, *and the cost can no longer be reconstructed from the document. In Spring AI, for example, the generic *Usage *object carries input and output; the cache counters sit on the provider-native object underneath it.
Unit prices come from a small pricing collection that the layer reads on write, not from a constant in the code. Each document is a versioned rate card:
{ "model": "claude-haiku-4–5", "provider": "anthropic", "currency": "USD", "inputPerMtok": 1.0, "outputPerMtok": 5.0, "cacheWritePerMtok": 1.25, "cacheReadPerMtok": 0.1, "effectiveDate": { "$date": "2026–07–01T00:00:00Z" }, "pricingVersion": 1}
These are Anthropic’s published per-token rates for Claude Haiku 4.5 at the time of writing: $1.00 input, $5.00 output, $1.25 to write a 5-minute cache entry, $0.10 to read one back, all per million tokens. Keeping them in a collection rather than a constant means that a price change next quarter or a second model joining the system results in a new document instead of a deployment. The field set is provider-specific, though. Anthropic prices two cache-write tiers (1.25x base input for a five-minute entry, 2x for one hour), and OpenAI charged nothing for cache writes before the GPT-5.6 family. Adding a second provider may require new fields, not only new documents.
A service looks up the current card for the model and hands back both the cost and the rate that produced it. The arithmetic is one multiplication per token class, kept in a pure function with no repository and no framework anywhere near it:
public static double costUsd(ModelPrice price, int promptTokens, int completionTokens, int cacheCreationTokens, int cacheReadTokens) { return (promptTokens * price.inputPerMtok() + completionTokens * price.outputPerMtok() + cacheCreationTokens * price.cacheWritePerMtok() + cacheReadTokens * price.cacheReadPerMtok()) / 1_000_000;}
That’s also why the rate comes back with the cost: it gets copied into the usage document it priced. Editing the pricing collection tomorrow can’t move a cost already written, and anyone auditing last month’s chargeback can recompute every line from the numbers stored beside it.
Here is one such document, straight out of the retrieval agent. The *traceId *is shared by every call serving the same user request, and it’s what later reconstructs the cost of an entire workflow:
{ "traceId": "f7035cf6-a13e-468d-9660–07452c5f2f4a", "agent": "retrieval-agent", "model": "claude-haiku-4–5", "promptTokens": 148, "completionTokens": 62, "cacheCreationTokens": 0, "cacheReadTokens": 0, "price": { "currency": "USD", "inputPerMtok": 1.0, "outputPerMtok": 5.0, "cacheWritePerMtok": 1.25, "cacheReadPerMtok": 0.1, "effectiveDate": { "$date": "2026–07–01T00:00:00Z" }, "pricingVersion": 1 }, "costUsd": 0.000458, "status": "OK", "attributes": { "doc_count": 3, "doc_ids": "doc-policy-v3,doc-guidelines-2024,doc-faq-returns" }, "ts": { "$date": "2026–08–04T09:14:03.512Z" }}
*attributes *is deliberately a free-form map rather than a fixed set of columns. Each agent decides what is worth recording about its own call, the same way it would set arbitrary attributes on a span if a tracing library were in the loop. That flexibility carries a governance consequence. Once an agent writes user identifiers, account names, or request content into attributes, agent_traces holds personal data and needs the same access control and retention policy as any other operational collection.
db.agent_traces.aggregate([ { $match: { traceId: "f7035cf6-a13e-468d-9660–07452c5f2f4a" } }, { $group: { _id: "$agent", calls: { $sum: 1 }, prompt_tokens: { $sum: "$promptTokens" }, completion_tokens: { $sum: "$completionTokens" }, cache_creation_tokens: { $sum: "$cacheCreationTokens" }, cache_read_tokens: { $sum: "$cacheReadTokens" }, cost_usd: { $sum: "$costUsd" } }}, { $sort: { cost_usd: -1 } }]);
At millions of events per day, evaluate MongoDB time series collections: usage events are append-only, timestamped, and queried by time range, exactly what they’re built for. Check the constraints first, though. They take no unique indexes, which rules out the deduplication key described later, and $merge can read from one but never write into one.
There are two reasonable sources for the usage document, with different trade-offs.
The direct write is the default when cost attribution is the only goal: the metering layer writes to MongoDB inline, with no dependency on a tracing stack and a write failure that is visible on the request path rather than silent. Visible is not the same as safe: the provider has already billed the call by the time a write times out, so the write path needs an idempotent retry. Every example in this piece takes this path: no OpenTelemetry and no span. It is just a repository save() call right after the model responds.
If OpenTelemetry already instruments the system, deriving the usage document from spans that follow the GenAI semantic conventions avoids duplicating instrumentation: the attribution context is already flowing through span attributes. A span processor extracts the usage fields, applies the pricing snapshot, and writes the same document shape to MongoDB.
The OpenTelemetry path carries one hard constraint: sampling and billing don’t mix. A sampled trace is fine for latency analysis, where a 10% sample still shows the distribution. It’s not a source of truth for billing, where every token spent has to be charged to someone. Choosing OpenTelemetry means running these spans unsampled, which is an operational cost to weigh against not instrumenting twice.
Either path needs custom code, because no standard OpenTelemetry Collector exporter turns arbitrary GenAI spans into the specific billing document described here, so a custom span processor is required regardless. The choice is about where the attribution context originates.
Raw documents are useless until they can be queried, and whoever is asking rarely wants a single cut of the data. Treat this as a set of query dimensions on one collection: cost by agent, by workflow via traceId, by model, by call outcome, and by time bucket for trend. Dividing spend by successful completions rather than by calls gives a different ranking: a workflow that is cheap per call and fails often can cost more per resolved task than an expensive one that succeeds on the first attempt. Anything more specific to one agent lives inside attributes and is still queryable with dot notation: attributes.decision.
Granularity depends on how the agents are set up, and where each agent does exactly one job, agent-level cost is already the finest useful grain, so there is no *task *field cluttering the document. A general-purpose agent handling many kinds of work is a different case, and needs a task identifier inside *attributes *to show where the spend inside that one agent is going.
Here is the time-bucketed query behind a per-agent trend, the view that surfaces which agent is growing month over month:
db.agent_traces.aggregate([ { $match: { ts: { $gte: ISODate("2026–07–01") } } }, { $group: { _id: { agent: "$agent", day: { $dateTrunc: { date: "$ts", unit: "day" } } }, cost_usd: { $sum: "$costUsd" }, calls: { $sum: 1 } }}, { $sort: { "_id.day": 1 } }]);
Swap *agent *for *model *or *status *in the *_id, *and you get every other view from the same collection without a schema change. That is the payoff of writing attribution dimensions at capture time.
For visualization, Atlas Charts is the low-friction path if the data already lives in Atlas, since it binds directly to the collection with no export step in between. Here is one dashboard built that way, four charts over agent_traces, each of them a different field on the same documents:
An existing BI or observability stack (Grafana, Looker, anything that speaks to MongoDB) reads the same collection or the same aggregated output without changing anything upstream.
As volume grows, the suggestion is to stop rescanning raw documents on every dashboard load. Pre-aggregate with an on-demand materialized view: a precomputed aggregation result stored on disk, refreshed by a scheduled pipeline ending in $merge. Here, that means materializing daily cost by agent and model into an *agent_traces_daily *collection, refreshed hourly for the current day and once after midnight to finalize the previous one. Dashboards then read a few thousand precomputed rows instead of millions of raw events.
db.agent_traces.aggregate([ { $match: { ts: { $gte: startOfDay } } }, { $group: { _id: { day: { $dateTrunc: { date: "$ts", unit: "day" } }, agent: "$agent", model: "$model" }, cost_usd: { $sum: "$costUsd" }, calls: { $sum: 1 } }}, { $merge: { into: "agent_traces_daily", on: "_id", whenMatched: "replace", whenNotMatched: "insert" }}]);
With a single agent, low volume, or a prototype, the provider’s native dashboard is enough, and custom metering is over-engineering. Complexity is the trigger: instrument only when there genuinely is a multi-dimensional attribution question or a chargeback need. If nobody is asking who spent the tokens, the one account-level number is a perfectly good answer, and the time this would take is better spent on the product.
Everything so far writes to MongoDB inline, on the same code path as the LLM call. That is the right starting point, and for most systems, the ending point too. As volume grows, there is a natural next step, worth naming precisely because its two halves are easy to conflate.
The producer is the metering layer at the call site: instead of writing the enriched document inline, it emits a minimal raw usage event onto a queue, the response’s usage plus the identifiers needed to enrich it later. The consumer reads that queue, attaches the attribution context and price snapshot, batches, and inserts. The agent stops blocking on a database write, and the write path evolves independently of the call path.
What you buy that with is more infrastructure, eventual consistency, and at-least-once delivery: the consumer will eventually see the same event twice and needs a deduplication key. Derive a stable *usage_event_id *before the call goes out, from an application call id, the traceId, and the attempt number, then let the database enforce it. The provider response id looks like the more natural source, but a timeout or transport failure returns no response id, and those are exactly the calls whose outcome is ambiguous enough to be retried. Carry the response id as an additional field when it is present:
db.agent_traces.createIndex({ usage_event_id: 1 }, { unique: true });
That index and a time-series collection can’t coexist, so a system wanting both has to deduplicate upstream in the queue instead. All of which is for a system with a very high number of agents, with millions of daily calls to LLMs: imposing a queue on a low-volume system contradicts the burden of proof applied throughout, and the inline write should stay until the numbers say otherwise.
Everything above covers attribution. Enforcement is a separate layer: budget alerts, per-agent rate limits, and circuit breakers that stop a retry storm all read from the same collection, and none of them are covered here.
Next month, the bill arrives again, and this time we can read it line by line: this agent, this workflow, this model, each with a number attached, computed once at the call boundary from token counts the provider gave us and priced with a rate card stored right next to the result. That doesn’t mean the total might still seem a little too high. Allocating and clearly knowing who is spending what doesn’t reduce costs, but it allows you to decide what to change.
Adding Cost Metering and LLM Spend Visibility to a Multi-Agent System was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.