Skip to content

Commit 0d74000

Browse files
authored
feat(aws): add Bedrock token pricing collector (#921)
1 parent bdf763d commit 0d74000

13 files changed

Lines changed: 982 additions & 19 deletions

File tree

docs/metrics/aws/bedrock.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# AWS Bedrock Metrics
2+
3+
| Metric name | Metric type | Description | Labels |
4+
|-----------------------------------------------------------|-------------|---------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
5+
| `cloudcost_aws_bedrock_token_input_usd_per_1k_tokens` | Gauge | AWS Bedrock input token price in USD per 1000 tokens | `account_id`=<AWS account ID> <br/> `region`=<AWS region> <br/> `model_id`=<model slug> <br/> `family`=<model provider> <br/> `price_tier`=<on_demand\|on_demand_batch\|on_demand_flex\|on_demand_priority\|cross_region> |
6+
| `cloudcost_aws_bedrock_token_output_usd_per_1k_tokens` | Gauge | AWS Bedrock output token price in USD per 1000 tokens | `account_id`=<AWS account ID> <br/> `region`=<AWS region> <br/> `model_id`=<model slug> <br/> `family`=<model provider> <br/> `price_tier`=<on_demand\|on_demand_batch\|on_demand_flex\|on_demand_priority\|cross_region> |
7+
| `cloudcost_aws_bedrock_search_unit_usd_per_1k_search_units` | Gauge | AWS Bedrock search unit price in USD per 1000 search units (e.g. Cohere Rerank) | `account_id`=<AWS account ID> <br/> `region`=<AWS region> <br/> `model_id`=<model slug> <br/> `family`=<model provider> <br/> `price_tier`=<on_demand\|cross_region> |
8+
9+
## Overview
10+
11+
The Bedrock collector exports list-price token cost metrics for AWS Bedrock foundation models across all configured regions. These are pricing rates, not measured spend. Multiply rates by token usage (e.g. from CloudWatch `AWS/Bedrock` metrics) to compute estimated cost.
12+
13+
## Configuration
14+
15+
Add `bedrock` to your AWS services configuration:
16+
17+
```yaml
18+
aws:
19+
services: ["bedrock"]
20+
regions: ["us-east-1", "us-west-2"]
21+
```
22+
23+
Or via command line:
24+
```bash
25+
--aws.services=bedrock
26+
```
27+
28+
## Labels
29+
30+
- **`account_id`**: AWS account ID (12-digit), resolved via STS `GetCallerIdentity`
31+
- **`region`**: AWS region for which the price applies
32+
- **`model_id`**: Model slug from the AWS Pricing API `usagetype` field (e.g. `Claude3Sonnet`, `Llama4-Scout-17B`, `Nova2.0Pro`)
33+
- **`family`**: Model provider, lowercased with spaces replaced by underscores (e.g. `anthropic`, `amazon`, `meta`, `mistral_ai`). Models with no provider attribute use `amazon`.
34+
- **`price_tier`**: Inference tier: `on_demand`, `on_demand_batch`, `on_demand_flex`, `on_demand_priority`, or `cross_region`
35+
36+
## Notes
37+
38+
- Pricing data is fetched from the AWS Pricing API (`us-east-1` endpoint) and refreshed every 24 hours
39+
- Image, video, audio, cache, and guardrail SKUs are skipped; only text token SKUs are emitted
40+
- `model_id` is the pricing SKU slug, not the canonical Bedrock model ARN
41+
42+
## IAM Permissions
43+
44+
Required permissions for Bedrock metrics collection:
45+
46+
```json
47+
{
48+
"Version": "2012-10-17",
49+
"Statement": [
50+
{
51+
"Effect": "Allow",
52+
"Action": [
53+
"pricing:GetProducts"
54+
],
55+
"Resource": "*"
56+
}
57+
]
58+
}
59+
```

pkg/aws/aws.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/prometheus/client_golang/prometheus"
2424
"golang.org/x/sync/errgroup"
2525

26+
"github.com/grafana/cloudcost-exporter/pkg/aws/bedrock"
2627
"github.com/grafana/cloudcost-exporter/pkg/aws/client"
2728
ec2Collector "github.com/grafana/cloudcost-exporter/pkg/aws/ec2"
2829
"github.com/grafana/cloudcost-exporter/pkg/aws/elb"
@@ -87,13 +88,14 @@ const (
8788
collectConcurrencyLimit = 10
8889

8990
// AWS service names used across the AWS provider.
90-
serviceS3 = "S3"
91-
serviceEC2 = "EC2"
92-
serviceRDS = "RDS"
93-
serviceNATGW = "NATGATEWAY"
94-
serviceELB = "ELB"
95-
serviceVPC = "VPC"
96-
serviceMSK = "MSK"
91+
serviceS3 = "S3"
92+
serviceEC2 = "EC2"
93+
serviceRDS = "RDS"
94+
serviceNATGW = "NATGATEWAY"
95+
serviceELB = "ELB"
96+
serviceVPC = "VPC"
97+
serviceMSK = "MSK"
98+
serviceBedrock = "BEDROCK"
9799
)
98100

99101
func New(ctx context.Context, config *Config) (*AWS, error) {
@@ -256,6 +258,29 @@ func newWithDependencies(ctx context.Context, config *Config, awsClient client.C
256258
continue
257259
}
258260
collectors = append(collectors, collector)
261+
case serviceBedrock:
262+
// Bedrock pricing lookups succeed regardless of the collector's configured regions.
263+
// Note: this pins the *endpoint*, not the queried region — the collector still
264+
// fetches prices per configured region via a regionCode filter. See
265+
// pkg/aws/bedrock.go newPriceFetcher().
266+
bedrockPricingConfig := awsConfig.Copy()
267+
bedrockPricingConfig.Region = "us-east-1"
268+
awsBedrockClient := client.NewAWSClient(client.Config{
269+
PricingService: awsPricing.NewFromConfig(bedrockPricingConfig),
270+
})
271+
collector, err := bedrock.New(ctx, &bedrock.Config{
272+
Regions: regions,
273+
PricingClient: awsBedrockClient,
274+
Logger: logger,
275+
AccountID: config.AccountID,
276+
})
277+
if err != nil {
278+
logger.LogAttrs(ctx, slog.LevelError, "Error creating collector",
279+
slog.String("service", service),
280+
slog.String("message", err.Error()))
281+
continue
282+
}
283+
collectors = append(collectors, collector)
259284
default:
260285
logger.LogAttrs(ctx, slog.LevelWarn, "unknown server, skipping",
261286
slog.String("service", service),

pkg/aws/aws_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ func Test_NewWithDependencies(t *testing.T) {
183183
// The collector is skipped gracefully; newWithDependencies still succeeds.
184184
expectedCollectors: 0,
185185
},
186+
{
187+
name: "Bedrock service is skipped gracefully when pricing API unavailable",
188+
services: []string{"BEDROCK"},
189+
regions: []types.Region{
190+
{RegionName: stringPtr("us-east-1")},
191+
},
192+
setupRegionClients: map[string]client.Client{},
193+
// Bedrock uses its own dedicated pricing client (not the injected awsClient),
194+
// so collector creation fails without real AWS credentials in tests.
195+
// The collector is skipped gracefully; newWithDependencies still succeeds.
196+
expectedCollectors: 0,
197+
},
186198
}
187199

188200
for _, tt := range tests {
@@ -605,8 +617,12 @@ func Test_AllCostMetricDescsIncludeAccountID(t *testing.T) {
605617

606618
// Create provider with all services that implement Describe.
607619
// S3 and RDS return nil from Describe, so they won't contribute Descs,
608-
// but EC2, ELB, NATGATEWAY, VPC, and MSK all do.
609-
allServices := []string{serviceEC2, serviceELB, serviceNATGW, serviceVPC, serviceMSK}
620+
// but EC2, ELB, NATGATEWAY, VPC, MSK, and Bedrock all do.
621+
// Note: ELB, VPC, and NATGATEWAY call createAWSConfig which requires real credentials;
622+
// Bedrock and MSK create their own pricing clients that also need credentials.
623+
// Without credentials these collectors are skipped, but the test still verifies
624+
// that any collector which does initialize exposes account_id on all its Descs.
625+
allServices := []string{serviceEC2, serviceELB, serviceNATGW, serviceVPC, serviceMSK, serviceBedrock}
610626
config := &Config{
611627
Services: allServices,
612628
Region: "us-east-1",

0 commit comments

Comments
 (0)