LLM Usage Metering Guide¶
The metadata + recorder guide for measuring LLM call volume per component, per feature and per user
1. Overview¶
Two components exist so that the token usage an LlmClient produces can be attributed — where it came from, who caused it and why.
| Component | Role |
|---|---|
LlmCallMetadata |
An immutable value object holding the call's attribution (component/feature/principal/traceId/tags) |
LlmUsageRecorder |
The sink interface that receives a call's outcome (provider/model/TokenUsage + metadata) |
MeteringLlmClient |
An LlmClient decorator. Intercepts the response and emits an event to the recorder |
InMemoryLlmUsageRecorder |
The default in-memory implementation, following the multi-instance-ready principle |
The provider implementations (OpenAILlmClient, AnthropicLlmClient) do not need to know that metering exists. Wrapping them in the decorator is enough.
IMPORTANT: TokenUsage.getReasoningTokens() is recorded and deliberately not priced. Reasoning
tokens are a subset of the completion tokens rather than an addition to them (OpenAI reports them
inside output_tokens_details), so ModelPrice.costOf's completion term has already priced them —
adding them again here would bill them twice. A recorder receives the whole TokenUsage, so the
field simply arrives.
provider is the vendor name ("OpenAI", "Anthropic") and model is the model the call actually used — the name the request's LlmModel gave, or the client's default (LlmClient.getDefaultModelName()) when it gave none.
2. Getting started¶
2.1 Wiring¶
InMemoryLlmUsageRecorder recorder = new InMemoryLlmUsageRecorder();
LlmClient client = new MeteringLlmClient(new OpenAILlmClient(apiKey), recorder);
// from here on, inject client into OrcaAgentExecutor, SubagentExecutor, the wiki components and so on
Using LlmUsageRecorder.NOOP lets you keep the decorator chain in place while metering is switched off.
2.2 Attaching attribution at the call site¶
LlmCallMetadata meta = LlmCallMetadata.builder()
.component("orca-agent")
.feature("react-loop")
.traceId(conversationId)
.principal(currentUser)
.tag("tenant", "acme")
.build();
client.sendMessage(systemPrompt, messages, tools, model, meta);
The plain sendMessage(...) overload that omits metadata works fine too — LlmCallMetadata.empty() is applied automatically.
2.3 Reading the aggregates¶
for (LlmUsageSnapshot snap : recorder.snapshot()) {
System.out.printf("%s → calls=%d, totalTokens=%d%n",
snap.getKey(), snap.getCallCount(), snap.getTotalUsage().getTotalTokens());
}
LlmUsageKey aggregates along five axes: provider/model + (component, feature, principal). Tags are left out of the key to keep cardinality from exploding; if you need per-event analysis, implement your own recorder.
3. Framework integration¶
The framework fills the metadata in automatically along the main call paths. A caller only overrides the fields it cares about.
3.1 The Orca agent¶
OrcaAgentExecutionRequest carries an llmCallMetadata field.
OrcaAgentExecutionRequest request = OrcaAgentExecutionRequest.builder()
.userInput("…")
.llmCallMetadata(LlmCallMetadata.builder()
.principal(currentUser)
.traceId(externalRequestId)
.tag("tenant", "acme")
.build())
.build();
executor.execute(context, request);
OrcaAgentExecutor.execute fills in the following fields when the caller left them empty (anything already set is preserved):
| Field | Automatic value |
|---|---|
component |
agent.getName() |
feature |
"react-loop" |
traceId |
conversationId.value() |
This effective metadata applies to every LLM call in the ReAct loop, and propagates into sub-executions through the ToolContext.
3.2 Subagents¶
When Orca runs a subagent through TaskTool, the parent metadata propagates automatically as SubagentExecutionEnvironment.parentLlmCallMetadata. DefaultSubagentExecutor builds its effective metadata like this:
| Field | Value |
|---|---|
component |
subagent.getName() — the subagent always wins (overridden even if the parent set it) |
feature |
"subagent" — always wins |
traceId |
inherits the parent's traceId as-is |
principal/tags |
inherits the parent's values |
The merge direction is the opposite of Orca's: Orca is "caller-supplied wins, auto-derived is the fallback", whereas a subagent is "the subagent's identity wins, the parent is the fallback". The asymmetry is deliberate — a sub-execution's token usage is only meaningful to track when it is aggregated under the name of the subagent that actually ran.
The same rules apply when you build a SubagentExecutionRequest and invoke it yourself. If you want to force a different component/feature tagging, it is cleaner to put another decorator in front of MeteringLlmClient.
3.3 Wiki¶
LlmRerankSearchStrategy and LlmWikiPageGenerator are static components that run without a call context, so their attribution is specified once, at builder time.
WikiSearchStrategy strategy = LlmRerankSearchStrategy.builder()
.llmClient(llmClient)
.modelConfig(LlmModel.builder().name("claude-haiku-4-5").build())
.llmCallMetadata(LlmCallMetadata.builder()
.component("wiki-rerank")
.feature("search")
.build())
.build();
If you set nothing, these defaults apply:
| Component | Default component | Default feature |
|---|---|---|
LlmRerankSearchStrategy |
wiki-rerank |
search |
LlmWikiPageGenerator |
wiki-generator |
page-generation or index-generation |
On top of that, both components attach the WikiScope information as tags on every call:
| Tag key | Value |
|---|---|
wiki.agent |
WikiScope.agentName |
wiki.context |
WikiScope.contextId |
wiki.name |
WikiScope.wikiName |
These tags are merged over the static metadata given to the builder via LlmCallMetadata.withTags(...), so both the caller-supplied base tags and the per-call scope tags survive. Note that wiki.context is unique per execution context and is therefore a high-cardinality value — rather than using it directly as a Prometheus label in an external recorder, it is safer to use it for correlating logs and traces.
4. Custom recorders¶
To send usage somewhere other than InMemoryLlmUsageRecorder — Prometheus, OpenTelemetry, a database — implement LlmUsageRecorder yourself. The interface has exactly one method:
public final class PrometheusLlmUsageRecorder implements LlmUsageRecorder {
private final Counter callsTotal;
private final Counter promptTokensTotal;
private final Counter completionTokensTotal;
@Override
public void record(String provider, String model, TokenUsage usage, LlmCallMetadata metadata) {
final String component = metadata.getComponent().orElse("unknown");
final String feature = metadata.getFeature().orElse("unknown");
callsTotal.labels(provider, model, component, feature).inc();
promptTokensTotal.labels(provider, model, component, feature).inc(usage.getPromptTokens());
completionTokensTotal.labels(provider, model, component, feature).inc(usage.getCompletionTokens());
}
}
Keep such an implementation in its own module and depend on it only where the CLI (or the consuming application) does its wiring — aimon-core knows nothing but the interface (the multi-instance design principle).
5. Best practices¶
- Make traceId match your external correlation id: putting the request id your gateway or CLI receives straight into it lets you join LLM usage against your external logs.
- Use
at.aimon.core.base.Principalfor principal: use it as-is when you need the user/group/system/service distinction. If a plain user id is all you need, putting it intagsis lighter. - Keep tags to low-cardinality values only: they are excluded from
LlmUsageKeyaggregation, but they are likely to end up as labels in an external recorder. Move high-cardinality values such as a user id intoprincipal. - Only one layer of the decorator: nesting it —
MeteringLlmClient(MeteringLlmClient(...))— emits every event twice. Meter at the outermost layer only. - An exception from the recorder does not break the call:
MeteringLlmClientcatches RuntimeException, logs at WARN and returns the response unchanged. An outage in the metering backend does not fail an LLM call.
6. Related code¶
- Interfaces / value objects:
at.aimon.core.llm.LlmCallMetadata,at.aimon.core.llm.LlmClient(the overload) - The recorder package:
at.aimon.core.llm.usage.* - Helper:
at.aimon.core.llm.usage.LlmCallMetadataResolver— merges the auto-derived fields into the caller's metadata - Integration points:
OrcaAgentExecutor,DefaultSubagentExecutor,LlmRerankSearchStrategy,LlmWikiPageGenerator