A team deploys Bedrock-powered agents in Q1. In Q4, finance asks for a per-team cost breakdown. No tags were applied at deployment time. The CUR is full of bedrock.amazonaws.com line items with empty resource tag columns. This scenario is now the norm, not the exception — AWS Bedrock’s tagging surface is limited compared to EC2/RDS, and most teams reach for it mid-flight.
The good news is that “retroactive” is not binary. Some attribution signals are fully recoverable, some are partially recoverable with significant effort, and some are genuinely gone. Knowing which is which saves weeks of dead-end work.
1. The Fundamental Problem: Cost Allocation Tags Are Not Retroactive in CUR
AWS Cost Allocation Tags operate on a forward-only model. When you activate a tag key in the Billing console, AWS begins including that tag in new CUR line items from that point forward. Historical line items already written to S3 do not get backfilled.
What this means concretely:
- CUR S3 objects already delivered (daily or hourly partitions) will never be rewritten by AWS to add tag columns.
- Even if you add tags to every Bedrock resource today, the
resource_tags_user_*columns in CUR for past months will remain NULL for those resources. - This is documented behavior, not a bug: AWS CUR is an append-only ledger from the billing system’s perspective.
Why Bedrock makes this worse than EC2:
Bedrock charges are associated with the API call, not a persistent resource. There is no running instance to tag. The billable resource in CUR is the model ARN (e.g., arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0), and you cannot tag a foundation model you don’t own. The tagging surface that does exist — Inference Profiles, Provisioned Throughput, Agents, Knowledge Bases — only matters for spend routed through those resources.
What you can tag and have it affect CUR going forward:
| Resource | Taggable | Tags flow to CUR |
|---|---|---|
| Application Inference Profile (AIP) | Yes | Yes, on new API calls through that AIP |
| Provisioned Throughput | Yes | Yes |
| Bedrock Agent | Yes | Yes, on agent invocation charges |
| Knowledge Base | Yes | Yes |
| Foundation Model (direct InvokeModel) | No | No tagging surface exists |
| Bedrock Project | Yes (project-level) | Yes, forward from assignment |
The bottom line: if your workload was calling InvokeModel directly against a foundation model ARN with no Inference Profile in between, there is no tag column to retroactively populate.
2. What IS Retroactive: Cost Categories and IAM Principal Data
Cost Categories — 12-Month Lookback
AWS Cost Categories are the most powerful retroactive tool available. Unlike tag activation, Cost Categories apply rules against historical CUR data going back 12 months from the date you create or update the category.
A Cost Category rule can match on:
- Linked account — the simplest retroactive cut; if teams have separate accounts, attribution is immediate.
- Service — less useful here since we’re already scoped to Bedrock.
- Usage type — e.g.,
USE1-Bedrock-InputTokensvsUSE1-Bedrock-OutputTokensfor model-family splits. - IAM principal — this is the critical one for Bedrock retroactive attribution.
When IAM principal cost allocation is enabled (opt-in, per-account), AWS records which IAM role or user made each API call. If this was enabled before the period you need to attribute, the line_item_operation and IAM principal fields are available for Cost Category rules.
Creating a Cost Category rule for retroactive team attribution:
In the AWS Billing console or via the Cost Explorer API:
{
"Name": "BedrockTeamAttribution",
"RuleVersion": "CostCategoryExpression.v1",
"Rules": [
{
"Value": "platform-team",
"Rule": {
"And": [
{ "Dimensions": { "Key": "SERVICE", "Values": ["Amazon Bedrock"] } },
{ "Tags": { "Key": "aws:createdBy", "Values": ["arn:aws:iam::123456789012:role/platform-*"] } }
]
}
},
{
"Value": "ml-team",
"Rule": {
"And": [
{ "Dimensions": { "Key": "SERVICE", "Values": ["Amazon Bedrock"] } },
{ "Tags": { "Key": "aws:createdBy", "Values": ["arn:aws:iam::123456789012:role/ml-*"] } }
]
}
}
],
"DefaultValue": "unattributed"
}
The aws:createdBy tag is automatically populated by AWS for resources created via IAM roles — but for Bedrock API calls (which aren’t resource-creation events), this approach uses IAM principal matching differently. See the CloudTrail join approach below for the more powerful version.
IAM Principal Cost Allocation — The Opt-In That Changes Everything
AWS supports “IAM principal” as a dimension in Cost Explorer if the feature is enabled. This records the IAM role ARN that made each API call in the billing record. Critically: this data is retroactive to when the opt-in was activated, not to the beginning of time.
If your account enabled IAM principal tracking in January and you’re doing attribution in June, you have 5 months of IAM-principal-attributed CUR data to work with. The earlier months are gone from this signal.
To check if it’s enabled: Billing console → Cost allocation preferences → IAM cost allocation.
If it was not enabled, enabling it now starts the clock — it does not backfill. The retroactive strategy for pre-enablement spend shifts entirely to CloudTrail.
3. Retroactive Attribution via CloudTrail: Joining InvokeModel Events to CUR
This is the workhorse strategy for retroactive Bedrock attribution when tag columns are empty and IAM principal tracking was not enabled.
The Core Insight
CloudTrail logs every InvokeModel, InvokeModelWithResponseStream, and Converse API call with:
- Timestamp (to millisecond precision)
userIdentity.arn— the IAM principalrequestParameters.modelId— which model was calledadditionalEventData.inputTokenCountandadditionalEventData.outputTokenCount— actual token countsawsRegionrecipientAccountId
CUR line items for Bedrock record:
line_item_usage_start_date/line_item_usage_end_date(hourly granularity by default)line_item_resource_id— the model ARNline_item_usage_amount— token count (input or output, one row per)line_item_usage_type— encodes region + token direction + model family
The join strategy: aggregate CloudTrail token counts by (hour, model, IAM principal) and use that distribution to proportionally attribute each CUR hourly line item.
This is not exact — it’s a statistical attribution — but for most workloads it produces team-level allocations accurate to within 2-5% when teams have distinct IAM roles.
Athena SQL: CloudTrail + CUR Join
Step 1: Create the CloudTrail Athena table (assuming CloudTrail logs are in S3 with standard path structure):
CREATE EXTERNAL TABLE cloudtrail_logs (
eventVersion STRING,
userIdentity STRUCT<
type: STRING,
principalId: STRING,
arn: STRING,
accountId: STRING,
sessionContext: STRUCT<
sessionIssuer: STRUCT<
type: STRING,
principalId: STRING,
arn: STRING,
accountId: STRING,
userName: STRING
>
>
>,
eventTime STRING,
eventSource STRING,
eventName STRING,
awsRegion STRING,
requestParameters STRING,
responseElements STRING,
additionalEventData STRING,
recipientAccountId STRING
)
PARTITIONED BY (region STRING, year STRING, month STRING, day STRING)
ROW FORMAT SERDE 'com.amazon.emr.hive.serde.CloudTrailSerde'
STORED AS INPUTFORMAT 'com.amazon.emr.cloudtrail.CloudTrailInputFormat'
OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION 's3://your-cloudtrail-bucket/AWSLogs/123456789012/CloudTrail/'
TBLPROPERTIES ('classification'='cloudtrail');
Step 2: Extract Bedrock InvokeModel events with token counts:
CREATE OR REPLACE VIEW bedrock_invocations AS
SELECT
DATE_TRUNC('hour', from_iso8601_timestamp(eventTime)) AS usage_hour,
awsRegion AS region,
recipientAccountId AS account_id,
-- Normalize model ID: strip version suffix for CUR matching
REGEXP_REPLACE(
json_extract_scalar(requestParameters, '$.modelId'),
':[\d]+$', ''
) AS model_id,
-- Prefer session issuer ARN (role) over assumed-role ARN
COALESCE(
userIdentity.sessionContext.sessionIssuer.arn,
userIdentity.arn
) AS principal_arn,
-- Extract team from role name convention (adjust regex to your naming)
REGEXP_EXTRACT(
COALESCE(
userIdentity.sessionContext.sessionIssuer.arn,
userIdentity.arn
),
'role/([a-z0-9-]+)-[^/]+$',
1
) AS team_prefix,
CAST(
json_extract_scalar(additionalEventData, '$.inputTokenCount')
AS BIGINT
) AS input_tokens,
CAST(
json_extract_scalar(additionalEventData, '$.outputTokenCount')
AS BIGINT
) AS output_tokens
FROM cloudtrail_logs
WHERE
eventSource = 'bedrock.amazonaws.com'
AND eventName IN ('InvokeModel', 'InvokeModelWithResponseStream', 'Converse', 'ConverseStream')
AND year >= '2025'
;
Step 3: Aggregate CloudTrail token share by hour, model, and team:
CREATE OR REPLACE VIEW bedrock_token_share_by_hour AS
WITH hourly_by_principal AS (
SELECT
usage_hour,
region,
account_id,
model_id,
team_prefix,
SUM(input_tokens) AS team_input_tokens,
SUM(output_tokens) AS team_output_tokens
FROM bedrock_invocations
GROUP BY 1, 2, 3, 4, 5
),
hourly_totals AS (
SELECT
usage_hour,
region,
account_id,
model_id,
SUM(input_tokens) AS total_input_tokens,
SUM(output_tokens) AS total_output_tokens
FROM bedrock_invocations
GROUP BY 1, 2, 3, 4
)
SELECT
p.usage_hour,
p.region,
p.account_id,
p.model_id,
p.team_prefix,
p.team_input_tokens,
p.team_output_tokens,
t.total_input_tokens,
t.total_output_tokens,
CAST(p.team_input_tokens AS DOUBLE) / NULLIF(t.total_input_tokens, 0) AS input_share,
CAST(p.team_output_tokens AS DOUBLE) / NULLIF(t.total_output_tokens, 0) AS output_share
FROM hourly_by_principal p
JOIN hourly_totals t
ON p.usage_hour = t.usage_hour
AND p.region = t.region
AND p.account_id = t.account_id
AND p.model_id = t.model_id
;
Step 4: Join to CUR and produce attributed spend:
-- CUR table assumed to be in Athena as cur_data
-- line_item_resource_id for Bedrock looks like:
-- arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0
-- We strip it to match model_id in CloudTrail
SELECT
DATE_TRUNC('month', CAST(cur.line_item_usage_start_date AS TIMESTAMP)) AS attribution_month,
s.team_prefix,
cur.line_item_resource_id,
cur.line_item_usage_type,
SUM(cur.line_item_unblended_cost) AS total_cost,
SUM(cur.line_item_unblended_cost * s.input_share) AS attributed_input_cost,
SUM(cur.line_item_unblended_cost * s.output_share) AS attributed_output_cost,
-- Average share as a single attribution where input/output can't be separated
SUM(cur.line_item_unblended_cost *
(COALESCE(s.input_share, 0) + COALESCE(s.output_share, 0)) / 2.0
) AS attributed_cost_blended
FROM cur_data cur
JOIN bedrock_token_share_by_hour s
ON DATE_TRUNC('hour', CAST(cur.line_item_usage_start_date AS TIMESTAMP)) = s.usage_hour
AND cur.product_region = s.region
AND cur.line_item_account_id = s.account_id
AND REPLACE(
REGEXP_EXTRACT(cur.line_item_resource_id, 'foundation-model/(.+)$', 1),
':[\d]+$', ''
) = s.model_id
WHERE
cur.line_item_product_code = 'AmazonBedrock'
AND cur.line_item_line_item_type IN ('Usage', 'SavingsPlanCoveredUsage')
GROUP BY 1, 2, 3, 4
ORDER BY 1 DESC, attributed_cost_blended DESC
;
Caveat on the join: CUR hourly granularity means one CUR row covers up to 3,600 seconds of calls from potentially many principals. The token-share apportionment is proportional, not exact. For teams with bursty usage patterns within the same hour, this introduces error. Weekly or monthly aggregates are reliable; per-request attribution is not possible from CUR alone.
4. Application Inference Profiles (AIPs) for Retroactive Segmentation
An Application Inference Profile is a named routing layer over a foundation model. When you create an AIP and tag it, every InvokeModel call through that AIP’s ARN produces CUR line items that carry the AIP’s tags.
The retroactive strategy: create AIPs now, migrate traffic, and accept that the boundary is the migration date.
# Create an AIP for the platform team
aws bedrock create-inference-profile \
--inference-profile-name "platform-team-claude-sonnet" \
--description "Platform team traffic — tagged for FinOps" \
--model-source '{"copyFrom": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"}' \
--tags '{"team": "platform", "cost-center": "CC-1042", "env": "prod"}' \
--region us-east-1
The returned inferenceProfileArn replaces the foundation model ARN in application code. From the migration date forward, CUR shows the AIP ARN as line_item_resource_id and the tags appear in resource_tags_user_team, resource_tags_user_cost_center, etc.
What the before/after CUR looks like:
Before migration — CUR line item:
line_item_resource_id: arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0
resource_tags_user_team: (NULL)
resource_tags_user_cost_center: (NULL)
line_item_unblended_cost: 47.23
After migration — CUR line item:
line_item_resource_id: arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/xxxxxxxxxx
resource_tags_user_team: platform
resource_tags_user_cost_center: CC-1042
line_item_unblended_cost: 47.23
The hard boundary: AIPs are not retroactive. Historical spend against the foundation model ARN does not get re-attributed to the AIP. The CloudTrail join approach handles the historical window; AIPs handle the forward window. These two strategies are complementary, not competing.
Important operational note: Cross-region inference profiles (system-managed, starting with us.) behave differently from application inference profiles (account-managed). Only application inference profiles carry user-defined tags in CUR. System inference profiles show the system ARN with no user tag surface.
5. Bedrock Projects as an Attribution Mechanism
AWS Bedrock Projects (GA in late 2025) provide a resource container that groups agents, knowledge bases, and flow definitions under a single project ARN. Project-level tags flow to CUR for charges incurred by resources within the project.
The attribution model is asset-centric rather than call-centric: charges for an Agent invocation inherit the Agent’s project assignment tags. This is a cleaner attribution path than AIPs for Agent workloads because the resource (the Agent) is persistent and taggable, whereas an AIP requires active traffic routing.
Creating a project and assigning resources:
aws bedrock create-project \
--name "data-platform-rag-v2" \
--description "RAG pipeline for data platform team" \
--tags '{"team": "data-platform", "cost-center": "CC-2091", "quarter": "Q1-2026"}'
# Assign an existing agent to the project
aws bedrock update-agent \
--agent-id "ABCD1234EF" \
--project-arn "arn:aws:bedrock:us-east-1:123456789012:project/xxxxxxxxxx"
What Bedrock Projects cannot fix retroactively: The project assignment tag does not backfill historical Agent invocation charges. Like all CUR tagging, it applies from the assignment date forward. For pre-assignment spend on Bedrock Agents, the CloudTrail join approach applies — Agent invocations appear in CloudTrail as InvokeAgent events with the same IAM principal structure.
6. S3 Access Log Retroactive Analysis
If Bedrock workloads interact with S3 (e.g., Knowledge Base document retrieval, output storage, batch inference manifests), S3 server access logs provide a parallel attribution signal.
The approach:
S3 server access logs record Requester (IAM ARN), Key, Operation, Time, Bytes Sent, and HTTP Status for every object request. If your Bedrock KB or batch job uses dedicated S3 prefixes per team, the logs give you a workload fingerprint correlated with Bedrock invocation timestamps.
This is a secondary signal, not a primary one. Its value is disambiguation: when two teams share the same IAM role (making the CloudTrail principal join ambiguous), their S3 access patterns against team-specific prefixes can disambiguate which team’s Bedrock call was which.
Enabling retroactive S3 access log analysis requires that logging was enabled on the bucket. If it was not enabled, this signal is unavailable. Check with:
aws s3api get-bucket-logging --bucket your-bedrock-kb-bucket
If LoggingEnabled is absent from the response, the logs do not exist and this approach is not available for the historical window.
7. CloudTrail Athena Integration: Full View for Team Attribution
The following is a production-ready Athena view that joins CloudTrail additionalEventData to CUR for a complete retroactive team attribution table. This extends the basic join from Section 3 with better principal normalization, model family grouping, and cost rollup.
-- Full retroactive attribution view
-- Run this after loading both cloudtrail_logs and cur_data tables
CREATE OR REPLACE VIEW bedrock_retroactive_attribution AS
WITH
-- Step 1: Parse all Bedrock invocations from CloudTrail
ct_invocations AS (
SELECT
from_iso8601_timestamp(eventTime) AS event_ts,
DATE_TRUNC('hour', from_iso8601_timestamp(eventTime)) AS usage_hour,
DATE_TRUNC('day', from_iso8601_timestamp(eventTime)) AS usage_day,
awsRegion AS region,
recipientAccountId AS account_id,
eventName,
-- Full model ID from request
json_extract_scalar(requestParameters, '$.modelId') AS raw_model_id,
-- Normalized model family for CUR matching
CASE
WHEN json_extract_scalar(requestParameters, '$.modelId') LIKE '%claude-3-5-sonnet%' THEN 'claude-3-5-sonnet'
WHEN json_extract_scalar(requestParameters, '$.modelId') LIKE '%claude-3-5-haiku%' THEN 'claude-3-5-haiku'
WHEN json_extract_scalar(requestParameters, '$.modelId') LIKE '%claude-3-opus%' THEN 'claude-3-opus'
WHEN json_extract_scalar(requestParameters, '$.modelId') LIKE '%titan%' THEN 'titan'
WHEN json_extract_scalar(requestParameters, '$.modelId') LIKE '%llama%' THEN 'llama'
ELSE json_extract_scalar(requestParameters, '$.modelId')
END AS model_family,
-- IAM principal normalization: prefer role ARN over session ARN
COALESCE(
userIdentity.sessionContext.sessionIssuer.arn,
userIdentity.arn,
'unknown'
) AS principal_arn,
-- Team extraction: adjust regex to your IAM naming convention
COALESCE(
REGEXP_EXTRACT(
COALESCE(
userIdentity.sessionContext.sessionIssuer.userName,
userIdentity.arn
),
'^([a-z][a-z0-9-]+)-',
1
),
'unattributed'
) AS team,
-- Token counts
CAST(COALESCE(
json_extract_scalar(additionalEventData, '$.inputTokenCount'), '0'
) AS BIGINT) AS input_tokens,
CAST(COALESCE(
json_extract_scalar(additionalEventData, '$.outputTokenCount'), '0'
) AS BIGINT) AS output_tokens,
-- HTTP error filter: don't attribute failed calls
CAST(
json_extract_scalar(responseElements, '$.httpStatusCode')
AS INT
) AS http_status
FROM cloudtrail_logs
WHERE
eventSource = 'bedrock.amazonaws.com'
AND eventName IN (
'InvokeModel', 'InvokeModelWithResponseStream',
'Converse', 'ConverseStream'
)
),
-- Step 2: Filter successful calls only, aggregate by hour + team + model
ct_hourly AS (
SELECT
usage_hour,
usage_day,
region,
account_id,
model_family,
team,
principal_arn,
SUM(input_tokens) AS team_input_tokens,
SUM(output_tokens) AS team_output_tokens,
COUNT(*) AS call_count
FROM ct_invocations
WHERE http_status IS NULL OR http_status < 400
GROUP BY 1, 2, 3, 4, 5, 6, 7
),
-- Step 3: Compute per-hour totals for share calculation
ct_hourly_totals AS (
SELECT
usage_hour,
region,
account_id,
model_family,
SUM(team_input_tokens) AS hour_total_input,
SUM(team_output_tokens) AS hour_total_output
FROM ct_hourly
GROUP BY 1, 2, 3, 4
),
-- Step 4: Compute attribution shares
ct_shares AS (
SELECT
h.*,
t.hour_total_input,
t.hour_total_output,
CAST(h.team_input_tokens AS DOUBLE) / NULLIF(t.hour_total_input, 0) AS input_share,
CAST(h.team_output_tokens AS DOUBLE) / NULLIF(t.hour_total_output, 0) AS output_share
FROM ct_hourly h
JOIN ct_hourly_totals t
ON h.usage_hour = t.usage_hour
AND h.region = t.region
AND h.account_id = t.account_id
AND h.model_family = t.model_family
),
-- Step 5: Pull CUR Bedrock line items
cur_bedrock AS (
SELECT
line_item_account_id,
line_item_usage_start_date,
DATE_TRUNC('hour', CAST(line_item_usage_start_date AS TIMESTAMP)) AS usage_hour,
product_region,
line_item_resource_id,
line_item_usage_type,
line_item_line_item_type,
line_item_unblended_cost,
line_item_usage_amount,
-- Classify input vs output from usage type
CASE
WHEN line_item_usage_type LIKE '%InputTokens%' THEN 'input'
WHEN line_item_usage_type LIKE '%OutputTokens%' THEN 'output'
ELSE 'other'
END AS token_direction,
-- Extract model family from resource ARN
CASE
WHEN line_item_resource_id LIKE '%claude-3-5-sonnet%' THEN 'claude-3-5-sonnet'
WHEN line_item_resource_id LIKE '%claude-3-5-haiku%' THEN 'claude-3-5-haiku'
WHEN line_item_resource_id LIKE '%claude-3-opus%' THEN 'claude-3-opus'
WHEN line_item_resource_id LIKE '%titan%' THEN 'titan'
WHEN line_item_resource_id LIKE '%llama%' THEN 'llama'
ELSE 'other'
END AS model_family
FROM cur_data
WHERE
line_item_product_code = 'AmazonBedrock'
AND line_item_line_item_type IN ('Usage', 'SavingsPlanCoveredUsage')
AND line_item_unblended_cost > 0
)
-- Step 6: Final attribution join
SELECT
DATE_TRUNC('month', c.usage_hour) AS attribution_month,
s.team,
s.principal_arn,
c.line_item_account_id AS account_id,
c.product_region AS region,
c.model_family,
c.token_direction,
c.line_item_usage_type,
-- Apply share to CUR cost
SUM(CASE
WHEN c.token_direction = 'input' THEN c.line_item_unblended_cost * s.input_share
WHEN c.token_direction = 'output' THEN c.line_item_unblended_cost * s.output_share
ELSE c.line_item_unblended_cost * ((COALESCE(s.input_share,0) + COALESCE(s.output_share,0)) / 2.0)
END) AS attributed_cost,
SUM(c.line_item_unblended_cost) AS total_line_cost,
SUM(s.call_count) AS attributed_calls
FROM cur_bedrock c
JOIN ct_shares s
ON c.usage_hour = s.usage_hour
AND c.product_region = s.region
AND c.line_item_account_id = s.account_id
AND c.model_family = s.model_family
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8
ORDER BY 1 DESC, attributed_cost DESC
;
8. The 90-Day CloudTrail Default Retention Problem
By default, CloudTrail event history in the console retains 90 days of management events. This is the free tier and it covers read/write management events — but crucially, InvokeModel is a data event, not a management event.
Data event retention reality:
- CloudTrail management event history (free): 90 days, management events only
- CloudTrail data events: not retained at all unless explicitly configured
- CloudTrail Lake: configurable retention from 7 days to 7 years, ingests both management and data events
- S3-delivered CloudTrail logs: indefinite retention (you pay S3 costs), data events included if configured
If data event logging was not configured before your retroactive window:
The CloudTrail Athena join approach only works if InvokeModel data events were being logged to S3 (or CloudTrail Lake) during the period. To check:
aws cloudtrail get-event-selectors --trail-name your-trail-name
Look for a selector with DataResources targeting AWS::Bedrock::* or arn:aws:bedrock. If absent, data events for Bedrock were not captured, and the CloudTrail signal is unavailable for the historical window.
CloudTrail Lake for Longer-Term Retroactive Work
CloudTrail Lake is the managed analytics store for CloudTrail. It provides SQL-queryable access to event history without the S3 → Athena setup complexity, and supports retention beyond 90 days.
Setting up Bedrock data event logging in CloudTrail Lake:
# Create an event data store with 90-day retention
aws cloudtrail create-event-data-store \
--name bedrock-invocations-eds \
--advanced-event-selectors '[
{
"Name": "BedrockDataEvents",
"FieldSelectors": [
{"Field": "eventCategory", "Equals": ["Data"]},
{"Field": "resources.type", "Equals": ["AWS::Bedrock::InferenceProfile"]},
{"Field": "eventSource", "Equals": ["bedrock.amazonaws.com"]}
]
}
]' \
--retention-period 365 \
--termination-protection-enabled
Querying CloudTrail Lake directly (no Athena setup required):
-- CloudTrail Lake query syntax (run via aws cloudtrail start-query)
SELECT
eventTime,
userIdentity.sessionContext.sessionIssuer.arn AS role_arn,
requestParameters.modelId AS model_id,
additionalEventData.inputTokenCount AS input_tokens,
additionalEventData.outputTokenCount AS output_tokens,
awsRegion
FROM $EDS_ID
WHERE
eventSource = 'bedrock.amazonaws.com'
AND eventName IN ('InvokeModel', 'Converse')
AND eventTime > '2026-01-01 00:00:00'
AND eventTime < '2026-06-01 00:00:00'
ORDER BY eventTime DESC
The critical limitation: CloudTrail Lake does not backfill. It begins ingesting from activation date. For periods before Lake was activated, you need S3-delivered logs if they exist, or the cost is unrecoverable from this signal.
9. Practical 30-Day Retroactive Attribution Playbook
For a team that had zero tagging and needs to attribute the last 30 days of Bedrock spend.
Day 1: Audit What Signals Exist
Check 1: Is IAM principal cost allocation enabled?
aws ce get-cost-allocation-tags --status Active | grep -i iam
If enabled, skip to Step 4. If not, continue.
Check 2: Does CloudTrail have Bedrock data events in S3?
aws cloudtrail get-event-selectors --trail-name $(aws cloudtrail describe-trails --query 'trailList[0].Name' --output text)
Look for bedrock.amazonaws.com data event selectors. If present, proceed with CloudTrail join. If absent, the signal is gone and you are bounded to Cost Categories on account split only.
Check 3: What’s the account structure?
aws organizations list-accounts --query 'Accounts[*].[Id,Name]' --output table
If teams have separate AWS accounts, Cost Categories on linked account is the simplest retroactive path and does not require any CloudTrail work.
Day 2–3: Set Up Athena Tables
If CloudTrail data event logs exist in S3:
- Identify the S3 bucket and prefix: typically
s3://your-cloudtrail-bucket/AWSLogs/{account-id}/CloudTrail/{region}/{year}/{month}/{day}/ - Create Athena table using the DDL from Section 7
- Run
MSCK REPAIR TABLE cloudtrail_logsto load partitions - Verify with a count query scoped to the last 30 days
If CUR is not yet in Athena, enable the CUR delivery to S3 and create the Athena table. Note: CUR setup has a 24-hour delay before the first delivery; you may have historical CUR already in S3 if it was previously configured.
Day 4–5: Validate CloudTrail Token Counts Against CUR
Before attributing costs, validate that CloudTrail token counts reconcile with CUR usage amounts within acceptable tolerance:
-- Reconciliation check: CloudTrail tokens vs CUR tokens, last 30 days
WITH ct_totals AS (
SELECT
model_family,
SUM(input_tokens) AS ct_input,
SUM(output_tokens) AS ct_output
FROM bedrock_invocations
WHERE usage_hour >= CURRENT_TIMESTAMP - INTERVAL '30' DAY
GROUP BY 1
),
cur_totals AS (
SELECT
model_family,
SUM(CASE WHEN token_direction = 'input' THEN line_item_usage_amount ELSE 0 END) AS cur_input,
SUM(CASE WHEN token_direction = 'output' THEN line_item_usage_amount ELSE 0 END) AS cur_output
FROM cur_bedrock
WHERE usage_hour >= CURRENT_TIMESTAMP - INTERVAL '30' DAY
GROUP BY 1
)
SELECT
ct.model_family,
ct.ct_input,
cur.cur_input,
ABS(ct.ct_input - cur.cur_input) / NULLIF(cur.cur_input, 0) * 100 AS input_delta_pct,
ct.ct_output,
cur.cur_output,
ABS(ct.ct_output - cur.cur_output) / NULLIF(cur.cur_output, 0) * 100 AS output_delta_pct
FROM ct_totals ct
FULL OUTER JOIN cur_totals cur USING (model_family)
ORDER BY model_family
;
If delta exceeds 10%, investigate before proceeding. Common causes:
- CloudTrail data events not fully configured (missing some API call types)
- Provisioned Throughput usage (billed differently, may not appear in CloudTrail data events the same way)
- Batch inference jobs (separate API path:
CreateModelInvocationJob)
Day 6–7: Build Cost Categories
Using the IAM principal patterns identified from CloudTrail, create Cost Categories in the Billing console with 12-month lookback:
- Billing → Cost Categories → Create cost category
- Name:
BedrockTeamAttribution - Add rules for each team, matching IAM role ARN patterns
- Enable split charges: set
Default valuetounattributed - Save — AWS processes the 12-month lookback within 24 hours
Day 8: Migrate Traffic to AIPs
For each active workload:
- Create a tagged AIP per team (Section 4 commands)
- Update application code to reference AIP ARN instead of foundation model ARN
- Verify new CUR line items show AIP ARN and tags within 24 hours
From this point forward, CUR is self-attributing and no CloudTrail join is needed.
Day 9–10: Produce the Attribution Report
Run the full attribution view from Section 7 scoped to the last 30 days. Export to CSV for finance. Annotate the report with:
- Attribution method: CloudTrail proportional join (estimated ±X%)
- Coverage: % of total Bedrock spend that could be attributed vs. unattributed remainder
- Effective date of forward attribution: AIP migration date
10. Cost Categories as the Retroactive Safety Net
Even when CloudTrail signals are unavailable, Cost Categories on linked account often provide a workable (if coarse) retroactive attribution. The hierarchy of retroactive options by effort and precision:
| Method | Precision | Effort | Lookback |
|---|---|---|---|
| Linked account split (Cost Categories) | Team-level if accounts are team-aligned | Low | 12 months |
| IAM principal (Cost Categories, if opt-in enabled) | Role-level | Low | 12 months (from opt-in date) |
| CloudTrail proportional join | Role-level ±2–5% | High | S3 log retention period |
| CloudTrail Lake query | Role-level ±2–5% | Medium | Lake retention period |
| AIP migration (forward only) | Exact from migration date | Medium | Forward only |
| S3 access log correlation | Approximate, secondary | Medium | S3 log retention |
Cost Category IAM principal rules are the most underused tool here. If you know your IAM role naming convention maps to teams (e.g., all ml-* roles belong to the ML team), a single Cost Category with regex rules covers the entire 12-month lookback immediately.
The Billing console enforces a limit of 50 Cost Categories per account and 100 rules per category. For organizations with many teams, consolidate rules using prefix matching and use the Default value bucket to catch unmatched spend.
11. What’s Truly Unrecoverable: Bounding the Unknown
Some spend cannot be attributed retroactively regardless of effort. Being explicit about this prevents finance teams from treating estimates as exact.
Permanently Unrecoverable
Direct InvokeModel with no CloudTrail data events configured: If CloudTrail data event logging for Bedrock was not enabled during the historical period, there is no AWS-native record of which IAM principal made which API call. The CUR shows the model ARN and token count; the who is gone.
Shared IAM roles used by multiple teams:
If three teams share arn:aws:iam::account:role/shared-bedrock-role, CloudTrail will show that role as the principal but cannot distinguish which team’s code was executing. Without application-level request metadata (user-agent strings, request IDs tied to application logs), the spend under that role cannot be split more granularly than “shared-role workloads.”
Provisioned Throughput not yet tagged: Provisioned Throughput units have a fixed hourly charge that appears in CUR as the PT resource ARN. If the PT was not tagged, and it was shared across teams, the proportional split requires knowing each team’s invocation volume through that PT — which requires CloudTrail data events.
Bedrock batch inference jobs (CreateModelInvocationJob) without job-level tracking:
Batch inference appears in CloudTrail as CreateModelInvocationJob with a job ARN. If the job naming convention does not encode team, and IAM roles are shared, batch costs cannot be attributed retroactively.
Quantifying the Unattributable Floor
Run this query to bound the unrecoverable spend:
-- What fraction of Bedrock spend has no CloudTrail match?
SELECT
attribution_month,
SUM(attributed_cost) AS attributed,
SUM(total_line_cost) AS total,
SUM(total_line_cost) - SUM(attributed_cost) AS unattributed,
ROUND(100.0 * (SUM(total_line_cost) - SUM(attributed_cost))
/ NULLIF(SUM(total_line_cost), 0), 1) AS unattributed_pct
FROM bedrock_retroactive_attribution
GROUP BY 1
ORDER BY 1 DESC
;
Typical ranges from production deployments: 5–15% unattributed when CloudTrail data events were configured. 60–90% unattributed when they were not. The delta between those two numbers is the cost of not enabling data event logging at deployment time.
The Practical Recommendation for Finance
Present retroactive attribution with explicit confidence intervals:
- High confidence (±2%): Cost Categories on linked accounts, IAM principal with opt-in enabled
- Medium confidence (±5–10%): CloudTrail proportional join with token reconciliation
- Low confidence / estimated: Shared-role splits, PT without invocation logs
- Unattributable: Report as a named line item; do not distribute it silently
Distributing unattributable spend proportionally across teams (a common workaround) creates incentive distortions — teams that invested in tagging see inflated costs because they absorb the untagged workloads’ share. Better to leave unattributable spend visible and use it as the business case for the AIP migration and CloudTrail data event enforcement going forward.
Key References
- AWS Documentation: Cost Allocation Tags — Activation Behavior
- AWS Documentation: Cost Categories — Lookback Period
- AWS Documentation: Bedrock — Supported CloudTrail Data Event Types
- AWS Documentation: Application Inference Profiles
- AWS Documentation: CloudTrail Lake — Query and Retention
- AWS re:Invent 2025 — FinOps for Generative AI (session COP323): AIP tagging demo and CUR walkthrough