← Agent Frameworks 🕐 13 min read
Agent Frameworks

Bedrock Retroactive Cost Attribution — Strategies When Tags Weren't Applied (2026)

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.

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-InputTokens vs USE1-Bedrock-OutputTokens for 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 principal
  • requestParameters.modelId — which model was called
  • additionalEventData.inputTokenCount and additionalEventData.outputTokenCount — actual token counts
  • awsRegion
  • recipientAccountId

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 ARN
  • line_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:

  1. Identify the S3 bucket and prefix: typically s3://your-cloudtrail-bucket/AWSLogs/{account-id}/CloudTrail/{region}/{year}/{month}/{day}/
  2. Create Athena table using the DDL from Section 7
  3. Run MSCK REPAIR TABLE cloudtrail_logs to load partitions
  4. 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:

  1. Billing → Cost Categories → Create cost category
  2. Name: BedrockTeamAttribution
  3. Add rules for each team, matching IAM role ARN patterns
  4. Enable split charges: set Default value to unattributed
  5. Save — AWS processes the 12-month lookback within 24 hours

Day 8: Migrate Traffic to AIPs

For each active workload:

  1. Create a tagged AIP per team (Section 4 commands)
  2. Update application code to reference AIP ARN instead of foundation model ARN
  3. 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