こんにちは。SCSK渡辺(大)です。
Claude on Amazon Bedrockの利用コストに蓋をするCloudFormationテンプレートを作ってみました。
CloudFormationテンプレートたった1つで、ユーザー/ロール(チーム)単位のコスト監視・通知・自動遮断・セルフサービスダッシュボードが全部動くようにしました。HTMLファイルの別途配置も不要です。
誰に嬉しいのか
「BedrockでClaude Code / Claude Desktopを検証開始したいが、コスト青天井が怖くて上司の許可が下りない」という方向けです。
上司への説明例:
- ユーザーごと、またはチームのロールごとに月額上限を設定し、超過したら自動的にAPI利用を遮断します
- 検知から遮断まで最大16分の遅延がありますが、暴走を放置するリスクと比較して十分実用的です
- 日次でコストレポートがメールで届くため、利用状況を常に把握できます
- 全てCloudFormation管理のため、不要になればスタック削除で即撤去できます(一部手動で撤去)
- このシステム自体の運用コストは小規模利用であれば月額数ドル程度です
向かない人:
- 50名超の大規模チーム → LiteLLMやAWS公式GenAI Gateway等のプロキシ方式を推奨
- 厳密なコスト上限保証が必要 → 最大16分の遅延があるため、1円単位の厳密な上限制御には不向き
- IAM Identity Center(SSO)で直接Bedrockを利用しており、自動遮断まで必要 → Protected roleの制約で遮断不可
このシステムで何ができるようになるか
デプロイ後すぐに得られる機能:
- 💰 ユーザー/チーム(ロール)単位のコスト可視化 — 誰が・どのチームがいくら使っているか、日次メールで自動把握
- 🚫 月額上限の自動遮断 — 設定額を超えたら自動検知し、IAM DenyPolicyで即座にブロック(※検知までの遅延は後述)
- 📊 リアルタイム運用ダッシュボード — モデル別の呼び出し回数・トークン量・レイテンシ・スロットリングを一画面で把握
- 🔔 スロットリング即時アラート — TPMクォータ超過でリクエストが弾かれたら即メール通知
- 🔄 月初自動リセット — 遮断されたユーザーも月初1日0:00(JST)に自動解除、運用負荷ゼロ
- 🔐 S3ログ保管(オプション) — KMS暗号化+閲覧者制限付きでBedrockログをS3に長期保管
- 📋 セルフサービスダッシュボード — 利用者が自分のコストをCognito認証で確認可能
検知遅延について
コスト超過の検知には最大約16分の遅延があります:
- CloudWatch Logsへのログ配信遅延: 5〜15分
- Lambda cap_check の実行間隔: 1分
このため、上限を超えてから遮断までの間に追加の利用が発生する可能性があります。
厳密な上限保証ではなく、急増を抑えるためのガードレールとして位置づけてください。
アーキテクチャ
主要なリソースのみ記載しています。
対応する利用形態
対応ツール
| ツール | コスト集計 | 備考 |
|---|---|---|
| Claude Code(CLI) | ✅ | requestMetadata.source で識別 |
| Claude Desktop(Chat / Cowork / Code) ※Chatは6月24時点でベータ版 |
✅ | requestMetadata.source で識別。 Desktop内のChat/Cowork/Codeの区別は不可(全て同じsource値) |
対応するIAM認証方式
| 認証方式 | コスト集計 | 上限遮断 | 備考 |
|---|---|---|---|
| aws login (セッションベース) |
✅ ユーザー単位 | ✅ | 推奨。一時クレデンシャルで安全 |
| IAMアクセスキー (長期クレデンシャル) |
✅ ユーザー単位 | ✅ | 環境変数または~/.aws/credentials |
| IAMロール(AssumeRole) | ✅ ロール単位 | ✅ | チーム共有ロールパターンを想定 |
| IAM Identity Center (SSO) |
✅(想定) | ❌ 非対応(想定) | 未検証 集計は可能と想定(identity.arnにSSOロール名が入る前提)。遮断はProtected roleのためDenyPolicy不可と想定。カスタムロールを作成し、そこにAssumeRoleして利用する構成であれば対応できる想定。 |
| Bedrock APIキー | ✅ ユーザー単位(想定) | ✅(想定) | 未検証 IAMユーザーに紐づくため動作する想定 |
運用コスト
このシステム自体の月額コスト:$2未満/月(約300円) ※小規模の場合
| リソース | 内訳 | 月額目安 |
|---|---|---|
| Lambda | 128MB / 120s timeout / 約1,441回/日(1分間隔×1440 + 日次1回) | $0.50〜1.00 |
| DynamoDB | オンデマンドモード。書き込み:ログイベントごと。読み取り:毎分+日次 | 〜$0.25 |
| SNS (Email) | 無料枠内 | $0 |
| CloudWatch Logs | Bedrock呼び出しログ保持。取り込み量に依存 | 〜$0.50/GB |
| CloudWatch Dashboard | 最初の3つまで無料 | $0 |
| CloudWatch Alarm | 標準メトリクス1個 | $0.10 |
S3ログ保管を有効にした場合の追加コスト:
| リソース | 内訳 | 月額目安 |
|---|---|---|
| S3 ストレージ | ログ量に依存(1呼び出し≒0.5KB) | 〜$0.025/GB |
| KMS | CMKキー1個 + API呼び出し | $1.00 + $0.03/10,000リクエスト |
なぜこの構成なのか — 他のコスト管理アプローチを選ばなかった理由
本テンプレートはCloudFormationコンソールにYAMLファイルを1つアップロードするだけ。
プロキシサーバー不要、コンテナ不要。
5〜20名規模の検証・導入フェーズに最適です。
大規模チーム(50名超)にはLiteLLM等への移行を推奨します。
| アプローチ | 概要 | 不採用の理由 |
|---|---|---|
| LiteLLM Proxy | 全リクエストをプロキシ経由にし、ユーザー/チーム別の予算上限・ルーティング・ログを一元管理 | EC2/ECS + RDS のインフラ構築が必要。運用コスト月$50〜。本格運用には強力だが「まず試す」には重い |
| AWS Multi-Provider GenAI Gateway | AWSが提供するLiteLLMベースのリファレンスアーキテクチャ | プロダクション向けの本格構成。CloudFormation数十スタック構成で、初期セットアップに半日〜1日かかる |
| Guidance for Claude Code with Amazon Bedrock | OpenTelemetry Collector + ECS Fargate + CloudWatch/S3 | 開発生産性メトリクスまで取れるが、Cognito + ALB + ECS の構築が必要。大規模チーム向け |
| AWS Budgets | アカウント全体の月額予算に対するアラート | ユーザー/ロール単位の粒度がない。超過時の自動遮断もできない |
チーム利用パターン
チームごとにBedrock利用ロールを作成し、メンバーにAssumeRoleさせるパターンです。
運用シーケンス
参考です。
利用者フロー
管理者フロー
セルフサービスダッシュボード
利用者が自分のClaude利用コストをブラウザから確認できるWebダッシュボードです。Cognito認証で保護されており、テンプレートデプロイ時に自動構築されます。
CloudWatch ダッシュボード
| # | パネル名 | 表示内容 | 異常時のアクション |
|---|---|---|---|
| 1 | テキストヘッダー | アカウントID・リージョン・概要 | — |
| 2 | 呼び出し回数+エラー | Invocations + ClientErrors | Errors急増 → リクエスト形式やパラメータに問題がある可能性。400系エラーはBedrock側ではなくアプリ側の不備 |
| 3 | トークン量 | Input/Output/CacheRead/CacheWrite | Output急増 → 特定ユーザーが大量生成している可能性が高い。出力トークンは入力の3〜5倍のコストがかかるため放置するとコスト急増 |
| 4 | モデル別呼び出し回数 | Opus/Sonnet/Haiku比率 | Opus偏重 → Opusは Sonnetの5倍の単価。同等品質でSonnetで足りるタスクならSonnet/Haiku切替でコスト大幅削減 |
| 5 | モデル別出力トークン | 各モデルの出力トークン比率 | 特定モデル突出 → コスト急増の予兆。CostLimitUsdの見直しが必要になる前兆 |
| 6 | レイテンシ p99 | 99パーセンタイル応答時間 | 悪化 → Bedrock側のキャパシティ逼迫。利用者体験が悪化するためクロスリージョン推論でリクエストを分散 |
| 7 | TTFT 平均 | Time To First Token | 悪化 → リクエストがキューで待たされている。TPMクォータに余裕がないためService Quotas引き上げで解消 |
| 8 | TPMクォータ消費 | Tokens Per Minute使用量 | 上限接近 → このまま増えるとスロットリング(リクエスト拒否)が発生する。事前にService Quotas引き上げ申請 |
| 9 | スロットリング | リクエスト拒否回数 | 1件でも発生 → 利用者のリクエストが実際に失敗している。即座にクォータ引き上げまたはリクエスト分散が必要 |
デプロイ方法
必要なファイル
CloudFormationテンプレート 1ファイルのみ。
そのままCloudFormationにアップロードすれば動きます。
セルフサービスダッシュボードのHTML(index.html / callback.html)はテンプレート内にインライン埋め込みされており、スタック作成時にカスタムリソースが自動でS3にアップロードします。Cognito設定値(ClientId / Domain / API URL)もCloudFormationの変数展開で自動設定されるため、手動でのファイル配置や書き換えは一切不要です。
bedrock-cost-guard.yaml(クリックで展開)
AWSTemplateFormatVersion: '2010-09-09'
Description: >-
Bedrock Cost Guard - Amazon Bedrock (Claude) per-user/role cost monitoring,
daily report, monthly budget cap with automatic Deny policy, KMS-encrypted
metadata-only invocation logging, per-user cost dashboard, and DynamoDB
aggregation for self-service usage visibility.
Parameters:
EmailAddress:
Type: String
Description: 'Email address for daily cost reports and cap alerts (SNS subscription confirmation required)'
SourceTags:
Type: String
Default: 'claude-code,claude-desktop'
Description: 'Bedrock requestMetadata.source tags to track (comma-separated, e.g. claude-code,claude-desktop)'
CostLimitUsd:
Type: String
Default: '0'
Description: 'Per-identity monthly cost cap (format: name:USD,name:USD,...). 0 = no cap.'
ExcludeUsers:
Type: String
Default: ''
Description: Users/roles to exclude from cost cap (comma-separated)
LogGroupName:
Type: String
Default: '/aws/bedrock/cost-guard'
Description: CloudWatch Logs group name for invocation logging
ResourcePrefix:
Type: String
Default: 'bedrock-cost-guard'
Description: Prefix for resource names
CostTag:
Type: String
Default: ''
Description: 'Cost allocation tag value applied to all resources (for cost tracking)'
LogReaderArns:
Type: String
Default: ''
Description: 'IAM ARNs allowed to decrypt logs (comma-separated). Empty = no restriction.'
LogRetentionDays:
Type: Number
Default: 365
Description: Log retention (days)
EnableS3Logging:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Also send Bedrock invocation logs to S3 (CWL is always enabled)'
EnableLogEncryption:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Enable KMS encryption for S3 log bucket (only when S3 logging is enabled)'
LogTextData:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Include text input/output body in logs'
LogImageData:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Include image data in logs'
LogVideoData:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Include video data in logs'
LogEmbeddingData:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Include embedding data in logs'
LogAudioData:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Include audio data in logs'
CapCheckIntervalMinutes:
Type: Number
Default: 1
Description: Cap check interval (minutes)
WarningThresholdPercent:
Type: Number
Default: 80
Description: Warning threshold (%)
DashboardDomainPrefix:
Type: String
Default: 'bedrock-cost-guard'
Description: 'Cognito domain prefix for self-service dashboard (must be globally unique across all AWS accounts)'
AllowedCidrs:
Type: String
Default: '0.0.0.0/0'
Description: 'Allowed CIDRs for self-service dashboard access (comma-separated). 0.0.0.0/0 = no IP restriction (Cognito auth only).'
JpyRate:
Type: Number
Default: 160
Description: USD to JPY conversion rate for reports
ModelPricing:
Type: String
Default: 'sonnet:3.0/15.0/3.75/0.30,opus:5.0/25.0/6.25/0.50,haiku:1.0/5.0/1.25/0.10'
Description: 'Model pricing per 1M tokens (format: tier:input/output/cache_write/cache_read, comma-separated)'
ExistingLogGroupName:
Type: String
Default: ''
Description: 'Existing CWL log group name. Empty = create new.'
ExistingCostTableName:
Type: String
Default: ''
Description: 'Existing DynamoDB table name. Empty = create new.'
ExistingSnsTopicArn:
Type: String
Default: ''
Description: 'Existing SNS topic ARN. Empty = create new.'
ExistingLogBucketName:
Type: String
Default: ''
Description: 'Existing S3 bucket for logs. Empty = create new (when S3 logging enabled).'
ExistingDashboardBucketName:
Type: String
Default: ''
Description: 'Existing S3 bucket for dashboard. Empty = create new.'
ReportScheduleHourJst:
Type: Number
Default: 9
Description: 'Hour (JST, 0-23) to send the daily cost report email'
ReportScheduleMinuteJst:
Type: Number
Default: 0
Description: 'Minute (0-59) to send the daily cost report email'
Conditions:
HasLogReaderRestriction:
!Not [!Equals [!Ref LogReaderArns, '']]
UseS3:
!Equals [!Ref EnableS3Logging, 'true']
UseKms:
!And
- !Equals [!Ref EnableS3Logging, 'true']
- !Equals [!Ref EnableLogEncryption, 'true']
UseKmsWithReaderRestriction:
!And
- !Condition UseKms
- !Condition HasLogReaderRestriction
CreateLogGroup:
!Equals [!Ref ExistingLogGroupName, '']
CreateCostTable:
!Equals [!Ref ExistingCostTableName, '']
CreateSnsTopic:
!Equals [!Ref ExistingSnsTopicArn, '']
CreateLogBucket:
!And
- !Condition UseS3
- !Equals [!Ref ExistingLogBucketName, '']
CreateDashboardBucket:
!Equals [!Ref ExistingDashboardBucketName, '']
Resources:
# ============================================================
# 1. KMS Key for S3 log encryption (created only when S3+KMS enabled)
# ============================================================
LogsKmsKey:
Type: AWS::KMS::Key
Condition: UseKms
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
Description: !Sub 'CMK for ${ResourcePrefix} Bedrock invocation log S3 encryption'
EnableKeyRotation: true
KeyPolicy:
Version: '2012-10-17'
Statement:
- Sid: EnableRootAccount
Effect: Allow
Principal:
AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
Action: 'kms:*'
Resource: '*'
- Sid: AllowBedrockServiceWrite
Effect: Allow
Principal:
Service: bedrock.amazonaws.com
Action:
- kms:GenerateDataKey
Resource: '*'
Condition:
StringEquals:
'aws:SourceAccount': !Ref 'AWS::AccountId'
ArnLike:
'aws:SourceArn': !Sub 'arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:*'
- !If
- HasLogReaderRestriction
- Sid: AllowLogReaderDecrypt
Effect: Allow
Principal:
AWS: !Split [',', !Ref LogReaderArns]
Action:
- kms:Decrypt
- kms:DescribeKey
Resource: '*'
- !Ref 'AWS::NoValue'
- !If
- UseKmsWithReaderRestriction
- Sid: DenyDecryptExceptAllowedPrincipals
Effect: Deny
Principal:
AWS: '*'
Action:
- kms:Decrypt
Resource: '*'
Condition:
StringNotLike:
'aws:PrincipalArn': !Split [',', !Ref LogReaderArns]
ArnNotLike:
'aws:PrincipalArn':
- !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ResourcePrefix}-lambda-role'
- !Sub 'arn:aws:iam::${AWS::AccountId}:root'
- !Ref 'AWS::NoValue'
Tags:
- Key: Cost
Value: !Ref CostTag
LogsKmsAlias:
Type: AWS::KMS::Alias
Condition: UseKms
Properties:
AliasName: !Sub 'alias/${ResourcePrefix}-logs'
TargetKeyId: !Ref LogsKmsKey
# ============================================================
# 1b. S3 Bucket for Bedrock invocation logs (created only when S3 destination selected)
# ============================================================
LogBucket:
Type: AWS::S3::Bucket
Condition: CreateLogBucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub '${ResourcePrefix}-logs-${AWS::AccountId}'
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: !If [UseKms, 'aws:kms', AES256]
KMSMasterKeyID: !If [UseKms, !GetAtt LogsKmsKey.Arn, !Ref 'AWS::NoValue']
BucketKeyEnabled: !If [UseKms, true, !Ref 'AWS::NoValue']
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: ExpireLogs
Status: Enabled
ExpirationInDays: !Ref LogRetentionDays
Tags:
- Key: Cost
Value: !Ref CostTag
# ============================================================
# 2. Log Group for Bedrock Model Invocation Logging (no KMS, retained)
# ============================================================
BedrockInvocationLogGroup:
Type: AWS::Logs::LogGroup
Condition: CreateLogGroup
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
LogGroupName: !Ref LogGroupName
RetentionInDays: !Ref LogRetentionDays
Tags:
- Key: Cost
Value: !Ref CostTag
# 3. IAM Role for Bedrock to write to CloudWatch Logs
BedrockLoggingRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ResourcePrefix}-logging-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: bedrock.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: WriteToLogGroup
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroupName}:*'
Tags:
- Key: Cost
Value: !Ref CostTag
# 3b. Custom Resource to configure Bedrock Model Invocation Logging
BedrockLoggingConfig:
Type: AWS::CloudFormation::CustomResource
DependsOn:
- SubscriptionFilterPermission
Properties:
ServiceToken: !GetAtt CostGuardFunction.Arn
ServiceTimeout: 120
Mode: configure_logging
LogDestination: !If [UseS3, 'Both', 'CWL']
LogGroupName: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
LoggingRoleArn: !GetAtt BedrockLoggingRole.Arn
S3BucketName: !If [UseS3, !If [CreateLogBucket, !Ref LogBucket, !Ref ExistingLogBucketName], '']
TextDataDeliveryEnabled: !Ref LogTextData
ImageDataDeliveryEnabled: !Ref LogImageData
VideoDataDeliveryEnabled: !Ref LogVideoData
EmbeddingDataDeliveryEnabled: !Ref LogEmbeddingData
AudioDataDeliveryEnabled: !Ref LogAudioData
SubscriptionFilterLogGroup: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
SubscriptionFilterDestinationArn: !GetAtt CostGuardFunction.Arn
SubscriptionFilterName: !Sub '${ResourcePrefix}-subscription'
# ============================================================
# 4. DynamoDB table for per-user monthly cost (updated every minute)
# ============================================================
CostTable:
Type: AWS::DynamoDB::Table
Condition: CreateCostTable
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: identityKey
AttributeType: S
- AttributeName: period
AttributeType: S
KeySchema:
- AttributeName: identityKey
KeyType: HASH
- AttributeName: period
KeyType: RANGE
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
SSESpecification:
SSEEnabled: true
Tags:
- Key: Cost
Value: !Ref CostTag
# 5. SNS Topic
CostReportTopic:
Type: AWS::SNS::Topic
Condition: CreateSnsTopic
Properties:
TopicName: !Ref ResourcePrefix
Tags:
- Key: Cost
Value: !Ref CostTag
# 6. SNS Subscription
CostReportSubscription:
Type: AWS::SNS::Subscription
Condition: CreateSnsTopic
Properties:
TopicArn: !Ref CostReportTopic
Protocol: email
Endpoint: !Ref EmailAddress
# ============================================================
# 7. Lambda Execution Role
# ============================================================
ReportLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ResourcePrefix}-lambda-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: CostGuardPermissions
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- bedrock:PutModelInvocationLoggingConfiguration
- bedrock:DeleteModelInvocationLoggingConfiguration
- bedrock:GetModelInvocationLoggingConfiguration
Resource: '*'
- Effect: Allow
Action:
- logs:PutSubscriptionFilter
- logs:DeleteSubscriptionFilter
Resource: !Sub
- 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroup}:*'
- LogGroup: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
- !If
- UseS3
- Effect: Allow
Action:
- s3:GetBucketPolicy
- s3:PutBucketPolicy
Resource: !Sub
- 'arn:aws:s3:::${Bucket}'
- Bucket: !If [CreateLogBucket, !Ref LogBucket, !Ref ExistingLogBucketName]
- !Ref 'AWS::NoValue'
- Effect: Allow
Action:
- iam:PassRole
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ResourcePrefix}-logging-role'
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:GetItem
- dynamodb:Query
- dynamodb:Scan
Resource: !If [CreateCostTable, !GetAtt CostTable.Arn, !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ExistingCostTableName}']
- Effect: Allow
Action:
- sns:Publish
Resource: !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]
- Effect: Allow
Action:
- iam:AttachUserPolicy
- iam:DetachUserPolicy
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:user/*'
Condition:
ArnEquals:
iam:PolicyArn: !Ref BedrockCostLimitDenyPolicy
- Effect: Allow
Action:
- iam:ListAttachedUserPolicies
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:user/*'
- Effect: Allow
Action:
- iam:AttachRolePolicy
- iam:DetachRolePolicy
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/*'
Condition:
ArnEquals:
iam:PolicyArn: !Ref BedrockCostLimitDenyPolicy
- Effect: Allow
Action:
- iam:ListAttachedRolePolicies
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/*'
- Effect: Allow
Action:
- iam:ListEntitiesForPolicy
Resource: !Ref BedrockCostLimitDenyPolicy
Tags:
- Key: Cost
Value: !Ref CostTag
# ============================================================
# 8. Lambda Function
# ============================================================
CostGuardFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Ref ResourcePrefix
Runtime: python3.12
Handler: index.handler
Timeout: 120
MemorySize: 128
ReservedConcurrentExecutions: 1
Role: !GetAtt ReportLambdaRole.Arn
Environment:
Variables:
LOG_GROUP_NAME: !Ref LogGroupName
SNS_TOPIC_ARN: !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]
SOURCE_TAGS: !Ref SourceTags
COST_LIMIT_USD: !Ref CostLimitUsd
EXCLUDE_USERS: !Ref ExcludeUsers
DENY_POLICY_ARN: !Ref BedrockCostLimitDenyPolicy
COST_TABLE_NAME: !If [CreateCostTable, !Ref CostTable, !Ref ExistingCostTableName]
WARNING_THRESHOLD_PERCENT: !Ref WarningThresholdPercent
CAP_CHECK_INTERVAL_MINUTES: !Ref CapCheckIntervalMinutes
JPY_RATE: !Ref JpyRate
MODEL_PRICING: !Ref ModelPricing
Tags:
- Key: Cost
Value: !Ref CostTag
Code:
ZipFile: |
import os
import time
import json
import base64
import gzip
import boto3
import cfnresponse
from datetime import datetime, timedelta, timezone
JST = timezone(timedelta(hours=9))
def parse_pricing(raw):
pricing = {}
for entry in raw.split(','):
tier, rates = entry.split(':')
parts = rates.split('/')
pricing[tier.strip()] = {'in': float(parts[0]), 'out': float(parts[1]), 'cache_write': float(parts[2]), 'cache_read': float(parts[3])}
return pricing
PRICING = parse_pricing(os.environ.get('MODEL_PRICING', 'sonnet:3.0/15.0/3.75/0.30,opus:5.0/25.0/6.25/0.50,haiku:1.0/5.0/1.25/0.10'))
def get_model_tier(model_id):
m = model_id.lower()
if 'opus' in m: return 'opus'
if 'haiku' in m: return 'haiku'
return 'sonnet'
def calc_cost(total_in, total_out, cache_write, cache_read, tier):
p = PRICING.get(tier, PRICING['sonnet'])
return (total_in / 1_000_000 * p['in']) + (total_out / 1_000_000 * p['out']) + (cache_write / 1_000_000 * p['cache_write']) + (cache_read / 1_000_000 * p['cache_read'])
def identity_from_arn(arn):
parts = arn.split('/')
if ':user/' in arn:
return ('user', parts[-1])
elif ':assumed-role/' in arn and len(parts) >= 2:
return ('role', parts[1] if len(parts) >= 3 else parts[-1])
return ('unknown', parts[-1] if parts else arn)
def parse_limits(raw):
limits = {}
if raw and raw != '0':
for entry in raw.split(','):
entry = entry.strip()
if ':' in entry:
name, amt = entry.rsplit(':', 1)
try:
limits[name.strip()] = float(amt.strip())
except ValueError:
pass
return limits
def handle_subscription_event(event, context):
"""Process log events from CWL Subscription Filter."""
ddb = boto3.client('dynamodb')
table = os.environ['COST_TABLE_NAME']
source_tags = [t.strip() for t in os.environ['SOURCE_TAGS'].split(',')]
now = datetime.now(JST)
period = now.strftime('%Y-%m')
# Decode and decompress the CWL subscription payload
payload = base64.b64decode(event['awslogs']['data'])
log_data = json.loads(gzip.decompress(payload))
if log_data.get('messageType') == 'CONTROL_MESSAGE':
return {'statusCode': 200, 'body': 'control'}
for log_event in log_data.get('logEvents', []):
try:
record = json.loads(log_event['message'])
except (json.JSONDecodeError, KeyError):
continue
# Filter by source tags
source = record.get('requestMetadata', {}).get('source', '')
if source not in source_tags:
continue
request_id = record.get('requestId', '')
if not request_id:
continue
identity_arn = record.get('identity', {}).get('arn', '')
model_id = record.get('modelId', '')
if 'haiku' in model_id:
continue
kind, name = identity_from_arn(identity_arn)
tier = get_model_tier(model_id)
input_tokens = record.get('input', {}).get('inputTokenCount', 0) or 0
output_tokens = record.get('output', {}).get('outputTokenCount', 0) or 0
cache_read = record.get('input', {}).get('cacheReadInputTokenCount', 0) or 0
cache_write = record.get('input', {}).get('cacheWriteInputTokenCount', 0) or 0
cost = calc_cost(input_tokens, output_tokens, cache_write, cache_read, tier)
if cost <= 0:
continue
# Determine source-specific cost field
source_field = source.replace('-', '_') # claude-code -> claude_code
# Dedup: write requestId as separate item (conditional put fails if exists)
try:
ddb.put_item(
TableName=table,
Item={
'identityKey': {'S': f'req#{request_id}'},
'period': {'S': period},
'ttl': {'N': str(int((now + timedelta(hours=72)).timestamp()))},
},
ConditionExpression='attribute_not_exists(identityKey)',
)
except ddb.exceptions.ConditionalCheckFailedException:
continue # Already processed
except Exception as ex:
print(f'[WARN] Dedup write failed for {request_id}: {ex}')
continue
# Increment cost for this identity
model_short = model_id.split('/')[-1] if '/' in model_id else model_id
try:
ddb.update_item(
TableName=table,
Key={'identityKey': {'S': f'{kind}:{name}'}, 'period': {'S': period}},
UpdateExpression='SET identityName=:n, identityKind=:k, lastUpdated=:t ADD monthlySpendUsd :cost, #src :srcCost, invocationCount :one',
ExpressionAttributeNames={'#src': f'{source_field}Usd'},
ExpressionAttributeValues={
':cost': {'N': str(round(cost, 6))},
':n': {'S': name},
':k': {'S': kind},
':t': {'S': now.isoformat()},
':srcCost': {'N': str(round(cost, 6))},
':one': {'N': '1'},
},
)
except Exception as ex:
print(f'[WARN] DynamoDB update failed for {name}/{request_id}: {ex}')
# Increment daily aggregate
day_str = now.strftime('%Y-%m-%d')
try:
ddb.update_item(
TableName=table,
Key={'identityKey': {'S': f'daily:{day_str}'}, 'period': {'S': period}},
UpdateExpression='SET lastUpdated=:t ADD totalUsd :cost, #src :srcCost',
ExpressionAttributeNames={'#src': f'{source_field}Usd'},
ExpressionAttributeValues={
':cost': {'N': str(round(cost, 6))},
':t': {'S': now.isoformat()},
':srcCost': {'N': str(round(cost, 6))},
},
)
except Exception as ex:
print(f'[WARN] Daily aggregate update failed for {day_str}: {ex}')
# Increment model aggregate
try:
ddb.update_item(
TableName=table,
Key={'identityKey': {'S': f'model:{model_short}'}, 'period': {'S': period}},
UpdateExpression='SET lastUpdated=:t ADD totalUsd :cost, invocationCount :one, #src :srcCost',
ExpressionAttributeNames={'#src': f'{source_field}Usd'},
ExpressionAttributeValues={
':cost': {'N': str(round(cost, 6))},
':t': {'S': now.isoformat()},
':one': {'N': '1'},
':srcCost': {'N': str(round(cost, 6))},
},
)
except Exception as ex:
print(f'[WARN] Model aggregate update failed for {model_short}: {ex}')
# Increment per-user daily aggregate
try:
ddb.update_item(
TableName=table,
Key={'identityKey': {'S': f'ud:{name}:{day_str}'}, 'period': {'S': period}},
UpdateExpression='SET lastUpdated=:t, identityName=:n ADD totalUsd :cost, #src :srcCost',
ExpressionAttributeNames={'#src': f'{source_field}Usd'},
ExpressionAttributeValues={
':cost': {'N': str(round(cost, 6))},
':t': {'S': now.isoformat()},
':n': {'S': name},
':srcCost': {'N': str(round(cost, 6))},
},
)
except Exception as ex:
print(f'[WARN] User-daily aggregate update failed for {name}/{day_str}: {ex}')
# Increment per-user model aggregate
try:
ddb.update_item(
TableName=table,
Key={'identityKey': {'S': f'um:{name}:{model_short}'}, 'period': {'S': period}},
UpdateExpression='SET lastUpdated=:t ADD totalUsd :cost, invocationCount :one, #src :srcCost',
ExpressionAttributeNames={'#src': f'{source_field}Usd'},
ExpressionAttributeValues={
':cost': {'N': str(round(cost, 6))},
':t': {'S': now.isoformat()},
':one': {'N': '1'},
':srcCost': {'N': str(round(cost, 6))},
},
)
except Exception as ex:
print(f'[WARN] User-model aggregate update failed: {ex}')
return {'statusCode': 200, 'body': 'processed'}
def handle_cap_check(event, context):
"""Check DynamoDB for cap breaches and apply Deny policy."""
ddb = boto3.client('dynamodb')
iam = boto3.client('iam')
sns = boto3.client('sns')
table = os.environ['COST_TABLE_NAME']
topic_arn = os.environ['SNS_TOPIC_ARN']
deny_policy_arn = os.environ['DENY_POLICY_ARN']
limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
exclude = [x.strip() for x in os.environ.get('EXCLUDE_USERS', '').split(',') if x.strip()]
warning_pct = int(os.environ.get('WARNING_THRESHOLD_PERCENT', '80')) / 100.0
if not limits:
return {'statusCode': 200, 'body': 'no_limits'}
now = datetime.now(JST)
period = now.strftime('%Y-%m')
# Monthly reset on day 1: clear all deny policies
if now.day == 1 and now.hour == 0 and now.minute < int(os.environ.get('CAP_CHECK_INTERVAL_MINUTES', '1')) + 1:
try:
entities = iam.list_entities_for_policy(PolicyArn=deny_policy_arn)
for u in entities.get('PolicyUsers', []):
iam.detach_user_policy(UserName=u['UserName'], PolicyArn=deny_policy_arn)
for r in entities.get('PolicyRoles', []):
iam.detach_role_policy(RoleName=r['RoleName'], PolicyArn=deny_policy_arn)
except Exception as ex:
print(f'[WARN] Monthly reset failed: {ex}')
for name, limit in limits.items():
if name in exclude:
continue
# Try both user: and role: prefixes
spend = 0.0
for kind in ('user', 'role'):
try:
resp = ddb.get_item(
TableName=table,
Key={'identityKey': {'S': f'{kind}:{name}'}, 'period': {'S': period}},
ProjectionExpression='monthlySpendUsd'
)
item = resp.get('Item', {})
if item:
spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
break
except Exception:
continue
# 100% - enforce deny
if spend >= limit:
try:
kind_for_attach = 'user' if ':user/' in name or kind == 'user' else 'role'
if kind_for_attach == 'user':
attached = iam.list_attached_user_policies(UserName=name)
already = any(p['PolicyArn'] == deny_policy_arn for p in attached.get('AttachedPolicies', []))
iam.attach_user_policy(UserName=name, PolicyArn=deny_policy_arn)
else:
attached = iam.list_attached_role_policies(RoleName=name)
already = any(p['PolicyArn'] == deny_policy_arn for p in attached.get('AttachedPolicies', []))
iam.attach_role_policy(RoleName=name, PolicyArn=deny_policy_arn)
if not already:
sns.publish(
TopicArn=topic_arn,
Subject=f'[Bedrock Cost Guard] 利用停止: {name}',
Message=f'{name} が月額上限 ${limit} を超過しました (実績: ${spend:.2f})。\n全Claudeモデルへのアクセスを遮断しました。'
)
except Exception as ex:
print(f'[WARN] Failed to attach deny policy to {name}: {ex}')
# Warning threshold
elif spend >= limit * warning_pct:
# Send warning (use DynamoDB to track if already warned this period)
try:
warn_resp = ddb.update_item(
TableName=table,
Key={'identityKey': {'S': f'{kind}:{name}'}, 'period': {'S': period}},
UpdateExpression='SET warningNotified=:t',
ConditionExpression='attribute_not_exists(warningNotified)',
ExpressionAttributeValues={':t': {'S': now.isoformat()}},
ReturnValues='ALL_NEW'
)
pct_display = int(spend / limit * 100)
sns.publish(
TopicArn=topic_arn,
Subject=f'[Bedrock Cost Guard] 警告: {name} が上限の{pct_display}%に到達',
Message=f'{name} の月額利用が上限の{pct_display}%に達しました。\n\n実績: ${spend:.2f} / 上限: ${limit:.2f} ({pct_display}%)\n\nこのまま利用を続けると上限到達時にBedrock全モデルへのアクセスが自動遮断されます。'
)
except ddb.exceptions.ConditionalCheckFailedException:
pass # Already warned this period
except Exception as ex:
print(f'[WARN] Warning notification failed for {name}: {ex}')
return {'statusCode': 200, 'body': 'cap_check'}
def handle_report(event, context):
"""Generate daily report from DynamoDB data."""
ddb = boto3.client('dynamodb')
sns = boto3.client('sns')
table = os.environ['COST_TABLE_NAME']
topic_arn = os.environ['SNS_TOPIC_ARN']
limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
account_id = context.invoked_function_arn.split(':')[4]
now = datetime.now(JST)
period = now.strftime('%Y-%m')
JPY = int(os.environ.get('JPY_RATE', '160'))
# Query all items for this period (exclude dedup records)
items = []
last_key = None
while True:
scan_kwargs = {
'TableName': table,
'FilterExpression': 'period = :p AND NOT begins_with(identityKey, :prefix)',
'ExpressionAttributeValues': {':p': {'S': period}, ':prefix': {'S': 'req#'}},
}
if last_key:
scan_kwargs['ExclusiveStartKey'] = last_key
resp = ddb.scan(**scan_kwargs)
items.extend(resp.get('Items', []))
last_key = resp.get('LastEvaluatedKey')
if not last_key:
break
# Build report
mtd_cost = 0.0
users = {}
roles = {}
for item in items:
identity_key = item.get('identityKey', {}).get('S', '')
kind, name = identity_key.split(':', 1) if ':' in identity_key else ('unknown', identity_key)
if kind not in ('user', 'role'):
continue
spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
code_usd = float(item.get('claude_codeUsd', {}).get('N', '0'))
desktop_usd = float(item.get('claude_desktopUsd', {}).get('N', '0'))
mtd_cost += spend
entry = {'cost': spend, 'claude-code': code_usd, 'claude-desktop': desktop_usd}
if kind == 'user':
users[name] = entry
else:
roles[name] = entry
month_start = now.replace(day=1)
lines = []
lines.append(f'[Bedrock Cost Guard] 日次コストレポート {now.strftime("%Y-%m-%d")}')
lines.append('')
lines.append(f'AWSアカウント: {account_id}')
lines.append(f'(1 USD = {JPY} JPY)')
lines.append('')
lines.append(f'今月 ({month_start.strftime("%-m/%-d")}-{now.strftime("%-m/%-d")}): ${mtd_cost:.2f} (JPY{int(mtd_cost*JPY):,})')
lines.append('')
lines.append('---')
lines.append('')
def fmt(items_dict, header):
if not items_dict:
return
lines.append(header)
lines.append('')
for name, e in sorted(items_dict.items(), key=lambda x: -x[1]['cost']):
subtotal = e['cost']
limit = limits.get(name, 0)
parts = []
if e['claude-code'] > 0: parts.append(f'Code ${e["claude-code"]:.2f}')
if e['claude-desktop'] > 0: parts.append(f'Desktop ${e["claude-desktop"]:.2f}')
limit_str = f' (上限: ${limit:.2f})' if limit > 0 else ''
if parts:
lines.append(f' {name}: ${subtotal:.2f} (JPY{int(subtotal*JPY):,}){limit_str} [{" / ".join(parts)}]')
else:
lines.append(f' {name}: ${subtotal:.2f} (JPY{int(subtotal*JPY):,}){limit_str}')
lines.append('')
fmt(users, '[今月のユーザー別コスト]')
fmt(roles, '[今月のロール別コスト]')
report = '\n'.join(lines)
sns.publish(
TopicArn=topic_arn,
Subject=f'[Bedrock Cost Guard] 日次コストレポート {now.strftime("%Y-%m-%d")}',
Message=report
)
return {'statusCode': 200, 'body': 'report'}
def handle_configure_logging(event, context):
"""Custom Resource handler for Bedrock logging configuration."""
try:
request_type = event.get('RequestType', 'Create')
props = event.get('ResourceProperties', {})
bedrock = boto3.client('bedrock')
if request_type == 'Delete':
try:
bedrock.delete_model_invocation_logging_configuration()
except Exception as de:
print(f'delete logging config: {de}')
sf_log_group = props.get('SubscriptionFilterLogGroup', '')
sf_name = props.get('SubscriptionFilterName', '')
if sf_log_group and sf_name:
try:
logs = boto3.client('logs')
logs.delete_subscription_filter(logGroupName=sf_log_group, filterName=sf_name)
except Exception as de:
print(f'delete subscription filter: {de}')
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
return
dest = props.get('LogDestination', 'CWL')
config = {'textDataDeliveryEnabled': props.get('TextDataDeliveryEnabled', 'false') == 'true',
'imageDataDeliveryEnabled': props.get('ImageDataDeliveryEnabled', 'false') == 'true',
'embeddingDataDeliveryEnabled': props.get('EmbeddingDataDeliveryEnabled', 'false') == 'true',
'videoDataDeliveryEnabled': props.get('VideoDataDeliveryEnabled', 'false') == 'true',
'audioDataDeliveryEnabled': props.get('AudioDataDeliveryEnabled', 'false') == 'true'}
if dest in ('CWL', 'Both'):
config['cloudWatchConfig'] = {
'logGroupName': props['LogGroupName'],
'roleArn': props['LoggingRoleArn']
}
if dest in ('S3', 'Both'):
config['s3Config'] = {
'bucketName': props['S3BucketName'],
'keyPrefix': ''
}
for attempt in range(5):
try:
bedrock.put_model_invocation_logging_configuration(loggingConfig=config)
break
except Exception as ve:
if attempt < 4 and ('permissions for bucket' in str(ve) or 'bucket policy' in str(ve).lower()):
import time
time.sleep(10)
else:
raise
sf_log_group = props.get('SubscriptionFilterLogGroup', '')
sf_dest = props.get('SubscriptionFilterDestinationArn', '')
sf_name = props.get('SubscriptionFilterName', '')
if sf_log_group and sf_dest and sf_name:
logs = boto3.client('logs')
import time
for attempt in range(6):
try:
logs.put_subscription_filter(
logGroupName=sf_log_group,
filterName=sf_name,
filterPattern='',
destinationArn=sf_dest
)
break
except Exception as se:
if attempt < 5 and ('Could not execute the lambda' in str(se) or 'permission' in str(se).lower()):
time.sleep(10)
else:
raise
cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Status': 'configured'})
except Exception as e:
print(f'Error: {e}')
cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})
def handler(event, context):
# Subscription Filter events have 'awslogs' key
if 'awslogs' in event:
return handle_subscription_event(event, context)
# CloudFormation Custom Resource events
if 'RequestType' in event:
if event.get('ResourceProperties', {}).get('Mode') == 'configure_logging':
return handle_configure_logging(event, context)
# Unknown custom resource - respond SUCCESS to avoid timeout
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
return
# EventBridge Scheduler events
mode = event.get('mode', 'report') if event else 'report'
if mode == 'cap_check':
return handle_cap_check(event, context)
else:
return handle_report(event, context)
# ============================================================
# 9. EventBridge Scheduler Role
# ============================================================
SchedulerRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ResourcePrefix}-scheduler-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: scheduler.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: InvokeLambda
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt CostGuardFunction.Arn
Tags:
- Key: Cost
Value: !Ref CostTag
# 10. EventBridge Scheduler (daily JST 0:00 - report)
DailySchedule:
Type: AWS::Scheduler::Schedule
Properties:
Name: !Sub '${ResourcePrefix}-daily-report'
ScheduleExpression: !Sub 'cron(${ReportScheduleMinuteJst} ${ReportScheduleHourJst} * * ? *)'
ScheduleExpressionTimezone: Asia/Tokyo
FlexibleTimeWindow:
Mode: 'OFF'
Target:
Arn: !GetAtt CostGuardFunction.Arn
RoleArn: !GetAtt SchedulerRole.Arn
Input: '{"mode": "report"}'
# 11. EventBridge Scheduler (cap check)
CapCheckSchedule:
Type: AWS::Scheduler::Schedule
Properties:
Name: !Sub '${ResourcePrefix}-cap-check'
ScheduleExpression: !Sub 'rate(${CapCheckIntervalMinutes} minutes)'
ScheduleExpressionTimezone: Asia/Tokyo
FlexibleTimeWindow:
Mode: 'OFF'
Target:
Arn: !GetAtt CostGuardFunction.Arn
RoleArn: !GetAtt SchedulerRole.Arn
Input: '{"mode": "cap_check"}'
# 12. Managed Deny Policy for cost cap
BedrockCostLimitDenyPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: !Sub '${ResourcePrefix}-deny'
Description: Denies all Claude model access when the monthly cost cap is exceeded. Managed by the Cost Guard Lambda.
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Deny
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
Resource:
- 'arn:aws:bedrock:*::foundation-model/anthropic.claude-*'
- 'arn:aws:bedrock:*:*:inference-profile/*anthropic.claude-*'
# ============================================================
# 13. Subscription Filter (CWL -> Lambda)
# ============================================================
SubscriptionFilterPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref CostGuardFunction
Action: lambda:InvokeFunction
Principal: !Sub 'logs.${AWS::Region}.amazonaws.com'
SourceAccount: !Ref 'AWS::AccountId'
SourceArn: !Sub
- 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroup}:*'
- LogGroup: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
# ============================================================
# 14. CloudWatch Dashboard (admin + per-user cost visibility)
# ============================================================
CostDashboard:
Type: AWS::CloudWatch::Dashboard
Properties:
DashboardName: !Sub '${ResourcePrefix}-dashboard'
DashboardBody: !Sub |
{
"start": "-P0M",
"widgets": [
{
"type": "text", "x": 0, "y": 0, "width": 24, "height": 2,
"properties": { "markdown": "# Bedrock Cost Guard\nコスト詳細は[セルフサービスダッシュボード](https://${DashboardDistribution.DomainName}/)を参照。ここではインフラメトリクスのみ表示。" }
},
{
"type": "metric", "x": 0, "y": 2, "width": 12, "height": 6,
"properties": {
"title": "呼び出し回数 (全体, 日次)",
"region": "${AWS::Region}", "stat": "Sum", "period": 86400, "view": "timeSeries",
"metrics": [
["AWS/Bedrock", "Invocations", {"label": "Invocations"}],
["AWS/Bedrock", "InvocationClientErrors", {"label": "Errors"}]
]
}
},
{
"type": "metric", "x": 12, "y": 2, "width": 12, "height": 6,
"properties": {
"title": "トークン量 (全体, 日次)",
"region": "${AWS::Region}", "stat": "Sum", "period": 86400, "view": "timeSeries",
"metrics": [
["AWS/Bedrock", "InputTokenCount", {"label": "Input"}],
["AWS/Bedrock", "OutputTokenCount", {"label": "Output"}],
["AWS/Bedrock", "CacheReadInputTokenCount", {"label": "CacheRead"}],
["AWS/Bedrock", "CacheWriteInputTokenCount", {"label": "CacheWrite"}]
]
}
},
{
"type": "metric", "x": 0, "y": 8, "width": 12, "height": 6,
"properties": {
"title": "モデル別 呼び出し回数 (日次)",
"region": "${AWS::Region}", "period": 86400, "view": "timeSeries", "stacked": true,
"metrics": [
[ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"Invocations\" anthropic', 'Sum', 86400)", "id": "e1", "region": "${AWS::Region}" } ]
]
}
},
{
"type": "metric", "x": 12, "y": 8, "width": 12, "height": 6,
"properties": {
"title": "モデル別 出力トークン (日次)",
"region": "${AWS::Region}", "period": 86400, "view": "timeSeries", "stacked": true,
"metrics": [
[ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"OutputTokenCount\" anthropic', 'Sum', 86400)", "id": "e2", "region": "${AWS::Region}" } ]
]
}
},
{
"type": "metric", "x": 0, "y": 14, "width": 8, "height": 6,
"properties": {
"title": "レイテンシ p99 (5min)",
"region": "${AWS::Region}", "period": 300, "view": "timeSeries",
"metrics": [
[ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"InvocationLatency\" anthropic', 'p99', 300)", "id": "e3", "region": "${AWS::Region}" } ]
]
}
},
{
"type": "metric", "x": 8, "y": 14, "width": 8, "height": 6,
"properties": {
"title": "TTFT (平均, 5min)",
"region": "${AWS::Region}", "period": 300, "view": "timeSeries",
"metrics": [
[ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"TimeToFirstToken\" anthropic', 'Average', 300)", "id": "e4", "region": "${AWS::Region}" } ]
]
}
},
{
"type": "metric", "x": 16, "y": 14, "width": 8, "height": 6,
"properties": {
"title": "TPMクォータ消費 (1min)",
"region": "${AWS::Region}", "period": 60, "view": "timeSeries",
"metrics": [
[ { "expression": "SEARCH('{AWS/Bedrock,ModelId} MetricName=\"EstimatedTPMQuotaUsage\" anthropic', 'Sum', 60)", "id": "e5", "region": "${AWS::Region}" } ]
]
}
},
{
"type": "metric", "x": 0, "y": 20, "width": 24, "height": 4,
"properties": {
"title": "スロットリング (5min)",
"region": "${AWS::Region}", "stat": "Sum", "period": 300, "view": "timeSeries",
"metrics": [
["AWS/Bedrock", "InvocationThrottles", {"label": "Throttles"}]
]
}
}
]
}
# ============================================================
# 15. CloudWatch Alarm (throttling)
# ============================================================
ThrottlingAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub '${ResourcePrefix}-throttling-alarm'
AlarmDescription: Bedrockでスロットリングが発生(ユーザーのリクエストが拒否されている)。Service Quotasの引き上げを検討してください。
Namespace: AWS/Bedrock
MetricName: InvocationThrottles
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: notBreaching
AlarmActions:
- !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]
# ============================================================
# 16. Self-Service Dashboard: Cognito User Pool + App Client
# ============================================================
DashboardUserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: !Sub '${ResourcePrefix}-dashboard-users'
AutoVerifiedAttributes:
- email
UsernameAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 8
RequireUppercase: true
RequireLowercase: true
RequireNumbers: true
RequireSymbols: false
Schema:
- Name: email
AttributeDataType: String
Required: true
Mutable: false
UserPoolTags:
Cost: !Ref CostTag
DashboardUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain: !Ref DashboardDomainPrefix
UserPoolId: !Ref DashboardUserPool
DashboardUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: !Sub '${ResourcePrefix}-dashboard-client'
UserPoolId: !Ref DashboardUserPool
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
SupportedIdentityProviders:
- COGNITO
AllowedOAuthFlows:
- implicit
AllowedOAuthScopes:
- openid
- email
AllowedOAuthFlowsUserPoolClient: true
CallbackURLs:
- !Sub 'https://${DashboardDistribution.DomainName}/callback.html'
LogoutURLs:
- !Sub 'https://${DashboardDistribution.DomainName}/'
# ============================================================
# 17. Self-Service Dashboard: S3 Bucket (static site)
# ============================================================
DashboardBucket:
Type: AWS::S3::Bucket
Condition: CreateDashboardBucket
Properties:
BucketName: !Sub '${ResourcePrefix}-dashboard-${AWS::AccountId}'
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
Tags:
- Key: Cost
Value: !Ref CostTag
DashboardBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !If [CreateDashboardBucket, !Ref DashboardBucket, !Ref ExistingDashboardBucketName]
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub
- '${BucketArn}/*'
- BucketArn: !If [CreateDashboardBucket, !GetAtt DashboardBucket.Arn, !Sub 'arn:aws:s3:::${ExistingDashboardBucketName}']
Condition:
StringEquals:
'AWS:SourceArn': !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${DashboardDistribution}'
# ============================================================
# 18. Self-Service Dashboard: CloudFront + OAC
# ============================================================
DashboardOAC:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${ResourcePrefix}-dashboard-oac'
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
DashboardIpRestriction:
Type: AWS::CloudFront::Function
Properties:
Name: !Sub '${ResourcePrefix}-ip-restrict'
AutoPublish: true
FunctionConfig:
Comment: IP restriction for dashboard
Runtime: cloudfront-js-2.0
FunctionCode: !Sub |
var ALLOWED_CIDRS = '${AllowedCidrs}'.split(',');
function ipToNum(ip) {
var parts = ip.split('.');
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
}
function isAllowed(ip) {
var ipNum = ipToNum(ip);
for (var i = 0; i < ALLOWED_CIDRS.length; i++) {
var parts = ALLOWED_CIDRS[i].split('/');
var base = ipToNum(parts[0]);
var mask = (~0 << (32 - parseInt(parts[1]))) >>> 0;
if ((ipNum & mask) === (base & mask)) return true;
}
return false;
}
function handler(event) {
var clientIp = event.viewer.ip;
if (!isAllowed(clientIp)) {
return { statusCode: 403, statusDescription: 'Forbidden',
headers: { 'content-type': { value: 'text/plain' } },
body: 'Access denied' };
}
return event.request;
}
DashboardDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
Comment: !Sub '${ResourcePrefix} self-service dashboard'
Origins:
- Id: S3Origin
DomainName: !If [CreateDashboardBucket, !GetAtt DashboardBucket.RegionalDomainName, !Sub '${ExistingDashboardBucketName}.s3.${AWS::Region}.amazonaws.com']
OriginAccessControlId: !Ref DashboardOAC
S3OriginConfig:
OriginAccessIdentity: ''
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
AllowedMethods: [GET, HEAD]
CachedMethods: [GET, HEAD]
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt DashboardIpRestriction.FunctionARN
PriceClass: PriceClass_200
Tags:
- Key: Cost
Value: !Ref CostTag
# ============================================================
# 19. Self-Service Dashboard: API Gateway (REST) + Lambda
# ============================================================
DashboardApi:
Type: AWS::ApiGateway::RestApi
Properties:
Name: !Sub '${ResourcePrefix}-dashboard-api'
Description: Self-service cost query API
Tags:
- Key: Cost
Value: !Ref CostTag
DashboardApiGatewayResponse4XX:
Type: AWS::ApiGateway::GatewayResponse
Properties:
RestApiId: !Ref DashboardApi
ResponseType: DEFAULT_4XX
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: !Sub "'https://${DashboardDistribution.DomainName}'"
gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"
DashboardApiAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
Name: CognitoAuth
RestApiId: !Ref DashboardApi
Type: COGNITO_USER_POOLS
IdentitySource: method.request.header.Authorization
ProviderARNs:
- !GetAtt DashboardUserPool.Arn
DashboardApiResource:
Type: AWS::ApiGateway::Resource
Properties:
RestApiId: !Ref DashboardApi
ParentId: !GetAtt DashboardApi.RootResourceId
PathPart: my-cost
DashboardApiMethod:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref DashboardApi
ResourceId: !Ref DashboardApiResource
HttpMethod: GET
AuthorizationType: COGNITO_USER_POOLS
AuthorizerId: !Ref DashboardApiAuthorizer
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DashboardQueryFunction.Arn}/invocations'
DashboardApiMethodOptions:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref DashboardApi
ResourceId: !Ref DashboardApiResource
HttpMethod: OPTIONS
AuthorizationType: NONE
Integration:
Type: MOCK
RequestTemplates:
application/json: '{"statusCode": 200}'
IntegrationResponses:
- StatusCode: 200
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"
method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'"
method.response.header.Access-Control-Allow-Origin: !Sub "'https://${DashboardDistribution.DomainName}'"
MethodResponses:
- StatusCode: 200
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: true
method.response.header.Access-Control-Allow-Methods: true
method.response.header.Access-Control-Allow-Origin: true
DashboardApiAllCostsResource:
Type: AWS::ApiGateway::Resource
Properties:
RestApiId: !Ref DashboardApi
ParentId: !GetAtt DashboardApi.RootResourceId
PathPart: all-costs
DashboardApiAllCostsMethod:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref DashboardApi
ResourceId: !Ref DashboardApiAllCostsResource
HttpMethod: GET
AuthorizationType: COGNITO_USER_POOLS
AuthorizerId: !Ref DashboardApiAuthorizer
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DashboardQueryFunction.Arn}/invocations'
DashboardApiAllCostsOptions:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref DashboardApi
ResourceId: !Ref DashboardApiAllCostsResource
HttpMethod: OPTIONS
AuthorizationType: NONE
Integration:
Type: MOCK
RequestTemplates:
application/json: '{"statusCode": 200}'
IntegrationResponses:
- StatusCode: 200
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: "'Content-Type,Authorization'"
method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'"
method.response.header.Access-Control-Allow-Origin: !Sub "'https://${DashboardDistribution.DomainName}'"
MethodResponses:
- StatusCode: 200
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: true
method.response.header.Access-Control-Allow-Methods: true
method.response.header.Access-Control-Allow-Origin: true
DashboardApiDeployment:
Type: AWS::ApiGateway::Deployment
DependsOn:
- DashboardApiMethod
- DashboardApiMethodOptions
- DashboardApiAllCostsMethod
- DashboardApiAllCostsOptions
Properties:
RestApiId: !Ref DashboardApi
DashboardApiStage:
Type: AWS::ApiGateway::Stage
Properties:
RestApiId: !Ref DashboardApi
DeploymentId: !Ref DashboardApiDeployment
StageName: v1
# ============================================================
# 20. Self-Service Dashboard: Query Lambda
# ============================================================
DashboardQueryLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ResourcePrefix}-dashboard-query-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: ReadCostTable
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:Query
- dynamodb:Scan
Resource: !If [CreateCostTable, !GetAtt CostTable.Arn, !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ExistingCostTableName}']
Tags:
- Key: Cost
Value: !Ref CostTag
DashboardQueryFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub '${ResourcePrefix}-dashboard-query'
Runtime: python3.12
Handler: index.handler
Timeout: 10
MemorySize: 128
Role: !GetAtt DashboardQueryLambdaRole.Arn
Environment:
Variables:
COST_TABLE_NAME: !If [CreateCostTable, !Ref CostTable, !Ref ExistingCostTableName]
COST_LIMIT_USD: !Ref CostLimitUsd
EXCLUDE_USERS: !Ref ExcludeUsers
ALLOWED_ORIGIN: !Sub 'https://${DashboardDistribution.DomainName}'
Tags:
- Key: Cost
Value: !Ref CostTag
Code:
ZipFile: |
import os
import json
import boto3
from datetime import datetime, timedelta, timezone
JST = timezone(timedelta(hours=9))
def parse_limits(raw):
limits = {}
if raw and raw != '0':
for entry in raw.split(','):
entry = entry.strip()
if ':' in entry:
name, amt = entry.rsplit(':', 1)
try:
limits[name.strip()] = float(amt.strip())
except ValueError:
pass
return limits
def handle_my_cost(event, headers):
try:
claims = event['requestContext']['authorizer']['claims']
email = claims.get('email', '')
username = email.split('@')[0] if '@' in email else email
except (KeyError, TypeError):
return {'statusCode': 401, 'headers': headers, 'body': json.dumps({'error': 'unauthorized'})}
ddb = boto3.client('dynamodb')
table = os.environ['COST_TABLE_NAME']
now = datetime.now(JST)
period = now.strftime('%Y-%m')
spend = 0.0
last_updated = None
for kind in ('user', 'role'):
try:
resp = ddb.get_item(
TableName=table,
Key={'identityKey': {'S': f'{kind}:{username}'}, 'period': {'S': period}},
)
item = resp.get('Item')
if item:
spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
last_updated = item.get('lastUpdated', {}).get('S', '')
break
except Exception:
continue
limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
limit = limits.get(username, 0)
remaining = max(0, limit - spend) if limit > 0 else None
body = {
'username': username,
'period': period,
'spendUsd': round(spend, 4),
'limitUsd': limit if limit > 0 else None,
'remainingUsd': round(remaining, 4) if remaining is not None else None,
'lastUpdated': last_updated,
}
return {'statusCode': 200, 'headers': headers, 'body': json.dumps(body)}
def get_aggregates(ddb, table, period, prefix):
items = []
last_key = None
while True:
kwargs = {
'TableName': table,
'FilterExpression': 'period = :p AND begins_with(identityKey, :prefix)',
'ExpressionAttributeValues': {':p': {'S': period}, ':prefix': {'S': prefix}},
}
if last_key:
kwargs['ExclusiveStartKey'] = last_key
resp = ddb.scan(**kwargs)
items.extend(resp.get('Items', []))
last_key = resp.get('LastEvaluatedKey')
if not last_key:
break
return items
def get_daily_costs(ddb, table, period):
items = get_aggregates(ddb, table, period, 'daily:')
days = []
for item in items:
key = item.get('identityKey', {}).get('S', '')
day_str = key.replace('daily:', '')
code = float(item.get('claude_codeUsd', {}).get('N', '0'))
desktop = float(item.get('claude_desktopUsd', {}).get('N', '0'))
days.append({'date': day_str[5:], 'code': round(code, 4), 'desktop': round(desktop, 4)})
days.sort(key=lambda x: x['date'])
return days
def get_model_stats(ddb, table, period):
items = get_aggregates(ddb, table, period, 'model:')
models = []
for item in items:
key = item.get('identityKey', {}).get('S', '')
name = key.replace('model:', '')
cost = float(item.get('totalUsd', {}).get('N', '0'))
count = int(item.get('invocationCount', {}).get('N', '0'))
code = float(item.get('claude_codeUsd', {}).get('N', '0'))
desktop = float(item.get('claude_desktopUsd', {}).get('N', '0'))
models.append({'name': name, 'costUsd': round(cost, 4), 'invocations': count, 'codeUsd': round(code, 4), 'desktopUsd': round(desktop, 4)})
models.sort(key=lambda x: -x['costUsd'])
return models
def get_user_daily_costs(ddb, table, period):
items = get_aggregates(ddb, table, period, 'ud:')
user_days = {}
for item in items:
key = item.get('identityKey', {}).get('S', '')
# ud:{name}:{date}
parts = key[3:].rsplit(':', 1)
if len(parts) != 2:
continue
uname, day_str = parts
cost = float(item.get('totalUsd', {}).get('N', '0'))
if uname not in user_days:
user_days[uname] = {}
user_days[uname][day_str] = round(cost, 4)
return user_days
def get_user_models(ddb, table, period):
items = get_aggregates(ddb, table, period, 'um:')
user_models = {}
for item in items:
key = item.get('identityKey', {}).get('S', '')
parts = key.replace('um:', '').split(':', 1)
if len(parts) != 2:
continue
uname, model = parts
cost = float(item.get('totalUsd', {}).get('N', '0'))
count = int(item.get('invocationCount', {}).get('N', '0'))
code = float(item.get('claude_codeUsd', {}).get('N', '0'))
desktop = float(item.get('claude_desktopUsd', {}).get('N', '0'))
if uname not in user_models:
user_models[uname] = []
user_models[uname].append({'model': model, 'costUsd': round(cost, 4), 'invocations': count, 'codeUsd': round(code, 4), 'desktopUsd': round(desktop, 4)})
return user_models
def handle_all_costs(event, headers):
ddb = boto3.client('dynamodb')
table = os.environ['COST_TABLE_NAME']
now = datetime.now(JST)
period = now.strftime('%Y-%m')
limits = parse_limits(os.environ.get('COST_LIMIT_USD', '0'))
exclude = [x.strip() for x in os.environ.get('EXCLUDE_USERS', '').split(',') if x.strip()]
items = []
last_key = None
while True:
kwargs = {
'TableName': table,
'FilterExpression': 'period = :p AND NOT begins_with(identityKey, :req) AND NOT begins_with(identityKey, :daily) AND NOT begins_with(identityKey, :model) AND NOT begins_with(identityKey, :um) AND NOT begins_with(identityKey, :ud)',
'ExpressionAttributeValues': {':p': {'S': period}, ':req': {'S': 'req#'}, ':daily': {'S': 'daily:'}, ':model': {'S': 'model:'}, ':um': {'S': 'um:'}, ':ud': {'S': 'ud:'}},
}
if last_key:
kwargs['ExclusiveStartKey'] = last_key
resp = ddb.scan(**kwargs)
items.extend(resp.get('Items', []))
last_key = resp.get('LastEvaluatedKey')
if not last_key:
break
users = []
for item in items:
identity_key = item.get('identityKey', {}).get('S', '')
kind, name = identity_key.split(':', 1) if ':' in identity_key else ('unknown', identity_key)
spend = float(item.get('monthlySpendUsd', {}).get('N', '0'))
code_usd = float(item.get('claude_codeUsd', {}).get('N', '0'))
desktop_usd = float(item.get('claude_desktopUsd', {}).get('N', '0'))
invocations = int(item.get('invocationCount', {}).get('N', '0'))
limit = limits.get(name, 0)
users.append({
'name': name,
'kind': kind,
'spendUsd': round(spend, 4),
'codeUsd': round(code_usd, 4),
'desktopUsd': round(desktop_usd, 4),
'invocations': invocations,
'limitUsd': limit if limit > 0 else None,
'remainingUsd': round(max(0, limit - spend), 4) if limit > 0 else None,
'lastUpdated': item.get('lastUpdated', {}).get('S', ''),
'excluded': name in exclude,
})
users.sort(key=lambda x: -x['spendUsd'])
daily_costs = get_daily_costs(ddb, table, period)
model_stats = get_model_stats(ddb, table, period)
user_daily_costs = get_user_daily_costs(ddb, table, period)
user_models = get_user_models(ddb, table, period)
for u in users:
um = user_models.get(u['name'], [])
u['topModelByCost'] = sorted(um, key=lambda x: -x['costUsd'])[0]['model'] if um else None
u['topModelByCount'] = sorted(um, key=lambda x: -x['invocations'])[0]['model'] if um else None
u['models'] = sorted(um, key=lambda x: -x['costUsd'])
body = {
'period': period,
'users': users,
'totalUsd': round(sum(u['spendUsd'] for u in users), 4),
'dailyCosts': daily_costs,
'userDailyCosts': user_daily_costs,
'models': model_stats,
'excludeUsers': exclude,
}
return {'statusCode': 200, 'headers': headers, 'body': json.dumps(body)}
def handler(event, context):
origin = os.environ['ALLOWED_ORIGIN']
headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
}
path = event.get('path', '')
if path.endswith('/all-costs'):
return handle_all_costs(event, headers)
return handle_my_cost(event, headers)
DashboardQueryPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref DashboardQueryFunction
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${DashboardApi}/*'
# ============================================================
# 21. Self-Service Dashboard: Deploy static files via Custom Resource
# ============================================================
DashboardDeployRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ResourcePrefix}-dashboard-deploy-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: S3Deploy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- s3:PutObject
- s3:DeleteObject
- s3:ListBucket
Resource:
- !If [CreateDashboardBucket, !GetAtt DashboardBucket.Arn, !Sub 'arn:aws:s3:::${ExistingDashboardBucketName}']
- !Sub
- '${BucketArn}/*'
- BucketArn: !If [CreateDashboardBucket, !GetAtt DashboardBucket.Arn, !Sub 'arn:aws:s3:::${ExistingDashboardBucketName}']
Tags:
- Key: Cost
Value: !Ref CostTag
DashboardDeployFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub '${ResourcePrefix}-dashboard-deploy'
Runtime: python3.12
Handler: index.handler
Timeout: 60
MemorySize: 128
Role: !GetAtt DashboardDeployRole.Arn
Environment:
Variables:
BUCKET_NAME: !If [CreateDashboardBucket, !Ref DashboardBucket, !Ref ExistingDashboardBucketName]
Tags:
- Key: Cost
Value: !Ref CostTag
Code:
ZipFile: |
import os
import json
import boto3
import cfnresponse
def handler(event, context):
physical_id = 'dashboard-deploy-singleton'
try:
bucket = os.environ['BUCKET_NAME']
s3 = boto3.client('s3')
if event['RequestType'] == 'Delete':
if event.get('ResourceProperties', {}).get('StackDelete') == 'true':
objs = s3.list_objects_v2(Bucket=bucket)
for obj in objs.get('Contents', []):
s3.delete_object(Bucket=bucket, Key=obj['Key'])
cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physical_id)
return
files = json.loads(event['ResourceProperties']['Files'])
for f in files:
s3.put_object(
Bucket=bucket,
Key=f['key'],
Body=f['body'],
ContentType=f.get('contentType', 'text/html'),
)
cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physical_id)
except Exception as e:
print(f'Error: {e}')
cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)}, physical_id)
DashboardDeployCustomResource:
Type: Custom::DashboardDeploy
Properties:
ServiceToken: !GetAtt DashboardDeployFunction.Arn
Version: '7'
Files: !Sub |
[
{
"key": "index.html",
"contentType": "text/html",
"body": "<!DOCTYPE html>\n<html lang=\"ja\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Claude利用状況ダッシュボード</title>\n<script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js\"></script>\n<script src=\"https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.min.js\"></script>\n<style>\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;padding:1.5rem}\n.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:2rem}\n.header h1{font-size:1.3rem;color:#f8fafc}\n.header-right{display:flex;gap:.8rem;align-items:center}\n.header button{background:#334155;color:#94a3b8;border:none;padding:.5rem 1rem;border-radius:6px;cursor:pointer;font-size:.85rem}\n.header button:hover{background:#475569}\n\n.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}\n.stat-card{background:#1e293b;border-radius:10px;padding:1.2rem;position:relative;overflow:hidden}\n.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}\n.stat-card.blue::before{background:linear-gradient(90deg,#3b82f6,#60a5fa)}\n.stat-card.green::before{background:linear-gradient(90deg,#10b981,#34d399)}\n.stat-card.amber::before{background:linear-gradient(90deg,#f59e0b,#fbbf24)}\n.stat-card.purple::before{background:linear-gradient(90deg,#8b5cf6,#a78bfa)}\n.stat-card .label{font-size:.7rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em}\n.stat-card .value{font-size:1.5rem;font-weight:700;color:#f8fafc;margin-top:.25rem}\n.stat-card .sub{font-size:.78rem;color:#94a3b8;margin-top:.2rem}\n\n.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:1rem;margin-bottom:1.5rem}\n.charts-row-2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:2rem}\n@media(max-width:900px){.charts-row,.charts-row-2{grid-template-columns:1fr}}\n.chart-card{background:#1e293b;border-radius:10px;padding:1.2rem}\n.chart-card h3{font-size:.85rem;color:#e2e8f0;margin-bottom:1rem;font-weight:500}\n.chart-card canvas{width:100%!important;max-height:220px}\n\n.section-title{font-size:.85rem;color:#e2e8f0;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1rem;padding-left:.2rem}\n.team-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1rem;margin-bottom:2rem}\n.user-card{background:#1e293b;border-radius:10px;padding:1.2rem;border-left:4px solid #3b82f6;transition:transform .15s,box-shadow .15s}\n.user-card:hover{transform:translateY(-2px);box-shadow:0 4px 24px rgba(0,0,0,.3)}\n.user-card.warning{border-left-color:#f59e0b}\n.user-card.danger{border-left-color:#ef4444}\n.user-card .name{font-size:1rem;font-weight:600;color:#f8fafc;margin-bottom:.8rem;display:flex;justify-content:space-between;align-items:center}\n.user-card .name .badge{font-size:.65rem;padding:.2rem .5rem;border-radius:4px;background:#334155;color:#94a3b8;text-transform:uppercase}\n.user-card .bar-bg{height:8px;background:#334155;border-radius:4px;overflow:hidden;margin:.6rem 0}\n.user-card .bar-fill{height:100%;border-radius:4px;transition:width .6s ease}\n.user-card .details{display:grid;grid-template-columns:1fr 1fr;gap:.4rem;font-size:.8rem;color:#94a3b8}\n.user-card .details .val{color:#e2e8f0;font-weight:500;text-align:right}\n.user-card .pct-label{font-size:.75rem;color:#64748b;text-align:right;margin-top:.2rem}\n\n.login-wrap{display:flex;align-items:center;justify-content:center;min-height:80vh}\n.login-card{background:#1e293b;border-radius:12px;padding:2.5rem;text-align:center;max-width:360px}\n.login-card h2{color:#f8fafc;margin-bottom:1rem}\n.login-card p{color:#94a3b8;margin-bottom:1.5rem;font-size:.9rem}\n.login-btn{background:#3b82f6;color:#fff;border:none;padding:.8rem 2rem;border-radius:8px;font-size:1rem;cursor:pointer;transition:background .2s}\n.login-btn:hover{background:#2563eb}\n#error{color:#ef4444;text-align:center;margin:1rem 0;display:none}\n.meta{text-align:center;font-size:.75rem;color:#475569;margin-top:1.5rem}\n</style>\n</head>\n<body>\n<div id=\"login-view\" class=\"login-wrap\">\n <div class=\"login-card\">\n <h2>Claude利用状況ダッシュボード</h2>\n <p>チームのClaude利用状況を確認</p>\n <button class=\"login-btn\" onclick=\"login()\">ログイン</button>\n </div>\n</div>\n<div id=\"data-view\" style=\"display:none\">\n <div class=\"header\">\n <div>\n <h1>Claude利用状況ダッシュボード</h1>\n <p style=\"font-size:.75rem;color:#94a3b8;margin-top:.3rem\">Claude Code / Claude Desktop の利用コストを表示 | コスト上限 (LimitUsd) が未設定のユーザーは集計対象外です | SDK直接呼出し等は含まれません</p>\n </div>\n <div class=\"header-right\">\n <button onclick=\"load()\">更新</button>\n <button onclick=\"logout()\">ログアウト</button>\n </div>\n </div>\n <div class=\"summary\" id=\"summary\"></div>\n <div class=\"charts-row\">\n <div class=\"chart-card\">\n <h3>日別ユーザーコスト推移 (USD)</h3>\n <canvas id=\"dailyChart\"></canvas>\n </div>\n <div class=\"chart-card\">\n <h3>ツール別内訳</h3>\n <canvas id=\"sourceChart\"></canvas>\n </div>\n </div>\n <div class=\"charts-row-2\">\n <div class=\"chart-card\">\n <h3>モデル別コスト (USD)</h3>\n <canvas id=\"modelCostChart\"></canvas>\n </div>\n <div class=\"chart-card\">\n <h3>モデル別リクエスト数</h3>\n <canvas id=\"modelCountChart\"></canvas>\n </div>\n </div>\n <div class=\"section-title\">利用者別</div>\n <div class=\"team-grid\" id=\"team\"></div>\n <p class=\"meta\" id=\"meta\"></p>\n</div>\n<p id=\"error\"></p>\n<script>\nconst CONFIG = {\n clientId: '${DashboardUserPoolClient}',\n domain: '${DashboardDomainPrefix}.auth.${AWS::Region}.amazoncognito.com',\n redirectUri: location.origin + '/callback.html',\n apiBase: 'https://${DashboardApi}.execute-api.${AWS::Region}.amazonaws.com/v1'\n};\n\nconst charts = {};\nChart.register(ChartDataLabels);\n\nfunction shortModel(name) {\n return name.replace(/^anthropic\\./, '').replace(/-\\d{8}.*/, '');\n}\n\nfunction login() {\n const u = new URL('https://' + CONFIG.domain + '/login');\n u.searchParams.set('client_id', CONFIG.clientId);\n u.searchParams.set('response_type', 'token');\n u.searchParams.set('scope', 'openid email');\n u.searchParams.set('redirect_uri', CONFIG.redirectUri);\n location.href = u.toString();\n}\n\nfunction logout() {\n localStorage.removeItem('id_token');\n const u = new URL('https://' + CONFIG.domain + '/logout');\n u.searchParams.set('client_id', CONFIG.clientId);\n u.searchParams.set('logout_uri', location.origin + '/');\n location.href = u.toString();\n}\n\nasync function load() {\n const token = localStorage.getItem('id_token');\n if (!token) {\n document.getElementById('login-view').style.display = '';\n document.getElementById('data-view').style.display = 'none';\n return;\n }\n document.getElementById('login-view').style.display = 'none';\n document.getElementById('data-view').style.display = '';\n try {\n const r = await fetch(CONFIG.apiBase + '/all-costs', { headers: { Authorization: token } });\n if (r.status === 401 || r.status === 403) {\n localStorage.removeItem('id_token');\n showError('セッション期限切れ。再ログインしてください。');\n document.getElementById('login-view').style.display = '';\n document.getElementById('data-view').style.display = 'none';\n return;\n }\n if (!r.ok) { showError('API error: ' + r.status); return; }\n const d = await r.json();\n render(d);\n } catch (e) {\n showError('通信エラー: ' + e.message);\n }\n}\n\nfunction showError(msg) {\n const el = document.getElementById('error');\n el.textContent = msg;\n el.style.display = 'block';\n}\n\nfunction render(d) {\n document.getElementById('error').style.display = 'none';\n const JPY = 160;\n const totalJpy = Math.round(d.totalUsd * JPY);\n const excludeUsers = d.excludeUsers || [];\n const withLimit = d.users.filter(u => u.limitUsd && !u.excluded);\n const avgPct = withLimit.length ? withLimit.reduce((a, u) => a + (u.spendUsd / u.limitUsd * 100), 0) / withLimit.length : 0;\n const totalCode = d.users.reduce((a, u) => a + (u.codeUsd || 0), 0);\n const totalDesktop = d.users.reduce((a, u) => a + (u.desktopUsd || 0), 0);\n const capped = d.users.filter(u => u.limitUsd && !u.excluded && u.spendUsd >= u.limitUsd).length;\n const excludeCount = excludeUsers.length;\n\n let s = '';\n s += '<div class=\"stat-card blue\"><div class=\"label\">今月合計</div><div class=\"value\">$' + d.totalUsd.toFixed(2) + '</div><div class=\"sub\">¥' + totalJpy.toLocaleString() + ' (1USD=' + JPY + '円)</div></div>';\n s += '<div class=\"stat-card green\"><div class=\"label\">利用者数</div><div class=\"value\">' + d.users.length + ' <span style=\"font-size:.8rem;font-weight:400;color:#94a3b8\">人</span></div><div class=\"sub\">' + d.period + (excludeCount ? ' (除外: ' + excludeCount + '人)' : '') + '</div></div>';\n s += '<div class=\"stat-card amber\"><div class=\"label\">平均消費率</div><div class=\"value\">' + avgPct.toFixed(0) + '%</div><div class=\"sub\">上限設定者 ' + withLimit.length + '人の平均 (除外者含まず)</div></div>';\n s += '<div class=\"stat-card purple\"><div class=\"label\">上限到達</div><div class=\"value\">' + capped + ' <span style=\"font-size:.8rem;font-weight:400;color:#94a3b8\">人</span></div><div class=\"sub\">制限中のユーザー</div></div>';\n document.getElementById('summary').innerHTML = s;\n\n renderDailyChart(d.dailyCosts || [], d.userDailyCosts || {});\n renderSourceChart(totalCode, totalDesktop);\n renderModelCharts(d.models || []);\n renderUserCards(d.users, JPY);\n\n document.getElementById('meta').textContent = '最終更新: ' + new Date().toLocaleString('ja-JP') + ' | 期間: ' + d.period;\n}\n\nfunction renderDailyChart(dailyCosts, userDailyCosts) {\n const ctx = document.getElementById('dailyChart');\n if (charts.daily) charts.daily.destroy();\n if (!dailyCosts.length) { charts.daily = null; return; }\n\n const USER_COLORS = ['#60a5fa','#fbbf24','#34d399','#f87171','#a78bfa','#22d3ee','#fb923c','#a3e635','#f472b6','#2dd4bf','#818cf8','#fcd34d'];\n const dates = dailyCosts.map(x => x.date);\n\n const userNames = Object.keys(userDailyCosts || {});\n userNames.sort((a, b) => {\n const totalA = Object.values(userDailyCosts[a]).reduce((s, v) => s + v, 0);\n const totalB = Object.values(userDailyCosts[b]).reduce((s, v) => s + v, 0);\n return totalB - totalA;\n });\n\n const datasets = userNames.map((name, i) => {\n const dayMap = userDailyCosts[name];\n const data = dailyCosts.map(x => {\n const match = Object.keys(dayMap).find(k => k.slice(5) === x.date);\n return match ? dayMap[match] : 0;\n });\n const isLast = (i === userNames.length - 1);\n return { label: name, data: data, backgroundColor: USER_COLORS[i % USER_COLORS.length], borderRadius: 3, barPercentage: 0.8, stack: 'daily', order: 2,\n datalabels: { display: isLast, color: '#f8fafc', anchor: 'end', align: 'top', font: { size: 9, weight: 'bold' },\n formatter: (v, ctx) => { let sum = 0; for (let ds of ctx.chart.data.datasets) { if (ds.stack === 'daily') sum += ds.data[ctx.dataIndex] || 0; } return sum > 0 ? '$' + sum.toFixed(1) : ''; }\n }\n };\n });\n\n charts.daily = new Chart(ctx, {\n type: 'bar',\n data: { labels: dates, datasets: datasets },\n options: {\n responsive: true, maintainAspectRatio: false,\n interaction: { mode: 'index', intersect: false },\n plugins: {\n legend: { position: 'top', labels: { color: '#e2e8f0', boxWidth: 12, font: { size: 11 } } },\n tooltip: { callbacks: { label: c => c.dataset.label + ': $' + (c.raw || 0).toFixed(2) } },\n datalabels: { display: false }\n },\n scales: {\n x: { stacked: true, grid: { display: false }, ticks: { color: '#cbd5e1', font: { size: 10 } } },\n y: { stacked: true, grid: { color: '#1e293b' }, ticks: { color: '#cbd5e1', font: { size: 10 }, callback: v => '$' + v.toFixed(1) } }\n }\n }\n });\n}\n\nfunction renderSourceChart(code, desktop) {\n const ctx = document.getElementById('sourceChart');\n if (charts.source) charts.source.destroy();\n charts.source = new Chart(ctx, {\n type: 'doughnut',\n data: {\n labels: ['Claude Code', 'Claude Desktop'],\n datasets: [{ data: [code, desktop], backgroundColor: ['#3b82f6', '#8b5cf6'], borderColor: '#1e293b', borderWidth: 3 }]\n },\n options: {\n responsive: true, maintainAspectRatio: false, cutout: '65%',\n plugins: {\n legend: { position: 'bottom', labels: { color: '#e2e8f0', padding: 16, font: { size: 11 } } },\n tooltip: { callbacks: { label: c => { const t = code+desktop; return c.label + ': $' + c.raw.toFixed(2) + ' (' + (t>0?(c.raw/t*100).toFixed(1):'0') + '%)'; } } },\n datalabels: { display: true, color: '#f8fafc', font: { size: 11, weight: 'bold' }, formatter: (v, ctx) => { const t = code+desktop; return '$' + v.toFixed(2) + '\\n' + (t>0?(v/t*100).toFixed(0):'0') + '%'; } }\n }\n }\n });\n}\n\nfunction renderModelCharts(models) {\n const COLORS = ['#a78bfa','#34d399','#fbbf24','#f87171','#38bdf8','#fb923c','#e879f9','#4ade80'];\n\n // Model cost chart\n const ctx1 = document.getElementById('modelCostChart');\n if (charts.modelCost) charts.modelCost.destroy();\n if (!models.length) { charts.modelCost = null; return; }\n charts.modelCost = new Chart(ctx1, {\n type: 'bar',\n data: {\n labels: models.map(m => m.name.replace(/^anthropic\\./, '').replace(/-\\d{8}.*/, '')),\n datasets: [{ data: models.map(m => m.costUsd), backgroundColor: COLORS.slice(0, models.length), borderRadius: 4 }]\n },\n options: {\n responsive: true, maintainAspectRatio: false, indexAxis: 'y',\n plugins: {\n legend: { display: false },\n tooltip: { callbacks: { label: c => '$' + c.raw.toFixed(2) } },\n datalabels: { display: true, color: '#e2e8f0', anchor: 'end', align: 'right', font: { size: 10 }, formatter: v => '$' + v.toFixed(2) }\n },\n scales: { x: { grid: { color: '#1e293b' }, ticks: { color: '#cbd5e1', callback: v => '$' + v } }, y: { grid: { display: false }, ticks: { color: '#e2e8f0', font: { size: 11 } } } }\n }\n });\n\n // Model invocations chart\n const ctx2 = document.getElementById('modelCountChart');\n if (charts.modelCount) charts.modelCount.destroy();\n charts.modelCount = new Chart(ctx2, {\n type: 'bar',\n data: {\n labels: models.map(m => m.name.replace(/^anthropic\\./, '').replace(/-\\d{8}.*/, '')),\n datasets: [{ data: models.map(m => m.invocations), backgroundColor: COLORS.slice(0, models.length), borderRadius: 4 }]\n },\n options: {\n responsive: true, maintainAspectRatio: false, indexAxis: 'y',\n plugins: {\n legend: { display: false },\n tooltip: { callbacks: { label: c => c.raw.toLocaleString() + ' 回' } },\n datalabels: { display: true, color: '#e2e8f0', anchor: 'end', align: 'right', font: { size: 10 }, formatter: v => v.toLocaleString() }\n },\n scales: { x: { grid: { color: '#1e293b' }, ticks: { color: '#cbd5e1' } }, y: { grid: { display: false }, ticks: { color: '#e2e8f0', font: { size: 11 } } } }\n }\n });\n}\n\nfunction renderUserCards(users, JPY) {\n let t = '';\n const sorted = [...users].sort((a, b) => b.spendUsd - a.spendUsd);\n sorted.forEach(u => {\n const pct = u.limitUsd ? Math.min(100, u.spendUsd / u.limitUsd * 100) : 0;\n const cls = pct >= 90 ? 'danger' : pct >= 70 ? 'warning' : '';\n const color = pct >= 90 ? '#ef4444' : pct >= 70 ? '#f59e0b' : '#3b82f6';\n t += '<div class=\"user-card ' + cls + '\">';\n t += '<div class=\"name\"><span>' + u.name + '</span><span>';\n if (u.excluded) t += '<span class=\"badge\" style=\"background:#7c3aed;color:#e9d5ff;margin-right:4px\">除外</span>';\n t += '<span class=\"badge\">' + u.kind + '</span></span></div>';\n if (u.limitUsd) {\n t += '<div class=\"bar-bg\"><div class=\"bar-fill\" style=\"width:' + pct + '%;background:' + color + '\"></div></div>';\n t += '<div class=\"pct-label\">' + pct.toFixed(1) + '% 消費</div>';\n }\n t += '<div class=\"details\">';\n t += '<span>利用額</span><span class=\"val\">$' + u.spendUsd.toFixed(2) + ' (¥' + Math.round(u.spendUsd * JPY).toLocaleString() + ')</span>';\n if (u.limitUsd) {\n t += '<span>上限</span><span class=\"val\">$' + u.limitUsd.toFixed(2) + '</span>';\n t += '<span>残り</span><span class=\"val\">$' + u.remainingUsd.toFixed(2) + '</span>';\n }\n t += '<span>Code</span><span class=\"val\">$' + u.codeUsd.toFixed(2) + '</span>';\n t += '<span>Desktop</span><span class=\"val\">$' + u.desktopUsd.toFixed(2) + '</span>';\n if (u.invocations) t += '<span>リクエスト数</span><span class=\"val\">' + u.invocations.toLocaleString() + ' 回</span>';\n if (u.topModelByCost) t += '<span>コスト最大</span><span class=\"val\">' + shortModel(u.topModelByCost) + '</span>';\n if (u.topModelByCount && u.topModelByCount !== u.topModelByCost) t += '<span>利用最多</span><span class=\"val\">' + shortModel(u.topModelByCount) + '</span>';\n t += '</div></div>';\n });\n document.getElementById('team').innerHTML = t;\n}\n\nload();\n</script>\n</body>\n</html>\n"
},
{
"key": "callback.html",
"contentType": "text/html",
"body": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Redirecting...</title></head><body><script>const hash=location.hash.substring(1);const params=new URLSearchParams(hash);const token=params.get('id_token');if(token){localStorage.setItem('id_token',token);location.href='/'}else{document.body.textContent='Login failed';}</script></body></html>"
}
]
Outputs:
LogGroupName:
Value: !If [CreateLogGroup, !Ref LogGroupName, !Ref ExistingLogGroupName]
Description: Set Bedrock Model Invocation Logging to this log group (CloudWatch Logs destination)
LoggingRoleArn:
Value: !GetAtt BedrockLoggingRole.Arn
Description: Set Bedrock Model Invocation Logging to use this service role ARN
LogsKmsKeyArn:
Condition: UseKms
Value: !GetAtt LogsKmsKey.Arn
Description: CMK used to encrypt the invocation log S3 bucket
CostTableName:
Value: !If [CreateCostTable, !Ref CostTable, !Ref ExistingCostTableName]
Description: DynamoDB table holding per-user monthly cost (updated every minute)
SNSTopicArn:
Value: !If [CreateSnsTopic, !Ref CostReportTopic, !Ref ExistingSnsTopicArn]
LambdaFunctionName:
Value: !Ref CostGuardFunction
DashboardURL:
Value: !Sub 'https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#dashboards/name=${ResourcePrefix}-dashboard'
Description: CloudWatch Dashboard URL (includes per-user cost table)
SelfServiceDashboardURL:
Value: !Sub 'https://${DashboardDistribution.DomainName}/'
Description: Self-service dashboard URL (user-facing, Cognito login required)
DashboardApiEndpoint:
Value: !Sub 'https://${DashboardApi}.execute-api.${AWS::Region}.amazonaws.com/v1'
Description: API Gateway base URL for self-service dashboard (set in index.html CONFIG.apiBase)
CognitoClientId:
Value: !Ref DashboardUserPoolClient
Description: Cognito App Client ID (set in index.html CONFIG.clientId)
CognitoDomain:
Value: !Sub '${DashboardDomainPrefix}.auth.${AWS::Region}.amazoncognito.com'
Description: Cognito auth domain (set in index.html CONFIG.domain)
CognitoUserPoolId:
Value: !Ref DashboardUserPool
Description: Cognito User Pool ID for dashboard login
リージョンについて
Bedrockへリクエストを送信するリージョンと同じリージョンにデプロイしてください。
Bedrockのモデル呼び出しログは、リクエストを送信したリージョンのCloudWatch Logsに記録されます。Subscription Filterは同一リージョンのLambdaにしか転送できないため、テンプレートを別リージョンにデプロイするとコスト集計が動きません。
クロスリージョン推論プロファイル利用時の補足:
us.anthropic.* や jp.anthropic.* 等のクロスリージョン推論プロファイルを使っている場合、実際の推論は別リージョンで実行されますが、ログとメトリクスはリクエストを発行したリージョンに記録されます。例えば東京リージョン(ap-northeast-1)からグローバルプロファイルで呼び出した場合、推論が米国で実行されてもログは東京のCloudWatch Logsに記録されます。そのためテンプレートはリクエスト送信元リージョンにデプロイすれば問題ありません。
CloudWatchダッシュボードのModelId欄に us.anthropic.claude-... のようなIDが表示されることがありますが、これはクロスリージョン推論プロファイルのモデルID命名であり、メトリクスデータ自体はデプロイリージョンに存在しています。
ステップ1: CloudFormationスタックのデプロイ
テンプレート(bedrock-cost-guard.yaml)をCloudFormationコンソールにアップロードし、パラメータを設定してください。太字のパラメータは初回デプロイ時に必ず確認・設定してください。他はデフォルトのままで動作します。
注意:スタック作成時に「IAMリソースの作成を許可」のチェックボックスへの同意が必要です(CAPABILITY_NAMED_IAM)。ModelPricingは最新の公式料金表をご確認ください。
| パラメータ | 説明 | デフォルト |
|---|---|---|
| EmailAddress | 日次レポート・アラート送信先。複数人に送る場合はメーリングリストを指定 | (必須) |
| CostLimitUsd | ユーザー/ロール別の月額上限。名前:金額のカンマ区切り。0=上限なし |
0 |
| SourceTags | 集計対象のrequestMetadata.source値(カンマ区切り) |
claude-code,claude-desktop |
| ExcludeUsers | 上限チェックから除外するユーザー/ロール名(カンマ区切り) | (空) |
| ResourcePrefix | 全リソース名のプレフィックス | bedrock-cost-guard |
| LogGroupName | Bedrockログ用CloudWatch Logsグループ名 | /aws/bedrock/cost-guard |
| CostTag | リソースに付与するコスト配分タグ値 | (空) |
| LogRetentionDays | ログ保持期間(日) | 365 |
| CapCheckIntervalMinutes | コスト上限チェック間隔(分) | 1 |
| WarningThresholdPercent | 警告通知を出す閾値(%) | 80 |
| ReportScheduleHourJst | 日次レポート送信時刻(JST、時) | 9 |
| ReportScheduleMinuteJst | 日次レポート送信時刻(JST、分) | 0 |
| JpyRate | USD→JPY換算レート | 160 |
| ModelPricing | モデル別料金(1Mトークンあたり input/output/cache_write/cache_read) | sonnet:3.0/15.0/3.75/0.30,opus:5.0/25.0/6.25/0.50,haiku:… |
| DashboardDomainPrefix | Cognitoドメイン(グローバルに一意である必要あり) | bedrock-cost-guard |
| AllowedCidrs | ダッシュボードアクセス許可CIDR(カンマ区切り) | 0.0.0.0/0 |
| EnableS3Logging | S3へのログ保管 | false |
| EnableLogEncryption | S3ログのKMS暗号化(S3有効時のみ) | false |
| LogReaderArns | S3ログの復号を許可するIAM ARN(カンマ区切り)。空=制限なし | (空) |
| LogTextData | テキスト入出力本文をログに含める | false |
| LogImageData | 画像データをログに含める | false |
| LogVideoData | 動画データをログに含める | false |
| LogEmbeddingData | 埋め込みデータをログに含める | false |
| LogAudioData | 音声データをログに含める | false |
| ExistingLogGroupName | 既存ロググループ名(空=新規作成) | (空) |
| ExistingCostTableName | 既存DynamoDBテーブル名(空=新規作成) | (空) |
| ExistingSnsTopicArn | 既存SNSトピックARN(空=新規作成) | (空) |
| ExistingLogBucketName | 既存S3ログバケット名(空=新規作成) | (空) |
| ExistingDashboardBucketName | 既存ダッシュボードバケット名(空=新規作成) | (空) |
ステップ2: SNSサブスクリプションの確認
デプロイ後、設定したメールアドレスにSNSの確認メールが届きます。「Confirm subscription」をクリックして購読を有効化してください。
ステップ3: Bedrockログ設定の確認
テンプレートがBedrockのモデル呼び出しログ設定を自動で行います。手動設定は不要です。
スタック作成完了後、Bedrockコンソール → Settings → Model invocation logging で以下が設定されていることを確認してください:
- CloudWatch Logs: 有効(ロググループ名はOutputsの
LogGroupName) - S3(
EnableS3Logging=trueの場合): 有効 - サービスロール: OutputsのLoggingRoleArn
ステップ4: セルフサービスダッシュボードの利用者登録
セルフサービスダッシュボード(CloudFront経由のWebUI)はテンプレートが自動構築します。HTML配置やCONFIG設定は不要です。
ダッシュボードにアクセスさせたい利用者のCognitoユーザーを作成してください。以下はCLIコマンドのサンプルです。
ダッシュボードURLは、スタックOutputsの SelfServiceDashboardURLです。
aws cognito-idp admin-create-user \ --user-pool-id (OutputsのCognitoUserPoolId) \ --username user@example.com \ --user-attributes Name=email,Value=user@example.com Name=email_verified,Value=true
ステップ5: requestMetadata.sourceの設定
Claude Code(CLI)の場合:
~/.claude/settings.json に以下を追加:
{
"env": {
"ANTHROPIC_CUSTOM_HEADERS": "X-Amzn-Bedrock-Request-Metadata: {\"source\": \"claude-code\"}"
}
}
Claude Desktop(Chat/Cowork/Code)の場合:
①左下のアイコンをクリックし、推論設定をクリック
②カスタム推論ヘッダーを以下の通り設定して変更を適用
X-Header-Name:X-Amzn-Bedrock-Request-Metadata
値:{“source”:”claude-desktop”}
日次レポートの例
デフォルトでは毎日9:00(JST)に以下のようなメールが届きます(ReportScheduleHourJst/ReportScheduleMinuteJstで変更可能)。
[Bedrock Cost Guard] 日次コストレポート 2026-06-18 AWSアカウント: 123456789012 (1 USD = 160 JPY) 今月 (6/1-6/18): $28.45 (JPY4,552) --- [今月のユーザー別コスト] tanaka: $8.20 (JPY1,312) (上限: $50.00) [Code $7.10 / Desktop $1.10] suzuki: $6.50 (JPY1,040) (上限: $50.00) [Code $6.50] yamada: $3.80 (JPY608) (上限: $30.00) [Desktop $3.80] sato: $1.20 (JPY192) (上限: $30.00) [Code $0.90 / Desktop $0.30] [今月のロール別コスト] team-a-bedrock-role: $5.50 (JPY880) (上限: $100.00) [Code $3.20 / Desktop $2.30] team-b-bedrock-role: $2.25 (JPY360) (上限: $80.00) [Code $2.25] intern-bedrock-role: $1.00 (JPY160) (上限: $10.00) [Desktop $1.00]
上限設定の仕組み
CostLimitUsdパラメータで、IAMユーザー名またはIAMロール名ごとに月額上限を指定します。
設定例:
tanaka:50,team-a-bedrock-role:100,team-b-bedrock-role:80
上限超過時の動作:
- 80%到達時 → SNSで警告メール送信(まだ利用可能)
- 100%超過時 → IAM DenyPolicyを自動アタッチし遮断 + SNSで遮断通知
- 月初1日 0:00 JST → DenyPolicyを自動デタッチ(全員解除)
S3ログ保管(オプション機能)
EnableS3Logging=true を設定すると、Bedrockの呼び出しログがS3バケットにも保管されます。
| 設定 | 効果 |
|---|---|
EnableS3Logging=true |
S3バケットを作成し、Bedrockログを保管 |
EnableLogEncryption=true |
KMSカスタマーキーでバケットを暗号化 |
LogReaderArns=arn:... |
指定したIAMエンティティのみ復号を許可。それ以外はAccessDenied |
アラートが届いたときの対応
① 「警告: 上限の80%に到達」
当該ユーザーに利用ペースの抑制を周知
② 「利用停止: DenyPolicyアタッチ」
月初に自動解除。緊急時はIAMコンソールから手動デタッチ
③ 「スロットリング発生」(CloudWatch Alarm)
Service Quotas引き上げ申請 or クロスリージョン推論プロファイルで分散
制約・留意事項
- 推定コストは概算です。厳密な上限保証ではなくガードレールです
- 検知から遮断までに最大約16分の遅延があります(CWLログ配信5-15分 + cap_check間隔1分)
requestMetadata.sourceを設定していないユーザーの利用は集計対象外- IAM Identity Center(SSO)の権限セットロール(AWSReservedSSO_*)には直接DenyPolicyをアタッチできません。カスタムロールへのAssumeRole運用を推奨
- Claude Desktop内のChat/Cowork/Codeモードの区別はできません(同一source値)
- DynamoDBテーブルとロググループはスタック削除後も保持されます(DeletionPolicy: Retain)
カスタマイズガイド
| やりたいこと | 変更箇所 |
|---|---|
| 通知先を変更 | EmailAddressを変更。複数人に送りたい場合はメーリングリストを指定してください |
| 上限額を変更 | CostLimitUsdを変更 |
| 特定ユーザーを上限対象外に | ExcludeUsersに追加 |
| レポート送信時刻を変更 | ReportScheduleHourJst / ReportScheduleMinuteJstを変更 |
| 料金単価を更新(料金改定時) | ModelPricingを更新してスタック更新 |
| 為替レートを変更 | JpyRateを変更 |
| 既存リソースを再利用 | ExistingLogGroupName / ExistingCostTableName等を指定 |
まとめ
- CloudFormationテンプレート1つで、Claude on Amazon Bedrockのユーザー/チーム単位のコスト管理が完結する
- 検知から遮断まで最大16分の遅延があるものの、月額$2未満(小規模の場合)でプロキシもコンテナも不要
- 5〜20名規模の検証・導入フェーズでは十分に実用的。50名超になったらLiteLLM等への移行を検討
公式から利用料の上限設定機能が提供された際には不要な仕組みになりますが、それまでの間にどなたかの参考になりましたら幸いです。








