Per-user usage attribution for AI agents on Amazon Bedrock
See who drives your Amazon Bedrock agent usage. Use OpenTelemetry baggage to attribute tokens, cost, and rate limits per user and team in CloudWatch.
Part 1 covers the concept; Part 2 is the implementation. At the end of Part 2, you'll find a ready-made prompt and Model Context Protocol (MCP) server configuration you can drop into Claude Code or Kiro to accelerate applying this guide to your own project.
Part 1: Why per-user attribution matters, and the one idea that delivers it
You have rolled out coding agents and AI agents to your organization. Hundreds of people use them through Claude Code (Anthropic's coding agent), Strands, and LangGraph. Adoption is climbing, and so is the token usage and bill.
Then the questions start: which teams are driving the spend, who tripped a rate limit at 2 AM, is one power user consuming half the capacity, can finance charge costs back to the right cost center. Every one of these is the same question: who is using the agents, and for what.
Why the usage looks anonymous
When an agent calls a model on Amazon Bedrock, Bedrock records who made the call. The catch is that the caller is the application, not the person using it. The agent runs under a shared IAM role, and every user's traffic flows through that single identity.
Think of a company corporate card. The whole team buys things on one card, and at the end of the month the statement shows the company name on every line. The charges are real and correct, but you cannot see which teammate booked the flight and which bought the laptop.
A shared agent works the same way. Bedrock sees the agent's role on every call, exactly right for isolation and account-level billing. What it does not see is the end user behind each request. To answer who, you have to add that information yourself, in a way that survives the trip from the user, through your application, through the agent framework, all the way to Amazon CloudWatch.
The idea: a tag that travels with the request
This is where baggage comes in. The word usually has a negative ring, the thing you want to get rid of. Here it is the thing you want your agents to carry.
Picture an airline luggage tag. When you check a bag, it gets a tag with your name. The bag goes through belts, trucks, planes, and sorting systems you never see, and at every step the tag travels with it. When it surfaces at the other end, the tag still says whose it is.
Baggage in telemetry works the same way. At the start of a request, when you still know who the user is, you attach a label: this request belongs to a particular user, on a particular team. That label rides through the agent and shows up on every record the system produces. By the time usage data lands in CloudWatch, every line carries the identity of the person it belongs to.
"Baggage" is a defined concept in OpenTelemetry [10], built on the W3C Baggage specification [11], a standard widely adopted across the industry, including by AWS. This is a supported, conventional pattern, not a workaround.
What you get once the tag is on
Once every usage record carries the user, team, and session:
- Tokens, cache usage, latency, and request rate, sliced by user, team, or session.
- A power user lighting up the rate-per-minute view, by name, before they trip a limit.
- A per-team breakdown, scoped with real access controls (covered in Part 2).
- Capacity conversations grounded in who is consuming what.
Figure: Per-user attribution at a glance. Baggage carries the end user onto every span for the operational view (the solid path); a per-user session carries the user to AWS cost reporting (the dashed path). [Add diagram image here.]
Two questions, two answers
There are two flavors of "who is using what":
The first is operational: tokens, cache, latency, rate, per user and team, available in minutes. That is the baggage story, and what this guide covers.
The second is financial: who pays, on the actual AWS bill, for chargeback. AWS's granular cost attribution for Bedrock [4] already handles this, it puts the user's identity on the bill and shows per-user dollars in Cost Explorer. When you need invoice-grade numbers, use that.
Most organizations want both. They complement each other.
Built on open standards, not a bolt-on
A fair question: does this mean installing unfamiliar software into the request path? No. The approach uses OpenTelemetry, the same vendor-neutral standard that Amazon Bedrock AgentCore's observability is built on [1], via AWS Distro for OpenTelemetry [8]. The glue code is a few lines you can read end to end. Part 2 covers exactly what gets installed.
Who does what
The work splits cleanly:
- Developers attach the tag, one small change where a request enters the agent, naming the user from an identity the app already trusts.
- The platform team reads the results, CloudWatch queries and dashboards, built once, that every team's usage flows into.
That division is the structure of Part 2, for Strands, LangGraph, and Claude Code, whether the agents run on AgentCore or anywhere else.
Part 2: The implementation, for developers and platform teams
Part 1 explained the concept. This part is the how-to. It splits by role, read the half that applies to you:
- Developers attach the tag (one change per service).
- The platform team reads the results (CloudWatch queries, built once).
Before either role's section, two things that come up in every review: what are we installing, and what about privacy.
What you are installing, and how to vet it
Three packages, all open-source.
| Package | What it does |
|---|---|
opentelemetry-api | The baggage and context calls in the helper below. CNCF graduated project (same foundation as Kubernetes), Apache-2.0, vendor-neutral. |
opentelemetry-processor-baggage | Copies your baggage keys onto spans. Part of the official OpenTelemetry contrib packages [14]. |
| AWS Distro for OpenTelemetry (ADOT) | AWS's supported build of these same packages [8]. Amazon Bedrock AgentCore's observability runs on it [1]. |
Baggage itself is a W3C specification [11], an open standard, not an AWS or third-party feature.
On AgentCore: the OTLP collector is already running in the container runtime. You register the BaggageSpanProcessor in your entrypoint. Done.
Off AgentCore: you add the two packages (most instrumented services already have
opentelemetry-api) and register the processor on your own TracerProvider.Either way, the helper is about ten lines. No proprietary agent, no closed SDK.
Handling the identity data responsibly
You're putting a person's identifier on every span. Even pseudonymized, it's still personal data under GDPR [19][20] and NIST SP 800-122 [22], because a mapping can link it back. The goal isn't to find a "safe" field; it's to minimize, pseudonymize, and protect.
- Pseudonymize at login, not at the span. Never log raw emails or IdP subjects. Resolve identity once into an opaque, app-issued ID (or HMAC-SHA256(secret, sub) with the secret in Secrets Manager). That's what travels as baggage. OpenTelemetry's
user.hashandenduser.pseudo.id[12][13] exist for this case. - Mask at the log layer. CloudWatch Logs data protection policies [5] catch and mask sensitive data at egress. The pseudonym itself is low-risk; the policy's value is catching raw identifiers that slip through from code you don't control.
- Encrypt and expire. KMS-encrypt the log groups [7]. Set an explicit retention period [6] so identity data ages out on schedule. Audit unmasking via CloudTrail.
- Minimize scope. Only tag spans where attribution is the purpose (cost, audit, abuse). Leave it off everything else. This is GDPR Article 5(1)(c) [21] and it keeps cardinality down too.
- Keep it inside your boundary. Baggage propagates over outbound HTTP headers by design. Strip identity keys at the egress point so the pseudonym stays within your systems.
- Support deletion. Because the value is a pseudonym, honoring a deletion request means dropping the mapping and letting retention expire the logs, not scrubbing every line. Record this in your data inventory (GDPR Article 30) and name an owner for the salt.
AgentCore note: AgentCore Identity can emit a raw user subject on its own spans. Pseudonymize at the collector boundary (the Collector can rewrite attributes before export) rather than letting it persist.
None of this is unique to agents, it's the same discipline you apply to user identifiers in any application log [23], stated explicitly rather than inherited by accident.
For developers: attach the tag
At the point where a request enters the agent, label it with the user. Everything downstream follows from that label.
A note on OAuth: in token-based flows the user's JWT is scoped to your application (its audience is the app), and you usually do not pass that token through to the agent. The application resolves the user at its auth boundary, and where it invokes the agent on the user's behalf it exchanges the user token for one whose
sub is the original user. Either way, derive the baggage value from the identity you resolved at that boundary, not from a raw user JWT reaching the model call.Step 1: The pseudonymizer (compute once at your auth boundary)
1
2
3
4
5
6
7
8
9
10
11
import hashlib, hmac, os
from functools import lru_cache
_SALT = os.environ["USER_PSEUDONYM_SALT"].encode() # 32+ random bytes, from AWS Secrets Manager
def pseudonymize(subject: str) -> str:
"""Stable, per-application pseudonym for an IdP subject such as the JWT sub.
The salt is secret and never leaves your secrets manager, so the result groups
usage without being a cross-system identifier."""
return hmac.new(_SALT, subject.encode(), hashlib.sha256).hexdigest()[:32]In every recipe below,
user_id is the output of pseudonymize(jwt_sub). The examples show plain values for readability.Step 2: The baggage helper (define once, import everywhere)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from contextlib import contextmanager
from opentelemetry import baggage, context
def with_user_baggage(user_id, team_id=None, session_id=None):
ctx = baggage.set_baggage("user.id", user_id)
if team_id:
ctx = baggage.set_baggage("team.id", team_id, context=ctx)
if session_id:
ctx = baggage.set_baggage("session.id", session_id, context=ctx)
token = context.attach(ctx)
try:
yield
finally:
context.detach(token)Step 3: Register the processor so the tag lands on spans
Register the
BaggageSpanProcessor on the tracer provider. It reads the baggage from the active context and copies those keys onto each span as it starts. Pass ALLOW_ALL_BAGGAGE_KEYS to copy every key, or a key predicate to limit it to user.id, team.id, and session.id.Off AgentCore you create the
TracerProvider yourself:1
2
3
4
5
6
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.processor.baggage import BaggageSpanProcessor, ALLOW_ALL_BAGGAGE_KEYS
provider = TracerProvider()
provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
# add your OTLP exporter, then trace.set_tracer_provider(provider)On AgentCore, the collector is already running, just add the processor in your entrypoint (shown in the framework recipes below).
Pin your versions:
1
2
3
opentelemetry-api==1.42.1
opentelemetry-sdk==1.42.1
opentelemetry-processor-baggage==0.63b1Watch out for threads.
context.attach stores identity in a context variable that doesn't cross thread boundaries. If your agent fans out to a ThreadPoolExecutor, carry the context:1
2
3
4
5
6
7
8
9
from opentelemetry import context as otel_context
parent = otel_context.get_current() # capture on the parent
# inside each worker thread:
token = otel_context.attach(parent) # restore before doing work
try:
...
finally:
otel_context.detach(token)asyncio.create_task copies context automatically, async fan-out is fine; raw threads and process pools are not.One detail per framework
The pattern is identical across frameworks. What differs is where you call it and what the token fields end up named. The table is the map; the recipes follow.
| Framework | On AgentCore Runtime | Off AgentCore (Lambda/EC2/EKS/local) |
|---|---|---|
| Strands | container deploy, StrandsTelemetry, baggage | add BaggageSpanProcessor yourself |
| LangGraph | container deploy, OpenInference, baggage | add processor, OpenInference |
| Claude Code | baggage on the invoke call, OTEL metrics | OTEL environment, run as subprocess |
Scope: This guide covers agents that call your Bedrock under your own credentials. Agents that authenticate to a managed Amazon service (e.g., an SSO/token-based assistant) are out of scope, their usage is metered by that service, not your Bedrock account.
Deployment tip: On AgentCore, always deploy as a container. The OTLP collector runs inside the container image on
localhost:4318. The direct-code deployment type doesn't include it, so spans have nowhere to go.Strands
Off AgentCore, set up a tracer that promotes baggage, then wrap the call.
1
2
3
4
5
6
7
8
9
from with_user_baggage import with_user_baggage
from strands import Agent
from strands.models import BedrockModel
model = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0", region_name="us-west-2")
agent = Agent(model=model, system_prompt="Answer in one short sentence.")
with with_user_baggage("user-1", team_id="team-1", session_id="session-1"):
agent("What is 2+2?")The span Strands emits carries the identity next to the usage, including cache tokens and time-to-first-token:
1
2
3
4
5
6
7
8
{
"user.id": "user-1",
"team.id": "team-1",
"gen_ai.usage.input_tokens": 25,
"gen_ai.usage.output_tokens": 12,
"gen_ai.usage.cache_read_input_tokens": 0,
"gen_ai.server.time_to_first_token": 638
}On AgentCore, initialize the exporter and processor in the entrypoint (Strands telemetry [17]), and set the identity from the payload your orchestrator sends:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from bedrock_agentcore import BedrockAgentCoreApp
from strands import Agent
from strands.models import BedrockModel
from strands.telemetry import StrandsTelemetry
from opentelemetry import baggage, context, trace
from opentelemetry.processor.baggage import BaggageSpanProcessor, ALLOW_ALL_BAGGAGE_KEYS
StrandsTelemetry().setup_otlp_exporter()
trace.get_tracer_provider().add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
app = BedrockAgentCoreApp()
model = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0", region_name="us-west-2")
agent = Agent(model=model, system_prompt="Answer in one short sentence.")
def invoke(payload):
ctx = baggage.set_baggage("user.id", payload.get("user_id", "unknown"))
ctx = baggage.set_baggage("team.id", payload.get("team_id", "unknown"), context=ctx)
tok = context.attach(ctx)
try:
return {"result": str(agent(payload.get("prompt", "hello")).message)}
finally:
context.detach(tok)
if __name__ == "__main__":
app.run()Deploy as a container, non-interactively:
1
2
agentcore configure -e strands_agentcore.py -n strandsAgent -rf requirements.txt -r us-west-2 -ni -dt container
agentcore launch -aucThe model span then arrives in the
aws/spans log group with the identity on it, even though Bedrock signed the call with the execution role:1
2
3
4
5
6
7
{
"span_name": "chat us.anthropic.claude-haiku-4-5-20251001-v1:0",
"user.id": "user-1",
"team.id": "team-1",
"gen_ai.usage.input_tokens": 20,
"gen_ai.usage.output_tokens": 9
}LangGraph
LangGraph does not emit model telemetry on its own; instrument the underlying LangChain calls with OpenInference [18]. The baggage wiring matches Strands.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from with_user_baggage import with_user_baggage
from openinference.instrumentation.langchain import LangChainInstrumentor
from langchain_aws import ChatBedrockConverse
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
LangChainInstrumentor().instrument()
llm = ChatBedrockConverse(model="us.anthropic.claude-haiku-4-5-20251001-v1:0", region_name="us-west-2")
class S(TypedDict):
q: str
a: str
def call_model(state):
return {"a": llm.invoke(state["q"]).content}
g = StateGraph(S); g.add_node("model", call_model)
g.add_edge(START, "model"); g.add_edge("model", END)
app = g.compile()
with with_user_baggage("user-2", team_id="team-2", session_id="session-2"):
app.invoke({"q": "What is 3+3? One short sentence."})The span carries the identity and OpenInference's token fields:
1
2
3
4
5
6
7
8
{
"user.id": "user-2",
"team.id": "team-2",
"llm.provider": "amazon_bedrock",
"llm.token_count.prompt": 18,
"llm.token_count.completion": 12,
"llm.token_count.total": 30
}Two things to know. OpenInference names tokens
llm.token_count.*, not gen_ai.usage.*, so the platform team's queries differ by framework (the next section accounts for this). And OpenInference does not report cache tokens by default; if you need cache visibility in LangGraph, read the usage block from the Bedrock response inside the node and attach it as span attributes yourself. On AgentCore, instrument in the entrypoint and deploy as a container, exactly as with Strands.Claude Code
Claude Code talks to Bedrock when you set
CLAUDE_CODE_USE_BEDROCK=1 [15], and it already emits OpenTelemetry with token and cost on each request [16]. You supply the identity through the environment.Off AgentCore, run it as a subprocess with the identity in the environment:
1
2
3
4
5
6
7
8
9
env = {k: v for k, v in os.environ.items() if k != "USER_PSEUDONYM_SALT"} # do not leak the salt
env.update({
"CLAUDE_CODE_USE_BEDROCK": "1",
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_RESOURCE_ATTRIBUTES": f"user.id={pseudonymize(jwt_sub)},team.id={team_id}",
})
subprocess.run(["claude", "-p", prompt], env=env)On AgentCore, the orchestrator passes the user as baggage on the invoke call [3], and the runtime carries
user.id in its resource attributes:1
2
3
4
5
6
client.invoke_agent_runtime(
agentRuntimeArn=runtime_arn,
runtimeSessionId=session_id,
baggage=f"user.id={pseudonymize(jwt_sub)},team.id={team},session.id={session_id}",
payload=json.dumps({"prompt": prompt}).encode(),
)Either way, each
claude_code.api_request record carries the user on the resource and the usage on the attributes: input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, duration_ms, and model. Claude Code emits per-request detail as OpenTelemetry events and rollups as metrics, so enable both OTEL_LOGS_EXPORTER and OTEL_METRICS_EXPORTER; the per-call rows the platform queries read come from the events (logs) exporter. Treat that cost_usd as an estimate Claude Code computes from its own price table, not the billed figure; see "From tokens to a cost estimate."A note on cost
If you also need per-user attribution on the AWS bill itself (not just in dashboards), sign each Bedrock call with a per-user session so AWS records the user as the billing principal. A small helper assumes a Bedrock-scoped role with the pseudonym as the session name:
1
2
3
4
5
6
7
8
9
10
11
12
import boto3
def assume_user_role(role_arn, user_id, team_id=None, region="us-west-2"):
sts = boto3.client("sts", region_name=region)
kwargs = dict(RoleArn=role_arn, RoleSessionName=user_id[:64]) # session name is the pseudonym
if team_id:
kwargs["Tags"] = [{"Key": "team", "Value": team_id}]
c = sts.assume_role(**kwargs)["Credentials"]
return boto3.Session(
aws_access_key_id=c["AccessKeyId"], aws_secret_access_key=c["SecretAccessKey"],
aws_session_token=c["SessionToken"], region_name=region,
)Pass
pseudonymize(jwt_sub) as the session name, and scope role_arn to only the Bedrock actions the agent needs (bedrock:InvokeModel, plus bedrock:InvokeModelWithResponseStream / bedrock:ConverseStream if it streams). Then wire it per framework: for Strands, BedrockModel(boto_session=assume_user_role(...)); for LangGraph, ChatBedrockConverse(client=assume_user_role(...).client("bedrock-runtime")); for Claude Code, export the session credentials into the subprocess environment. That makes the user the principal Bedrock bills, which feeds the AWS cost attribution feature [4]. The reporting itself is that feature's job, covered in its own launch material, not here.Confirm it works
Before wiring every service, prove the path end to end with one call. Run a single request through one framework with the tag set, then query CloudWatch:
1
2
3
4
fields @timestamp, coalesce(attributes.user.id, resource.user.id) as user
| filter ispresent(user)
| sort @timestamp desc
| limit 5If your test user shows up, attribution works and you can roll out. If the rows are empty, check the usual causes in the next section before changing anything else.
When attribution breaks
Attribution fails quietly. Spans still arrive, just without the identity, so a dashboard looks healthy while it under-counts. The common causes, in the order worth checking:
- The processor is not active. If the
BaggageSpanProcessoris not registered (or its key predicate excludes your keys), the tag is set but never promoted onto spans. - The context did not cross a boundary. Threads you spawn and out-of-process sub-agents do not inherit the parent context unless you carry it over (see the helper caveat).
- The wrong deployment type. On AgentCore, direct-code deployment has no collector, so framework spans never export. Use a container deployment.
- Sampling dropped the span. If you sample, the dropped spans take their attribution with them; account for it when you reconcile counts.
Make the gap visible instead of silent. Measure the share of records missing the identity and alarm on it:
1
2
3
4
fields @timestamp
| stats sum(ispresent(coalesce(attributes.user.id, resource.user.id))) as tagged,
count(*) as total
| display tagged, total, (total - tagged) / greatest(total, 1) * 100 as pct_untaggedTurn
pct_untagged into a metric filter with a CloudWatch alarm, so a regression surfaces the day it ships rather than at quarter-end when the numbers do not add up. Alarm on total dropping to zero as well: a stalled pipeline reports zero records, which reads as 0% untagged and would otherwise look healthy.For the platform team: read from CloudWatch
Once developers are attaching the tag, your side is queries. Each one groups records by the identity the developers set. Build them once and they serve every team.
Normalize three things first, because they differ by source:
- The identity field path. The tag lands on span attributes for Strands and LangGraph (
attributes.user.id) and on the resource for Claude Code (resource.user.id). Usecoalesce(attributes.user.id, resource.user.id)so one query works regardless of where it sits. - The token field names. Each framework names tokens differently (see the mapping table at the end of this section). The examples below use the Claude Code names; substitute your source's names, or
coalesceacross them if a single dashboard spans frameworks. - The span that carries the usage. Some frameworks emit several nested spans for a single model call, and more than one of them repeats the token counts, so a plain sum over-counts. Scope each query to one span per call. For Strands on
aws/spans, filterattributes.gen_ai.operation.name = "invoke_agent", the per-turn span that holds the accumulated usage and the cache counts. For Claude Code, theattributes.event.name = "api_request"filter already does this. For LangGraph, sum the OpenInference LLM span (openinference.span.kind = "LLM"); the chain and graph spans do not carry token counts.
In practice you usually have one log group per source (the AgentCore
aws/spans group, your Claude Code telemetry group, and so on), so you point each query at the relevant group and adjust the filter and token fields for that source.Cost and tokens per user
1
2
3
4
5
6
7
8
fields @timestamp
| filter attributes.event.name = "api_request" /* Claude Code source; adjust per source */
| stats sum(attributes.input_tokens) as input_tokens,
sum(attributes.output_tokens) as output_tokens,
sum(attributes.cache_read_tokens) as cache_read,
sum(attributes.cost_usd) as cost_usd,
count(*) as requests
by coalesce(attributes.user.id, resource.user.id) as userTwo weeks of one user's activity aggregates to something like this:
1
2
3
4
5
6
user = user-1
requests = 147
input_tokens = 2238
output_tokens = 123481
cache_read = 6851829
cost_usd = 5.0088The cache-read figure stands out: 6.85M cache-read tokens against 2,238 fresh input tokens, so almost all of this user's context was served from cache. A cost-only view hides that; per-user token telemetry surfaces it.
From tokens to a cost estimate
That
cost_usd comes from Claude Code, which computes it from its own price table. Treat it as an estimate, not the bill: it will not reflect your negotiated, provisioned, cached, or region-specific rates. Strands and LangGraph emit token counts only, with no cost field.To put a dollar figure on tokens, multiply each token type by a rate you choose, since cached and output tokens are priced differently:
1
cost = input_tokens * rate_in + output_tokens * rate_out + cache_read * rate_cr + cache_write * rate_cwFor an AWS cost estimate, use the on-demand per-token rate for that model and region, from the AWS Price List API or the Bedrock pricing page. It stays an estimate: provisioned throughput is priced per hour, cross-region, global, and cached tokens have their own rates, and negotiated or batch discounts are not reflected.
If you are an ISV reselling agents, plug in your own customer-facing rate card instead. The same token counts then yield both your AWS cost, at the AWS rate, and what you bill your customer, at your rate, with the difference as your margin.
Either way it is an estimate. For the billed figure, reconcile against the AWS cost-attribution feature (CUR).
By user and by team
The grouping key is the only thing that changes between views.
by resource.user.id gives people:1
2
3
user-1 reqs=2 in=40 out=18 avg_ttft_ms=1601
user-2 reqs=2 in=98 out=43 avg_ttft_ms=553
user-3 reqs=2 in=195 out=54 avg_ttft_ms=551by team.id gives teams:1
2
team-1 reqs=4 tokens=307
team-2 reqs=2 tokens=141Same spans, different
by. Use by session.id for a per-session view, and group by coalesce(attributes.user.id, resource.user.id) so the identity resolves whichever framework emitted it.A caution on access. Grouping or filtering by
team.id builds a per-team view, but it is a query convenience, not access control. A team.id filter in a query does not stop someone who can open the log group from seeing everyone's data. If a team lead must be limited to their own team, enforce that with real controls: scope their IAM access to a dedicated log group or account, or front the data with a service that checks team membership before it queries. The baggage gives you the dimension to slice by; the enforcement is a separate, deliberate step.Rate per user (RPM and TPM)
Bin by minute, then read the peaks. This is the view for finding who is causing throttling.
1
2
3
4
5
fields @timestamp
| filter attributes.event.name = "api_request" /* adjust per source */
| stats count(*) as rpm, sum(attributes.input_tokens + attributes.output_tokens) as tpm
by bin(1m) as minute, coalesce(attributes.user.id, resource.user.id) as user
| sort minute descThe account's Bedrock quota comes from the Service Quotas API [9], and the per-user TPM from this query is what you compare against it.
Because this view reads from CloudWatch, expect a roughly 30 to 60 second ingestion delay, so treat it as near-real-time. For a hard budget or rate cutoff, enforce in-process at the call site or at an LLM gateway, not from this read path.
Latency per user
1
2
3
4
5
6
fields @timestamp
| filter attributes.event.name = "api_request" /* adjust per source */
| stats avg(attributes.duration_ms) as avg_ms,
pct(attributes.duration_ms, 90) as p90_ms,
pct(attributes.duration_ms, 99) as p99_ms
by coalesce(attributes.user.id, resource.user.id) as userStrands also reports
gen_ai.server.time_to_first_token, so for Strands you can separate time-to-first-token from total duration.Mind the field names
The queries above use the Claude Code field names. Because frameworks name tokens differently, keep a small mapping when you build cross-framework dashboards:
| Framework | Input tokens | Output tokens | Cache | Latency |
|---|---|---|---|---|
| Strands | gen_ai.usage.input_tokens | gen_ai.usage.output_tokens | gen_ai.usage.cache_read_input_tokens | gen_ai.server.time_to_first_token |
| LangGraph (OpenInference) | llm.token_count.prompt | llm.token_count.completion | not emitted | not emitted |
| Claude Code | input_tokens | output_tokens | cache_read_tokens | duration_ms |
When the cost-attribution feature is enough
If the only question is what each user costs and a one to two day delay is acceptable, you may not need any of the operational queries. AWS's granular cost attribution for Bedrock [4] surfaces the user's IAM principal in CUR 2.0 and shows per-user dollars in Cost Explorer, with no telemetry at all, as long as users reach Bedrock under a per-user session.
Reach for that feature when you need billing-grade dollars for chargeback, per-day and per-model granularity is enough, and a 24 to 48 hour delay is fine. Reach for the baggage telemetry in this guide when you need near-real-time numbers, a cache breakdown, latency percentiles, requests or tokens per minute, per-session detail, or per-user and per-team slices for your operational dashboards. The strongest setup runs both and reconciles them: the cost feature for the number finance bills, baggage telemetry for the live operational picture.
Rolling it out
A practical order that keeps each step verifiable:
- Platform prerequisites first. Decide the log groups and retention [6], attach the CloudWatch Logs data protection policy [5], set KMS encryption on the groups [7], and stand up the missing-identity canary so attribution is observable the moment it flows.
- Ship the helpers. Put
with_user_baggage.pyandpseudonymize()in a shared library, with the salt in Secrets Manager and a named owner. - Smoke-test one path. Wire one framework on one service, run the "Confirm it works" check, and confirm the test user appears.
- Roll out per framework. Make the one-line change in each service or entrypoint, following the matrix. On AgentCore, deploy as a container.
- Set the alarm target. Aim for under 1% of records missing the identity in steady state; alarm at 5% so a regression surfaces the day it ships.
- Decide on legacy logs. Records that predate the tag will not back-fill. Either report from the cutover date, or use the cost-attribution feature [4] for the historical financial view.
Developers own steps 2 to 4; the platform team owns 1, 5, and the dashboards. After step 1, neither blocks on the other.
Accelerate with a coding agent
You don't have to implement this manually. Feed this guide to a coding agent, Claude Code or Kiro, along with your codebase, and let it generate the integration steps, helper code, and tests tailored to your stack.
Setup:
- Point the agent at your codebase (open the repo in Kiro, or pass the path to Claude Code).
- Add the AWS Documentation MCP server from https://awslabs.github.io/mcp/servers/aws-documentation-mcp-server so the agent can pull current docs for Bedrock, CloudWatch, and OpenTelemetry as it works.
- Give it the prompt below.
The prompt:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
I want to implement per-user cost and usage attribution for our AI agents
that call Amazon Bedrock, following this guide:
https://builder.aws.com/content/3FHroAwLuM1WzjiUrWcItZBVkGK/per-user-usage-attribution-for-ai-agents-on-amazon-bedrock
Our stack:
- Framework: [Strands / LangGraph / Claude Code]
- Runtime: [AgentCore container / Lambda / ECS / EKS / local]
- Auth: [Cognito / Okta / custom IdP, how we get the user identity today]
- Observability: [existing CloudWatch setup, if any]
Please:
1. Analyze our codebase and identify where agent requests enter the system
(the auth boundary and the model invocation points).
2. Generate a step-by-step plan split by role:
- Platform engineer: log groups, retention, KMS encryption, data protection
policy, IAM scoping, CloudWatch dashboards, and the missing-identity alarm.
- Software engineer: pseudonymize helper, baggage context manager,
BaggageSpanProcessor registration, and the per-framework wiring.
3. Create the implementation code (helpers, config, environment variables).
4. Write test cases that verify:
- The pseudonym appears on spans (unit test the helper).
- A full request produces a CloudWatch record with user.id present
(integration test).
- The missing-identity alarm fires when the tag is absent (canary test).
5. Give me a runbook to test end-to-end: one request in, identity visible in
CloudWatch Logs Insights out.
Start by reading the codebase and mapping it to the guide, then propose the
plan before generating code.References
AWS
- [1] Amazon Bedrock AgentCore Observability. https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability.html
- [2] Amazon Bedrock AgentCore, configuring observability. https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-configure.html
- [3] Amazon Bedrock AgentCore, InvokeAgentRuntime API reference. https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_InvokeAgentRuntime.html
- [4] Introducing granular cost attribution for Amazon Bedrock. https://aws.amazon.com/blogs/machine-learning/introducing-granular-cost-attribution-for-amazon-bedrock/
- [5] Amazon CloudWatch Logs, protecting sensitive log data with masking. https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/mask-sensitive-log-data.html
- [6] Amazon CloudWatch Logs, working with log groups and retention. https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Working-with-log-groups-and-streams.html
- [7] Amazon CloudWatch Logs, encrypt log data with AWS KMS. https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html
- [8] AWS Distro for OpenTelemetry (ADOT). https://aws-otel.github.io/docs/introduction
- [9] Amazon Bedrock service quotas. https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html
OpenTelemetry and frameworks
- [10] OpenTelemetry, Baggage. https://opentelemetry.io/docs/concepts/signals/baggage/
- [11] W3C, Baggage specification. https://www.w3.org/TR/baggage/
- [12] OpenTelemetry semantic conventions, user attributes. https://opentelemetry.io/docs/specs/semconv/attributes-registry/user/
- [13] OpenTelemetry semantic conventions, enduser attributes. https://opentelemetry.io/docs/specs/semconv/attributes-registry/enduser/
- [14] OpenTelemetry BaggageSpanProcessor (Python). https://pypi.org/project/opentelemetry-processor-baggage/
- [15] Claude Code on Amazon Bedrock. https://docs.claude.com/en/docs/claude-code/amazon-bedrock
- [16] Claude Code, monitoring with OpenTelemetry. https://docs.claude.com/en/docs/claude-code/monitoring-usage
- [17] Strands Agents, observability. https://strandsagents.com/docs/user-guide/observability-evaluation/observability/
- [18] OpenInference instrumentation for LangChain. https://github.com/Arize-ai/openinference/tree/main/python/instrumentation/openinference-instrumentation-langchain
Privacy and standards
- [19] GDPR Recital 26, not applicable to anonymous data. https://gdpr-info.eu/recitals/no-26/
- [20] GDPR Article 4(5), definition of pseudonymisation. https://gdpr-info.eu/art-4-gdpr/
- [21] GDPR Article 5(1)(c), data minimisation. https://gdpr-info.eu/art-5-gdpr/
- [22] NIST SP 800-122, protecting the confidentiality of PII. https://csrc.nist.gov/publications/detail/sp/800-122/final
- [23] OWASP Logging Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- [24] IETF RFC 7519, JSON Web Token, the "sub" claim (section 4.1.2). https://www.rfc-editor.org/rfc/rfc7519#section-4.1.2
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article