AWS Business Value Ratio: The Cost Health Check Your FinOps Team Is Missing
AWS Business Value Ratio (BVR) is a FinOps metric that compares the percentage of cloud spend directly tied to revenue-generating or customer-facing workloads against spend consumed by drift, redundancy, or unreviewed infrastructure. Tracking BVR helps teams distinguish wasteful accumulation from legitimate architectural cost, enabling more precise remediation than blanket rightsizing efforts typically achieve.
1. Two causes, one bill
AWS cost posture problems in product accounts come from two distinct sources, and most remediation frameworks conflate them, which is why so much cost optimisation effort produces disappointing results. The detection scripts behind this analysis are published at github.com/andrewbakercloudscale/aws-bvr1.
The more common cause is drift. Engineers make locally reasonable decisions that nobody reviews in aggregate: a cluster gets sized for a peak load that never materialises, log groups accumulate without retention policies because the default is never expire, a directory service outlives the migration project it was built for, and GuardDuty add ons get enabled speculatively during a security review and never revisited. Each decision was defensible at the time and invisible in isolation; the problem only becomes apparent when the bill is read as a whole. Drift is gradual and it compounds, which is why accounts that looked reasonable at twelve months can look broken at twenty four.
The less common but harder to fix cause is provisioning standards. Corporate landing zone templates and account vending pipelines deploy security tooling, certificate infrastructure, directory services, and observability stacks before a single application workload exists. The account arrives with a prebaked cost structure that nobody has reviewed against what the product team actually needs, and every new account vended from the same template inherits the same problem. The bill in the screenshot accompanying this post illustrates this pattern: it is a new account, with OpenSearch and Certificate Manager together accounting for over sixty percent of total spend before any product code was deployed.
The distinction matters because the fixes point in different directions. Drift is addressed through ongoing governance: retention policies, periodic right sizing reviews, tagging enforcement, and budget alerts that surface anomalies before they compound. Provisioning problems are addressed upstream in the vending template itself, because fixing individual accounts one at a time while the template keeps producing the same baseline is remediation on a treadmill.
One important scope boundary before going further: this framework applies to product accounts. It does not apply to shared services accounts where the entire workload is logging, security tooling, or cryptographic infrastructure. A shared logging account that is ninety percent CloudWatch and OpenSearch is correctly configured. A product account with the same profile is not.
2. Why reserved instances and right sizing miss the point
Standard AWS cost optimisation frameworks address unit cost by reducing what you pay per hour for a known workload through reserved instances, savings plans, and right sizing. These are valid techniques and they produce real savings, but they answer the wrong question first because applying a reserved instance to an OpenSearch cluster that is running as a log sink rather than powering a product feature locks in spend that should be eliminated at a lower unit rate. Ninety day retention on CloudWatch log groups with no active consumer is not governance, and GuardDuty Malware Protection enabled on a serverless workload with no executable binaries is not security coverage.
The question that precedes unit cost is whether the workload composition is appropriate in the first place. Optimising before answering that question commits engineering effort to reducing the cost of the wrong things, so the Business Value Ratio framework answers the composition question first and lets unit cost optimisation follow from a corrected baseline.
3. The Business Value Ratio
The Business Value Ratio is defined as:
Adjusted BVR = Product Spend / (Total Spend - Tax - Credits - Support) Total AWS spend includes line items that reflect billing mechanics rather than architectural decisions: tax, support tiers, savings plan amortisation, and enterprise agreement credits. Including these in the denominator means two identically architected accounts can score differently based on their commercial arrangements rather than their cost composition. Adjusted BVR removes those line items to produce a stable comparison. The script classifies Tax separately and excludes it automatically; Credits and Support charges should be excluded manually if present.
Before the taxonomy: BVR classifies spend by workload purpose, not by AWS service. Service mapping is a bootstrap mechanism for accounts that lack ownership tagging maturity. S3 can be product storage or a backup archive. Lambda can serve a customer API or run a compliance job. EKS can host a revenue generating platform or a team internal tool. The same service in two different accounts can be correctly classified in two different categories. The service list that follows is a practical starting point, not a permanent judgement; the tag based classification described in section 5 is how mature organisations override it.
A bank’s compliance controls, a security company’s GuardDuty deployment, or a platform team’s observability stack may carry significant strategic value while correctly scoring low on BVR in a product account context. The metric is not a judgement on whether spending is wise; it is a signal about whether the composition of spend in a given account matches the stated purpose of that account.
Services fall into three categories. Product services directly run or expose something a customer interacts with: EC2, ECS, EKS, Lambda, RDS, Aurora, DynamoDB, SageMaker, Bedrock, Kinesis, S3 in its primary storage role, and Amazon Connect when it powers customer contact centre operations. The test is simple: removing this service degrades a customer outcome. Enablement services are necessary plumbing that breaks delivery but not customer function when removed: VPC, Route 53, Certificate Manager, load balancers, IAM, Secrets Manager, KMS, Directory Service, OpenSearch, and WAF. They should sit at a predictable and modest fraction of total spend, and when a single enablement service is a top three cost item in a product account that is a signal worth investigating. Governance services are observability, audit, compliance, and security controls: CloudWatch, Config, GuardDuty, CloudTrail, and Security Hub. Removing these reduces safety and compliance posture rather than breaking customer function. Their spend should be proportionate to the size and risk profile of what they are watching.
One caveat on gaming: the goal is not to maximise BVR by cutting governance or security spend. An account that deletes its CloudTrail, disables GuardDuty, and inflates EC2 spend will show an improved BVR while becoming materially less safe. The metric is a diagnostic tool, not an optimisation target, and it should be read alongside a companion metric that makes the incentive explicit.
The Governance Load Ratio (GLR) is defined as:
GLR = (Governance Spend + Enablement Spend) / Product Spend Where BVR asks whether enough spend is pointed at the product, GLR asks whether the combined weight of governance and enablement is proportionate to the product tier it supports. Including enablement in the numerator matters because services like ACM Private CA, Directory Service, and load balancers are enablement costs that compound just as readily as governance costs, and excluding them would let the most common BVR killers escape the ratio entirely. A healthy product account carries a GLR below 0.25, meaning governance and enablement together cost less than a quarter of what the product tier costs to run. A GLR between 0.25 and 0.75 warrants review. Above 0.75 the account is infrastructure heavy relative to its product footprint, which is the correct diagnosis for the account in the example bill regardless of whether the underlying cause is drift or provisioning standards. Reading BVR and GLR together removes the incentive to improve one metric by degrading the other.
BVR benchmarks for product accounts:
| BVR | Assessment |
|---|---|
| 60% and above | Healthy. Most spend is directed at the product. |
| 35% to 60% | Warning. Enablement and governance spend is elevated relative to product spend. |
| Below 35% | Critical. The account costs more to run than the business it exists to serve. |
These thresholds are heuristic operating bands derived from reviewing product accounts across multiple environments, not statistically validated benchmarks. They are starting points for a conversation rather than pass/fail gates, and organisations with unusual cost structures should calibrate them against their own baseline before treating the defaults as absolute.
A BVR below 35% in a mature product account is strong evidence that spend composition should be reviewed before further optimisation is attempted. The example bill has EC2-Instances at $171 against total spend of $17,325, giving a BVR of approximately one percent, and that number should end the conversation about savings plans and start one about account standards.
New accounts require separate treatment. If total spend is below $150 per month and value generating services are near zero, computing a BVR percentage produces a technically accurate but practically misleading number. A ratio of zero point one percent carries no diagnostic weight when there is no workload to measure yet. The script in section 5 detects this condition automatically and surfaces a specific finding: the account has no value generating workload, all current spend is standards overhead, and production workloads should not be deployed until that baseline is reviewed and rationalised.
4. The six patterns most worth fixing first
These are ranked by the size of recovery they typically produce, not by frequency.
4.1 Private Certificate Authority left running
Public ACM certificates are free, so if Certificate Manager is generating material spend in a product account the cause is almost certainly one or more private CAs running under ACM Private CA, each costing four hundred US dollars per month regardless of certificate volume and regardless of whether it is actively used. The script flags Certificate Manager above a twenty dollar detection floor, but a single active private CA will appear as $400 or more before a single certificate is issued, so any Certificate Manager line above that threshold in a product account almost certainly represents redundant or unconsolidated private PKI rather than normal certificate operations. A Certificate Manager line of $3,873 implies three active private CAs, and the questions to ask are whether all three are in use, whether they can be consolidated under a single CA hierarchy, and whether all consumers genuinely require private PKI or were issued private certificates because private was assumed to be the safer default. Private PKI is architecturally correct for zero trust environments and mutual TLS at scale, but the economics require deliberate design and unused or redundant private CAs are among the highest unit cost waste items in the platform.
Potential monthly recovery: $400 to $1,200 per redundant CA decommissioned.
4.2 OpenSearch running as a log sink
OpenSearch is classified as an enablement service rather than a product service throughout this framework, because it is most commonly deployed as internal infrastructure: a log sink, a query layer for operational data, or a search backend for internal tooling. Even in cases where OpenSearch does power a customer facing feature, it is rarely the customer facing surface itself; it sits behind an API that the product team owns and operates. Classifying it as enablement rather than product ensures that high OpenSearch spend is always surfaced for review rather than silently absorbing BVR points that obscure a broken cost composition.
The pattern most worth catching is the log sink variant: an engineer centralises logs into a cluster, sizes it for performance, and the cluster runs indefinitely because nobody owns the migration away from it. A year later it is the most expensive line item in the account and the primary workload is log search by the operations team.
To confirm which situation you are in, run the following to check cluster configuration and then inspect index naming patterns:
aws opensearch list-domain-names u005cn u002du002dquery 'DomainNames[*].DomainName' u005cn u002du002doutput text | u005cnxargs -I{} aws opensearch describe-domain u005cn u002du002ddomain-name {} u005cn u002du002dquery 'DomainStatus.{Name:DomainName,Nodes:ClusterConfig.InstanceCount,Type:ClusterConfig.InstanceType}' Indices named after CloudWatch log groups, application log streams, or dated with daily suffixes such as cwl-2026.05.14 confirm the log sink pattern. A log sink cluster in a product account belongs in a shared logging account with appropriate cost allocation, or it should be replaced with S3 plus Athena for ad hoc queries and CloudWatch Logs Insights for operational queries, since OpenSearch Serverless removes always on cluster provisioning and shifts cost toward usage based indexing and query consumption, which is often lower total cost for intermittent workloads though the outcome is workload dependent and should be validated before migration.
Potential monthly recovery: full cluster cost, typically $500 to $5,000 depending on node count and instance type.
4.3 CloudWatch without log retention policies
CloudWatch charges for ingestion, storage, and custom metric API calls, and storage charges compound invisibly because AWS defaults to never expire retention on all log groups. Every Lambda invocation, every ECS container, and every API Gateway request accumulates at the per GB storage rate indefinitely, producing a cost curve that climbs regardless of whether the logs are ever read. To identify affected log groups:
aws logs describe-log-groups u005cn u002du002dquery 'logGroups[?retentionInDays==`null`].[logGroupName,storedBytes]' u005cn u002du002doutput table Setting retention to thirty days for operational logs and ninety days for security relevant logs is the immediate fix, and enforcing this going forward with an EventBridge rule that fires on CreateLogGroup events and applies a default retention policy automatically prevents the pattern from recurring.
Potential monthly recovery: $50 to $500 depending on log volume and how long groups have been accumulating without retention.
4.4 Directory Service outliving its original purpose
AWS Managed Microsoft AD runs a minimum of two domain controllers per directory at approximately $0.16 per DC per hour, which is roughly $230 per directory per month before traffic charges. Directories get provisioned for migration projects and left running because nobody is confident about what still depends on them, and orphaned instances are particularly common in accounts that have completed a Windows to Linux migration or moved away from RDS SQL Server with Windows Authentication.
aws ds describe-directories u005cn u002du002dquery 'DirectoryDescriptions[*].{Name:Name,Type:Type,Status:Stage,Created:LaunchTime}' u005cn u002du002doutput table Any directory with no active domain joins in the past sixty days should be flagged for a decommission review.
Potential monthly recovery: $230 per orphaned directory.
4.5 GuardDuty add ons without a response process
GuardDuty base costs scale with CloudTrail, VPC Flow Log, and DNS query volume, and the optional add ons including Malware Protection, EKS Runtime Monitoring, RDS Protection, and Lambda Network Monitoring each carry additional per unit charges. The question is not whether GuardDuty is enabled but whether the findings are being actioned, because GuardDuty running with multiple add ons and no active process consuming findings is spend without a security outcome. Malware Protection on a serverless workload with no executable binaries produces no actionable findings, and EKS Runtime Monitoring without a runtime security capability produces noise. The right approach is to enable only the protection types that correspond to a threat surface the team has a response playbook for.
Potential monthly recovery: $50 to $300 depending on which add ons are active and account traffic volume.
4.6 Cross account observability duplication
This pattern is less visible than the others because its cost is distributed across many accounts and no single line item looks alarming, but in aggregate it is frequently the largest source of governance overspend in organisations running more than a handful of accounts.
The mechanism is straightforward: when a landing zone template provisions CloudTrail, Config, Flow Logs, and security tooling into every new account independently, those services run in parallel with the organisation level equivalents already collecting the same data centrally. A CloudTrail org trail captures all API events across the organisation; an account level trail in the same account captures the same events again and charges separately for storage and delivery. Config organisation level aggregation records configuration changes centrally; per account Config recording in each member account records the same changes and charges per configuration item. Flow Logs enabled at the VPC level in every account feed data into both a central SIEM and account local CloudWatch log groups, paying ingestion costs twice. Prometheus and Grafana agents deployed per account collect metrics that are also collected by a central observability platform.
None of these duplications is created maliciously. They are the natural result of central security teams deploying tooling for coverage without visibility into what the account vending template already provisions, combined with product teams enabling account local logging for debugging convenience without knowing it replicates existing collection. The cost is invisible at the account level precisely because each individual account’s share is modest; the problem only surfaces when totalled across an organisation at scale.
The audit is to compare what the organisation level trail, Config aggregator, and central SIEM are collecting against what individual accounts are independently provisioning, and to remove account level duplication where the central collection already provides adequate coverage and retention. In regulated environments this requires a compliance review before disabling anything, since some frameworks explicitly require account level audit trails in addition to organisation level ones, but even in those cases there is typically scope to consolidate Flow Log destinations and reduce per account Prometheus collection.
Potential monthly recovery: $20 to $200 per account, compounding significantly at organisational scale.
5. The script
aws_value_check.py fetches cost and usage data via the Cost Explorer API, classifies each service as PRODUCT, ENABLEMENT, or GOVERNANCE, computes the BVR and GLR, and flags services exceeding review thresholds with specific remediation guidance. It detects new accounts automatically when monthly average spend is below $150 with no value generating workload present, and the --new-account flag forces that detection path regardless of spend level.
Install the script with the following heredoc block, which writes the file and sets executable permissions in a single operation:
cat u003e aws_value_check.py u003cu003c 'EOF'n#!/usr/bin/env python3nu0022u0022u0022naws_value_check.py u002du002d AWS Business Value Cost Auditorn========================================================nClassifies every service in your AWS bill as Value-Generating,nOverhead, or Administrative, computes a Business Value Ratio (BVR),nand flags services that warrant urgent attention.nnRequirements:n pip install boto3 rich python-dateutilnnIAM permission required:n ce:GetCostAndUsage (Cost Explorer read access)nnUsage:n python aws_value_check.py # last 3 monthsn python aws_value_check.py u002du002dmonths 6 # last 6 monthsn python aws_value_check.py u002du002dcsv out.csv # also write CSVn python aws_value_check.py u002du002dprofile myprof # named AWS profilen python aws_value_check.py u002du002dnew-account # force new account modenu0022u0022u0022nnimport argparsenimport csvnimport sysnfrom datetime import datenfrom dateutil.relativedelta import relativedeltannimport boto3nfrom botocore.exceptions import ClientError, NoCredentialsErrornntry:n from rich.console import Consolen from rich.table import Tablen from rich.panel import Paneln from rich import boxn RICH = Truenexcept ImportError:n RICH = Falsenn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Service classification taxonomyn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nnVALUE_GENERATING = {n u0022Amazon EC2u0022, u0022Amazon EC2 - Otheru0022, u0022EC2 - Otheru0022, u0022EC2-Instancesu0022, u0022EC2-Otheru0022,n u0022Amazon Elastic Compute Cloud - Computeu0022, u0022Amazon Elastic Compute Cloudu0022,n u0022Amazon Elastic Container Serviceu0022, u0022Amazon Elastic Kubernetes Serviceu0022,n u0022AWS Lambdau0022, u0022Amazon RDSu0022, u0022Amazon Aurorau0022,n u0022Amazon Relational Database Serviceu0022,n u0022Amazon DynamoDBu0022, u0022Amazon ElastiCacheu0022, u0022Amazon Redshiftu0022, u0022Amazon EMRu0022,n u0022Amazon SageMakeru0022, u0022Amazon Bedrocku0022,n u0022Amazon Rekognitionu0022, u0022Amazon Comprehendu0022, u0022Amazon Translateu0022,n u0022Amazon Transcribeu0022, u0022Amazon Pollyu0022, u0022Amazon Lexu0022, u0022Amazon Personalizeu0022,n u0022Amazon Forecastu0022, u0022Amazon Kendrau0022,n u0022Amazon S3u0022, u0022Amazon Simple Storage Serviceu0022, u0022Amazon S3 Glacieru0022,n u0022Amazon OpenSearch Serviceu0022, u0022Amazon API Gatewayu0022, u0022Amazon AppSyncu0022,n u0022Amazon SQSu0022, u0022Amazon Simple Queue Serviceu0022,n u0022Amazon SNSu0022, u0022Amazon Simple Notification Serviceu0022,n u0022Amazon Kinesisu0022, u0022Amazon MSKu0022,n u0022Amazon EventBridgeu0022, u0022Amazon Connectu0022, u0022Amazon Pinpointu0022, u0022Amazon WorkSpacesu0022,n u0022AWS Elastic Beanstalku0022, u0022Amazon Lightsailu0022, u0022AWS Amplifyu0022, u0022AWS App Runneru0022,n u0022Amazon ECSu0022, u0022Amazon EKSu0022, u0022Amazon ECRu0022, u0022AWS Batchu0022, u0022Amazon MQu0022,n u0022Amazon DocumentDBu0022, u0022Amazon Neptuneu0022, u0022Amazon Timestreamu0022, u0022Amazon QLDBu0022,n u0022Amazon Managed Blockchainu0022, u0022AWS Glueu0022, u0022Amazon Athenau0022, u0022Amazon QuickSightu0022,n u0022AWS Lake Formationu0022, u0022Amazon DataZoneu0022,n u0022Amazon Registraru0022,n}nnOVERHEAD = {n u0022Amazon VPCu0022, u0022Amazon Virtual Private Cloudu0022, u0022VPCu0022,n u0022AWS Transit Gatewayu0022, u0022Amazon CloudFrontu0022,n u0022Amazon Route 53u0022, u0022Route 53u0022,n u0022AWS Direct Connectu0022,n u0022Amazon Elastic Load Balancingu0022, u0022Elastic Load Balancingu0022,n u0022AWS Global Acceleratoru0022, u0022AWS PrivateLinku0022,n u0022AWS Certificate Manageru0022, u0022Certificate Manageru0022,n u0022AWS Secrets Manageru0022, u0022Secrets Manageru0022,n u0022AWS Key Management Serviceu0022, u0022Key Management Serviceu0022,n u0022AWS Identity and Access Managementu0022, u0022AWS IAM Identity Centeru0022,n u0022Amazon Cognitou0022, u0022AWS Directory Serviceu0022, u0022Directory Serviceu0022,n u0022AWS WAFu0022, u0022AWS Shieldu0022, u0022Amazon Inspectoru0022, u0022AWS Firewall Manageru0022,n u0022AWS Network Firewallu0022, u0022Amazon Macieu0022, u0022AWS Security Hubu0022,n u0022Amazon Backupu0022, u0022AWS Disaster Recovery Serviceu0022,n u0022AWS Elastic Disaster Recoveryu0022, u0022AWS DataSyncu0022, u0022AWS Transfer Familyu0022,n u0022AWS Snow Familyu0022, u0022AWS Storage Gatewayu0022, u0022Amazon WorkMailu0022,n u0022Amazon WorkDocsu0022, u0022Amazon Chimeu0022, u0022AWS Systems Manageru0022, u0022Systems Manageru0022,n u0022AWS OpsWorksu0022, u0022AWS CodeDeployu0022, u0022AWS CodePipelineu0022, u0022AWS CodeBuildu0022,n u0022AWS CodeCommitu0022, u0022AWS CodeArtifactu0022, u0022Amazon ECR Publicu0022,n u0022AWS App Meshu0022, u0022AWS Service Meshu0022, u0022Amazon EFSu0022, u0022AWS Fargateu0022,n}nnADMINISTRATIVE = {n u0022Amazon CloudWatchu0022, u0022CloudWatchu0022, u0022AmazonCloudWatchu0022,n u0022AWS CloudTrailu0022, u0022AWS Configu0022, u0022Configu0022,n u0022Amazon GuardDutyu0022, u0022GuardDutyu0022, u0022AWS Security Hubu0022, u0022Amazon Detectiveu0022,n u0022Amazon Inspectoru0022, u0022Amazon Inspector2u0022, u0022AWS Trusted Advisoru0022,n u0022AWS Cost Exploreru0022, u0022AWS Budgetsu0022, u0022AWS Control Toweru0022, u0022AWS Organizationsu0022,n u0022AWS Service Catalogu0022, u0022AWS Audit Manageru0022, u0022AWS License Manageru0022,n u0022AWS Resource Access Manageru0022, u0022AWS Compute Optimizeru0022,n u0022AWS Health Dashboardu0022, u0022AWS Personal Health Dashboardu0022, u0022AWS Supportu0022,n u0022AWS Artifactu0022, u0022Amazon Managed Grafanau0022,n u0022Amazon Managed Service for Prometheusu0022, u0022AWS X-Rayu0022, u0022AWS Chatbotu0022,n u0022Amazon DevOps Guruu0022, u0022AWS Fault Injection Simulatoru0022, u0022AWS Resilience Hubu0022,n u0022AWS Migration Hubu0022, u0022AWS Application Migration Serviceu0022,n u0022AWS Database Migration Serviceu0022, u0022AWS Schema Conversion Toolu0022,n u0022AWS Well-Architected Toolu0022, u0022AWS Service Quotasu0022, u0022AWS Resource Groupsu0022,n u0022AWS Tag Editoru0022, u0022Taxu0022, u0022AWS Taxu0022,n u0022Savings Plans for AWS Compute usageu0022, u0022Savings Plans Upfront Feeu0022,n u0022AWS Reserved Instanceu0022,n}nn# Services that are nearly always orphaned or accumulated waste in product accounts.n# EBS snapshots show up under u0022Amazon EBSu0022 in Cost Explorer when billed separately,n# or as part of u0022EC2 - Otheru0022 / u0022Amazon EC2u0022 at the usage-type level.nWASTE = {n u0022Amazon EBSu0022,n u0022Amazon EC2 Snapshotsu0022,n u0022EBS Snapshotsu0022,n}nnWARNING_THRESHOLDS = {n u0022Amazon CloudWatchu0022: 50, u0022CloudWatchu0022: 50,n u0022AWS Configu0022: 30, u0022Configu0022: 30,n u0022Amazon GuardDutyu0022: 100, u0022GuardDutyu0022: 100,n u0022AWS CloudTrailu0022: 50,n u0022Amazon OpenSearch Serviceu0022: 500,n u0022AWS Directory Serviceu0022: 200, u0022Directory Serviceu0022: 200,n u0022Amazon Route 53u0022: 200, u0022Route 53u0022: 200,n u0022AWS Certificate Manageru0022: 100, u0022Certificate Manageru0022: 100,n u0022AWS Secrets Manageru0022: 50, u0022Secrets Manageru0022: 50,n u0022Amazon EBSu0022: 20, u0022Amazon EC2 Snapshotsu0022: 20, u0022EBS Snapshotsu0022: 20,n}nnREVIEW_ADVICE = {n u0022Amazon OpenSearch Serviceu0022: (n u0022Confirm this cluster is serving a customer-facing feature rather than u0022n u0022internal log analysis. If it is a log sink, replace with S3 plus Athena u0022n u0022for ad hoc queries or OpenSearch Serverless to eliminate provisioned node u0022n u0022costs. A shared cluster serving a single team rarely needs production-tier u0022n u0022node counts.u0022n ),n u0022Amazon CloudWatchu0022: (n u0022CloudWatch costs compound across log ingestion, retention, and custom u0022n u0022metrics. Set retention policies on all log groups (30 days for operational, u0022n u002290 days for security-relevant logs), reduce custom metric resolution where u0022n u00221-minute granularity is not needed, and route high-volume application logs u0022n u0022to S3 via Firehose.u0022n ),n u0022CloudWatchu0022: (n u0022Set retention policies on all log groups, reduce custom metric resolution, u0022n u0022and route high-volume application logs to S3 via Firehose.u0022n ),n u0022AWS Configu0022: (n u0022Config charges per configuration item recorded. Disable rules not actively u0022n u0022monitored, exclude ephemeral resources such as Lambda versions and ECS tasks, u0022n u0022and use Config aggregation rather than per-account rules where possible.u0022n ),n u0022Configu0022: (n u0022Disable rules not actively reviewed and exclude ephemeral resource types u0022n u0022to reduce configuration item volume.u0022n ),n u0022AWS Certificate Manageru0022: (n u0022Public ACM certificates are free. Material cost here almost certainly means u0022n u0022ACM Private CA at USD 400 per month per CA regardless of usage. Audit active u0022n u0022private CAs, consolidate under a single CA hierarchy, and confirm all u0022n u0022consumers genuinely require private PKI.u0022n ),n u0022Certificate Manageru0022: (n u0022Material ACM cost almost always means Private CA at USD 400 per month per CA. u0022n u0022Audit active private CAs and consolidate where possible.u0022n ),n u0022Amazon Route 53u0022: (n u0022Route 53 charges per hosted zone and per query. Audit for stale or redundant u0022n u0022zones, disable health checks on non-critical endpoints, and move high-volume u0022n u0022internal resolution to a private hosted zone with Route 53 Resolver.u0022n ),n u0022Route 53u0022: (n u0022Audit hosted zones for stale entries, disable health checks on non-critical u0022n u0022endpoints, and use private hosted zones for internal resolution.u0022n ),n u0022AWS Directory Serviceu0022: (n u0022Managed Microsoft AD bills per domain controller at approximately USD 0.16 u0022n u0022per DC-hour across a minimum of two DCs per directory. Confirm all directories u0022n u0022are actively used; orphaned directories from legacy migrations are common and u0022n u0022cost roughly USD 230 per month each.u0022n ),n u0022Directory Serviceu0022: (n u0022Audit active directories. Orphaned Managed AD instances from old migrations u0022n u0022cost roughly USD 230 per month each and are frequently overlooked.u0022n ),n u0022Amazon GuardDutyu0022: (n u0022GuardDuty costs scale with CloudTrail, VPC Flow Log, and DNS log volume. u0022n u0022Confirm findings are being triaged and actioned. Disable add ons such as u0022n u0022Malware Protection or EKS Runtime Monitoring if there is no response playbook u0022n u0022for those finding types.u0022n ),n u0022GuardDutyu0022: (n u0022Confirm findings are being actioned and disable protection types not covered u0022n u0022by an active response playbook.u0022n ),n u0022AWS Secrets Manageru0022: (n u0022Secrets Manager charges per secret per month plus per API call. Audit for u0022n u0022unused secrets and use SSM Parameter Store for non-sensitive configuration u0022n u0022to eliminate per-secret monthly charges.u0022n ),n u0022Secrets Manageru0022: (n u0022Audit for unused secrets and use SSM Parameter Store for non-sensitive u0022n u0022configuration to eliminate per-secret monthly charges.u0022n ),n u0022Amazon EBSu0022: (n u0022EBS snapshot storage accumulates indefinitely unless a lifecycle policy is u0022n u0022applied. Audit for snapshots older than 90 days with no active AMI dependency, u0022n u0022delete orphaned volumes (state: available), and enable Data Lifecycle Manager u0022n u0022to enforce automated retention on all future snapshots.u0022n ),n u0022Amazon EC2 Snapshotsu0022: (n u0022Snapshot costs grow silently — AWS does not expire them by default. u0022n u0022Run `aws ec2 describe-snapshots u002du002downer-ids self` to list all snapshots, u0022n u0022identify those with no AMI or active restore dependency, and delete them. u0022n u0022Use Data Lifecycle Manager policies to cap retention going forward.u0022n ),n u0022EBS Snapshotsu0022: (n u0022Snapshot costs grow silently — AWS does not expire them by default. u0022n u0022Audit with `aws ec2 describe-snapshots u002du002downer-ids self`, delete orphaned u0022n u0022snapshots, and enforce retention via Data Lifecycle Manager.u0022n ),n}nnNEW_ACCOUNT_SPEND_THRESHOLD = 150.0nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Classification and helpersn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef classify(service_name: str) -u003e str:n if service_name in WASTE:n return u0022WASTEu0022n if service_name in VALUE_GENERATING:n return u0022VALUEu0022n if service_name in OVERHEAD:n return u0022OVERHEADu0022n if service_name in ADMINISTRATIVE:n return u0022ADMINu0022n sl = service_name.lower()n if any(k in sl for k in (u0022snapshotu0022,)):n return u0022WASTEu0022n if any(k in sl for k in (u0022ec2u0022, u0022rdsu0022, u0022lambdau0022, u0022sagemakeru0022, u0022bedrocku0022,n u0022ecsu0022, u0022eksu0022, u0022opensearchu0022, u0022elasticacheu0022,n u0022redshiftu0022, u0022emru0022, u0022aurorau0022, u0022dynamodbu0022,n u0022kinesisu0022, u0022s3 glacieru0022, u0022msku0022)):n return u0022VALUEu0022n if any(k in sl for k in (u0022cloudwatchu0022, u0022configu0022, u0022guarddutyu0022, u0022cloudtrailu0022,n u0022auditu0022, u0022security hubu0022, u0022inspectoru0022, u0022macieu0022,n u0022detectiveu0022, u0022trusted advisoru0022, u0022healthu0022)):n return u0022ADMINu0022n if any(k in sl for k in (u0022vpcu0022, u0022route 53u0022, u0022certificateu0022, u0022directoryu0022,n u0022secretsu0022, u0022kmsu0022, u0022iamu0022, u0022cognitou0022, u0022wafu0022,n u0022shieldu0022, u0022firewallu0022, u0022backupu0022, u0022load balancu0022,n u0022transit gatewayu0022, u0022cloudfrontu0022, u0022direct connectu0022)):n return u0022OVERHEADu0022n return u0022UNKNOWNu0022nnndef get_date_range(months_back: int) -u003e tuple:n today = date.today()n end = today.replace(day=1)n start = end - relativedelta(months=months_back)n return start.strftime(u0022%Y-%m-%du0022), end.strftime(u0022%Y-%m-%du0022)nnndef fetch_costs(ce_client, start: str, end: str) -u003e dict:n costs = {}n paginator_token = Nonen while True:n kwargs = dict(n TimePeriod={u0022Startu0022: start, u0022Endu0022: end},n Granularity=u0022MONTHLYu0022,n Metrics=[u0022BlendedCostu0022],n GroupBy=[{u0022Typeu0022: u0022DIMENSIONu0022, u0022Keyu0022: u0022SERVICEu0022}],n )n if paginator_token:n kwargs[u0022NextPageTokenu0022] = paginator_tokenn response = ce_client.get_cost_and_usage(**kwargs)n for result in response.get(u0022ResultsByTimeu0022, []):n for group in result.get(u0022Groupsu0022, []):n service = group[u0022Keysu0022][0]n amount = float(group[u0022Metricsu0022][u0022BlendedCostu0022][u0022Amountu0022])n costs[service] = costs.get(service, 0.0) + amountn paginator_token = response.get(u0022NextPageTokenu0022)n if not paginator_token:n breakn return costsnnndef bvr_colour(ratio: float) -u003e str:n if ratio u003e= 0.60:n return u0022greenu0022n if ratio u003e= 0.35:n return u0022yellowu0022n return u0022redu0022nnndef urgency(service: str, cost: float, category: str) -u003e str:n if category == u0022WASTEu0022 and cost u003e= 0.01:n return u0022URGENTu0022n threshold = WARNING_THRESHOLDS.get(service)n if threshold and cost u003e= threshold:n return u0022URGENTu0022n if category == u0022ADMINu0022 and cost u003e= 200:n return u0022REVIEWu0022n if category == u0022OVERHEADu0022 and cost u003e= 500:n return u0022REVIEWu0022n return u0022u0022nnndef is_new_account(costs: dict, months: int) -u003e bool:n total = sum(costs.values())n monthly_avg = total / max(months, 1)n value_spend = sum(v for s, v in costs.items() if classify(s) == u0022VALUEu0022)n return monthly_avg u003c NEW_ACCOUNT_SPEND_THRESHOLD and value_spend u003c 1.0nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Plain text outputn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef separator():n print(u0022-u0022 * 90)nnndef print_plain(costs: dict, months: int):n total = sum(costs.values())n breakdown = {n u0022VALUEu0022: sum(v for s, v in costs.items() if classify(s) == u0022VALUEu0022),n u0022OVERHEADu0022: sum(v for s, v in costs.items() if classify(s) == u0022OVERHEADu0022),n u0022ADMINu0022: sum(v for s, v in costs.items() if classify(s) == u0022ADMINu0022),n u0022WASTEu0022: sum(v for s, v in costs.items() if classify(s) == u0022WASTEu0022),n u0022UNKNOWNu0022: sum(v for s, v in costs.items() if classify(s) == u0022UNKNOWNu0022),n }n bvr = breakdown[u0022VALUEu0022] / total if total u003e 0 else 0.0nn print(u0022u005cnu0022 + u0022=u0022 * 90)n print(u0022 AWS BUSINESS VALUE COST AUDITu0022)n print(fu0022 Period: {months} months Total spend: ${total:,.2f}u0022)n print(u0022=u0022 * 90)n print(fu0022u005cn Business Value Ratio (BVR): {bvr:.1%}u0022)n print(fu0022 Value-generating: ${breakdown['VALUE']:u003e10,.2f} ({breakdown['VALUE']/total:.1%})u0022)n print(fu0022 Overhead: ${breakdown['OVERHEAD']:u003e10,.2f} ({breakdown['OVERHEAD']/total:.1%})u0022)n print(fu0022 Administrative: ${breakdown['ADMIN']:u003e10,.2f} ({breakdown['ADMIN']/total:.1%})u0022)n if breakdown[u0022WASTEu0022]:n print(fu0022 Waste: ${breakdown['WASTE']:u003e10,.2f} ({breakdown['WASTE']/total:.1%})u0022)n if breakdown[u0022UNKNOWNu0022]:n print(fu0022 Unclassified: ${breakdown['UNKNOWN']:u003e10,.2f} ({breakdown['UNKNOWN']/total:.1%})u0022)nn if bvr u003c 0.35:n print(u0022u005cn [CRITICAL] BVR below 35%. Account costs more to run than the business it serves.u0022)n elif bvr u003c 0.60:n print(u0022u005cn [WARNING] BVR below 60%. Overhead and administrative costs are elevated.u0022)n else:n print(u0022u005cn [OK] BVR is healthy.u0022)nn print(u0022u005cnu0022)n separator()n print(fu0022 {'SERVICE':u003c45} {'CATEGORY':u003c12} {'COST':u003e12} {'SHARE':u003e6} {'FLAG'}u0022)n separator()nn urgent_services = []n for service, cost in sorted(costs.items(), key=lambda x: x[1], reverse=True):n if cost u003c 0.01:n continuen cat = classify(service)n flag = urgency(service, cost, cat)n share = cost / total * 100n print(fu0022 {service:u003c45} {cat:u003c12} ${cost:u003e11,.2f} {share:u003e5.1f}% {flag}u0022)n if flag == u0022URGENTu0022:n urgent_services.append(service)nn separator()nn if urgent_services:n print(u0022u005cn URGENT REVIEW ITEMSu005cnu0022)n for svc in urgent_services:n advice = REVIEW_ADVICE.get(svc)n if advice:n print(fu0022 [{svc}]u005cn {advice}u005cnu0022)n print()nnndef print_new_account_plain(costs: dict, months: int):n total = sum(costs.values())n monthly_avg = total / max(months, 1)n print(u0022u005cnu0022 + u0022=u0022 * 70)n print(u0022 NEW ACCOUNT DETECTEDu0022)n print(u0022=u0022 * 70)n print(fu0022u005cn Total spend ({months}m): ${total:,.2f} Monthly average: ${monthly_avg:,.2f}u0022)n print(u0022 Value-generating workload: $0.00u005cnu0022)n print(u0022 All current spend is provisioning standards overhead.u0022)n print(u0022 Do not deploy production workloads until this baseline isu0022)n print(u0022 reviewed and rationalised.u005cnu0022)n print(u0022 Recommended action: review the account vending template againstu0022)n print(u0022 this service list before onboarding application teams.u005cnu0022)n print(u0022 Current services:u0022)n separator()n for service, cost in sorted(costs.items(), key=lambda x: x[1], reverse=True):n if cost u003c 0.01:n continuen cat = classify(service)n print(fu0022 {service:u003c45} {cat:u003c12} ${cost:u003e10,.2f}u0022)n separator()n print()nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Rich outputn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef print_rich(costs: dict, months: int, start: str, end: str):n console = Console()n total = sum(costs.values())n breakdown = {n u0022VALUEu0022: sum(v for s, v in costs.items() if classify(s) == u0022VALUEu0022),n u0022OVERHEADu0022: sum(v for s, v in costs.items() if classify(s) == u0022OVERHEADu0022),n u0022ADMINu0022: sum(v for s, v in costs.items() if classify(s) == u0022ADMINu0022),n u0022WASTEu0022: sum(v for s, v in costs.items() if classify(s) == u0022WASTEu0022),n u0022UNKNOWNu0022: sum(v for s, v in costs.items() if classify(s) == u0022UNKNOWNu0022),n }n bvr = breakdown[u0022VALUEu0022] / total if total u003e 0 else 0.0n colour = bvr_colour(bvr)nn summary_lines = [n fu0022[bold]Period:[/bold] {start} to {end} [bold]Months analysed:[/bold] {months}u0022,n fu0022[bold]Total AWS spend:[/bold] [yellow]${total:,.2f}[/yellow]u0022,n u0022u0022,n fu0022[bold]Business Value Ratio (BVR):[/bold] [{colour} bold]{bvr:.1%}[/{colour} bold]u0022,n u0022u0022,n fu0022 Value-generating: [green]${breakdown['VALUE']:u003e12,.2f}[/green] ({breakdown['VALUE']/total:.1%})u0022,n fu0022 Overhead: [yellow]${breakdown['OVERHEAD']:u003e12,.2f}[/yellow] ({breakdown['OVERHEAD']/total:.1%})u0022,n fu0022 Administrative: [cyan]${breakdown['ADMIN']:u003e12,.2f}[/cyan] ({breakdown['ADMIN']/total:.1%})u0022,n ]n if breakdown[u0022WASTEu0022]:n summary_lines.append(n fu0022 Waste: [red]${breakdown['WASTE']:u003e12,.2f}[/red] ({breakdown['WASTE']/total:.1%})u0022n )n if breakdown[u0022UNKNOWNu0022]:n summary_lines.append(n fu0022 Unclassified: [dim]${breakdown['UNKNOWN']:u003e12,.2f}[/dim] ({breakdown['UNKNOWN']/total:.1%})u0022n )n if bvr u003c 0.35:n summary_lines += [u0022u0022, u0022[red bold]CRITICAL: BVR below 35%. Account costs more to run than the business it serves.[/red bold]u0022]n elif bvr u003c 0.60:n summary_lines += [u0022u0022, u0022[yellow bold]WARNING: BVR below 60%. Overhead and administrative costs are elevated.[/yellow bold]u0022]n else:n summary_lines += [u0022u0022, u0022[green bold]HEALTHY: BVR is above 60%.[/green bold]u0022]nn console.print(Panel(u0022u005cnu0022.join(summary_lines), title=u0022[bold]AWS Business Value Cost Audit[/bold]u0022, expand=True))nn table = Table(box=box.SIMPLE_HEAVY, show_header=True, header_style=u0022bold whiteu0022)n table.add_column(u0022Serviceu0022, style=u0022whiteu0022, min_width=40)n table.add_column(u0022Categoryu0022, justify=u0022leftu0022, min_width=10)n table.add_column(u0022Cost (USD)u0022, justify=u0022rightu0022, min_width=12)n table.add_column(u0022Shareu0022, justify=u0022rightu0022, min_width=7)n table.add_column(u0022Flagu0022, justify=u0022centeru0022, min_width=8)nn cat_style = {u0022VALUEu0022: u0022greenu0022, u0022OVERHEADu0022: u0022yellowu0022, u0022ADMINu0022: u0022cyanu0022, u0022WASTEu0022: u0022redu0022, u0022UNKNOWNu0022: u0022dimu0022}n urgent_services = []nn for service, cost in sorted(costs.items(), key=lambda x: x[1], reverse=True):n if cost u003c 0.01:n continuen cat = classify(service)n flag = urgency(service, cost, cat)n share = cost / total * 100n style = cat_style.get(cat, u0022whiteu0022)n flag_display = u0022[red bold]URGENT[/red bold]u0022 if flag == u0022URGENTu0022 else (n u0022[yellow]REVIEW[/yellow]u0022 if flag == u0022REVIEWu0022 else u0022u0022)n table.add_row(service, fu0022[{style}]{cat}[/{style}]u0022, fu0022${cost:,.2f}u0022, fu0022{share:.1f}%u0022, flag_display)n if flag == u0022URGENTu0022:n urgent_services.append(service)nn console.print(table)nn if urgent_services:n console.print(u0022u005cn[bold red]URGENT REVIEW ITEMS[/bold red]u005cnu0022)n for svc in urgent_services:n advice = REVIEW_ADVICE.get(svc)n if advice:n console.print(Panel(advice, title=fu0022[red bold]{svc}[/red bold]u0022, expand=False))nnndef print_new_account_rich(costs: dict, months: int):n console = Console()n total = sum(costs.values())n monthly_avg = total / max(months, 1)nn summary = u0022u005cnu0022.join([n fu0022[bold]Total spend ({months}m):[/bold] [yellow]${total:,.2f}[/yellow] u0022n fu0022[bold]Monthly average:[/bold] [yellow]${monthly_avg:,.2f}[/yellow]u0022,n u0022[bold]Value-generating workload:[/bold] [red]$0.00[/red]u0022,n u0022u0022,n u0022[yellow]All current spend is provisioning standards overhead.[/yellow]u0022,n u0022Do not deploy production workloads until this baseline is reviewed and rationalised.u0022,n u0022u0022,n u0022[bold]Recommended action:[/bold] review the account vending template againstu0022,n u0022this service list before onboarding application teams.u0022,n ])n console.print(Panel(summary, title=u0022[bold red]NEW ACCOUNT DETECTED[/bold red]u0022, expand=True))nn table = Table(box=box.SIMPLE_HEAVY, show_header=True, header_style=u0022bold whiteu0022)n table.add_column(u0022Serviceu0022, min_width=40)n table.add_column(u0022Categoryu0022, min_width=10)n table.add_column(u0022Cost (USD)u0022, justify=u0022rightu0022, min_width=12)nn cat_style = {u0022VALUEu0022: u0022greenu0022, u0022OVERHEADu0022: u0022yellowu0022, u0022ADMINu0022: u0022cyanu0022, u0022WASTEu0022: u0022redu0022, u0022UNKNOWNu0022: u0022dimu0022}n for service, cost in sorted(costs.items(), key=lambda x: x[1], reverse=True):n if cost u003c 0.01:n continuen cat = classify(service)n style = cat_style.get(cat, u0022whiteu0022)n table.add_row(service, fu0022[{style}]{cat}[/{style}]u0022, fu0022${cost:,.2f}u0022)nn console.print(table)nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# CSV exportn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef write_csv(costs: dict, path: str, total: float):n with open(path, u0022wu0022, newline=u0022u0022) as f:n writer = csv.writer(f)n writer.writerow([u0022Serviceu0022, u0022Categoryu0022, u0022Cost_USDu0022, u0022Share_Pctu0022, u0022Flagu0022])n for service, cost in sorted(costs.items(), key=lambda x: x[1], reverse=True):n if cost u003c 0.01:n continuen cat = classify(service)n flag = urgency(service, cost, cat)n share = cost / total * 100n writer.writerow([service, cat, fu0022{cost:.2f}u0022, fu0022{share:.2f}u0022, flag])n print(fu0022CSV written to {path}u0022)nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Entry pointn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef main():n parser = argparse.ArgumentParser(description=u0022AWS Business Value Cost Auditoru0022)n parser.add_argument(u0022u002du002dmonthsu0022, type=int, default=3, help=u0022Months to analyse (default: 3)u0022)n parser.add_argument(u0022u002du002dcsvu0022, type=str, default=None, help=u0022Optional CSV output pathu0022)n parser.add_argument(u0022u002du002dprofileu0022, type=str, default=None, help=u0022AWS profile nameu0022)n parser.add_argument(u0022u002du002dno-richu0022, action=u0022store_trueu0022, help=u0022Force plain text outputu0022)n parser.add_argument(u0022u002du002dnew-accountu0022, action=u0022store_trueu0022, help=u0022Force new account detection modeu0022)n args = parser.parse_args()nn session_kwargs = {}n if args.profile:n session_kwargs[u0022profile_nameu0022] = args.profilenn try:n session = boto3.Session(**session_kwargs)n ce = session.client(u0022ceu0022, region_name=u0022us-east-1u0022)n except Exception as e:n print(fu0022ERROR: Could not create AWS session: {e}u0022, file=sys.stderr)n sys.exit(1)nn start, end = get_date_range(args.months)n print(fu0022Fetching Cost Explorer data ({start} to {end}) ...u0022)nn try:n costs = fetch_costs(ce, start, end)n except NoCredentialsError:n print(u0022ERROR: No AWS credentials found. Configure via environment variables, u0022n u0022~/.aws/credentials, or an IAM role.u0022, file=sys.stderr)n sys.exit(1)n except ClientError as e:n code = e.response[u0022Erroru0022][u0022Codeu0022]n if code == u0022OptInRequiredu0022:n print(u0022ERROR: Cost Explorer is not enabled. Enable it at u0022n u0022https://console.aws.amazon.com/cost-management/homeu0022, file=sys.stderr)n else:n print(fu0022ERROR: AWS API error: {e}u0022, file=sys.stderr)n sys.exit(1)nn if not costs:n print(u0022No cost data returned for the requested period.u0022)n sys.exit(0)nn total = sum(costs.values())nn if args.csv:n write_csv(costs, args.csv, total)nn use_rich = RICH and not args.no_richnn if args.new_account or is_new_account(costs, args.months):n if use_rich:n print_new_account_rich(costs, args.months)n else:n print_new_account_plain(costs, args.months)n sys.exit(0)nn if use_rich:n print_rich(costs, args.months, start, end)n else:n print_plain(costs, args.months)nnnif __name__ == u0022__main__u0022:n main()nEOFnchmod +x aws_value_check.py Install dependencies and verify the script is ready to run:
pip install boto3 rich python-dateutilnpython aws_value_check.py u002du002dhelp One IAM permission is required: ce:GetCostAndUsage, available via the managed policy AWSBillingReadOnlyAccess. Each run makes two to five Cost Explorer API calls at $0.01 each, so the total audit cost is negligible.
A future version of the script will support tag based classification rules, allowing services to be reclassified dynamically based on resource tags rather than service name alone. An OpenSearch domain tagged purpose=customer-search would score as PRODUCT; the same service tagged purpose=logging would score as GOVERNANCE. That makes the tool a governance engine rather than a static report and eliminates most of the classification debates that arise from shared services with multiple use cases.
5.1 Usage
# Last 3 months against the default profilenpython aws_value_check.pynn# Last 6 months with CSV export for finance reportingnpython aws_value_check.py u002du002dmonths 6 u002du002dcsv results.csvnn# Named AWS profile for cross-account accessnpython aws_value_check.py u002du002dprofile prod-readonlynn# Force new account detection modenpython aws_value_check.py u002du002dnew-account 5.2 Sample output
A product account with a broken cost structure produces output similar to the following. OpenSearch is correctly classified as ENABLEMENT, which means its cost compounds against the product spend rather than inflating the BVR, and the overall picture is correspondingly worse than a naive reading of the bill would suggest:
┌───────────────────────────────────────────────────────────────────────┐n│ AWS Business Value Cost Audit │n│ Period: 2026-02-01 to 2026-05-01 Months analysed: 3 │n│ Total AWS spend: $17,325.87 │n│ │n│ Business Value Ratio (BVR): 1.0% CRITICAL │n│ Governance Load Ratio (GLR): 43.7 CRITICAL │n│ │n│ Product: $ 171.95 ( 1.0%) │n│ Enablement: $ 17,532.14 (61.7% — OpenSearch + ACM + Route 53) │n│ Governance: $ 4,924.35 (28.4%) │n│ Tax / Other: $ 1,793.78 (10.4%) │n│ │n│ CRITICAL: BVR below 35%. Account costs more to run than the │n│ business it exists to serve. │n│ CRITICAL: GLR above 0.75. Infrastructure weight is extreme. │n└───────────────────────────────────────────────────────────────────────┘nn Service Category Cost (USD) Share Flagn ──────────────────────────────────────────────────────────────────────n Amazon OpenSearch Service ENABLEMENT $7,315.93 42.2% URGENTn AWS Certificate Manager ENABLEMENT $3,873.26 22.4% URGENTn Amazon Route 53 ENABLEMENT $1,746.67 10.1% REVIEWn AWS Directory Service ENABLEMENT $1,035.91 6.0% REVIEWn Amazon VPC ENABLEMENT $694.35 4.0%n Amazon EC2 - Other PRODUCT $573.25 3.3%n Amazon EC2 Instances PRODUCT $171.95 1.0%nn URGENT: Amazon OpenSearch Servicen OpenSearch is classified as enablement rather than product spend.n Confirm whether this cluster is serving a customer-facing featuren or running as a log sink. Indices named cwl-2026.05.14 confirmn the log sink pattern. If so, migrate to S3 plus Athena orn OpenSearch Serverless and move the cluster to a shared loggingn account with appropriate cost allocation.nn URGENT: AWS Certificate Managern Public ACM certificates are free. A single active private CAn costs USD 400 per month before any certificates are issued.n Audit active private CAs, consolidate under a single CA hierarchy,n and confirm all consumers genuinely require private PKI.n Estimated monthly recovery: $800 to $1,200. A new account with no product workload produces a different finding:
┌───────────────────────────────────────────────────────────────────────┐n│ NEW ACCOUNT DETECTED │n│ Total spend: $127.43 Monthly average: $127.43 │n│ Product workload spend: $0.00 │n│ │n│ All current spend is provisioning standards overhead. │n│ Do not deploy production workloads until this baseline │n│ is reviewed and rationalised. │n│ │n│ Recommended action: review the account vending template against │n│ this service list before onboarding application teams. │n└───────────────────────────────────────────────────────────────────────┘ 6. Running the audit across an entire OU
The single-account script is the right starting point for understanding one account in depth. At organisational scale the more useful question is which accounts across an entire OU are the worst offenders, ranked by BVR, GLR, and total spend, so that remediation effort can be directed at the accounts with the highest recovery potential rather than spread evenly across hundreds of accounts that may not need attention.
aws_ou_bvr_scan.py does this. It enumerates every account in a target OU using the AWS Organizations API, assumes a read-only role in each account, runs the cost classification logic, computes BVR and GLR for each account, writes a per-account detail report, and produces a summary HTML report with all accounts ranked by cost composition health. Accounts flagged as new or as shared services accounts are listed separately so they do not distort the product account rankings.
The summary report links to the per-account detail files so a reviewer can move from the organisational view to a specific account’s full breakdown in a single click.
Install with:
cat u003e aws_ou_bvr_scan.py u003cu003c 'EOF'n#!/usr/bin/env python3nu0022u0022u0022naws_ou_bvr_scan.py u002du002d OU-level AWS Business Value Cost Scannern=================================================================nEnumerates all accounts in an AWS Organizations OU, assumes anread-only role in each, computes BVR and GLR, writes per-accountnCSV detail files, and produces a ranked HTML summary report.nnRequirements:n pip install boto3 rich python-dateutil jinja2nnIAM permissions required (management account or delegated admin):n organizations:ListAccountsForParentn organizations:ListChildrenn organizations:DescribeAccountn sts:AssumeRole (to assume the audit role in each member account)nnEach member account must have a role matching u002du002drole-name that trustsnthe scanning identity. The role requires only ce:GetCostAndUsage.nnUsage:n python aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxxn python aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxx u002du002dmonths 3n python aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxx u002du002drole-name BVRAuditRolen python aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxx u002du002dout-dir ./bvr-reportsnu0022u0022u0022nnimport argparsenimport csvnimport jsonnimport osnimport sysnfrom datetime import date, datetimenfrom dateutil.relativedelta import relativedeltanfrom pathlib import Pathnnimport boto3nfrom botocore.exceptions import ClientError, NoCredentialsErrornn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Import classification and fetch logic from aws_value_check.py if present,n# otherwise embed the minimum required subset inline.n# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nntry:n sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))n from aws_value_check import (n classify, fetch_costs, get_date_range,n VALUE_GENERATING, OVERHEAD, ADMINISTRATIVE,n NEW_ACCOUNT_SPEND_THRESHOLD, REVIEW_ADVICE, WARNING_THRESHOLDS,n urgencyn )n IMPORTED = Truenexcept ImportError:n IMPORTED = Falsenn# If aws_value_check.py is not present, embed the minimum classification subset.nif not IMPORTED:n NEW_ACCOUNT_SPEND_THRESHOLD = 150.0nn VALUE_GENERATING = {n u0022Amazon EC2u0022, u0022Amazon EC2 - Otheru0022, u0022EC2 - Otheru0022, u0022EC2-Instancesu0022, u0022EC2-Otheru0022,n u0022Amazon Elastic Container Serviceu0022, u0022Amazon Elastic Kubernetes Serviceu0022,n u0022AWS Lambdau0022, u0022Amazon RDSu0022, u0022Amazon Aurorau0022, u0022Amazon DynamoDBu0022,n u0022Amazon ElastiCacheu0022, u0022Amazon Redshiftu0022, u0022Amazon EMRu0022, u0022Amazon SageMakeru0022,n u0022Amazon Bedrocku0022, u0022Amazon Rekognitionu0022, u0022Amazon Comprehendu0022, u0022Amazon Translateu0022,n u0022Amazon Transcribeu0022, u0022Amazon Pollyu0022, u0022Amazon Lexu0022, u0022Amazon Personalizeu0022,n u0022Amazon Forecastu0022, u0022Amazon Kendrau0022, u0022Amazon S3u0022, u0022Amazon S3 Glacieru0022,n u0022Amazon OpenSearch Serviceu0022, u0022Amazon API Gatewayu0022, u0022Amazon AppSyncu0022,n u0022Amazon SQSu0022, u0022Amazon SNSu0022, u0022Amazon Kinesisu0022, u0022Amazon MSKu0022,n u0022Amazon EventBridgeu0022, u0022Amazon Connectu0022, u0022Amazon Pinpointu0022, u0022Amazon WorkSpacesu0022,n u0022AWS Elastic Beanstalku0022, u0022Amazon Lightsailu0022, u0022AWS Amplifyu0022, u0022AWS App Runneru0022,n u0022Amazon ECSu0022, u0022Amazon EKSu0022, u0022Amazon ECRu0022, u0022AWS Batchu0022, u0022Amazon MQu0022,n u0022Amazon DocumentDBu0022, u0022Amazon Neptuneu0022, u0022Amazon Timestreamu0022, u0022Amazon QLDBu0022,n u0022Amazon Managed Blockchainu0022, u0022AWS Glueu0022, u0022Amazon Athenau0022, u0022Amazon QuickSightu0022,n u0022AWS Lake Formationu0022, u0022Amazon DataZoneu0022,n }nn OVERHEAD = {n u0022Amazon VPCu0022, u0022VPCu0022, u0022AWS Transit Gatewayu0022, u0022Amazon CloudFrontu0022,n u0022Amazon Route 53u0022, u0022Route 53u0022, u0022AWS Direct Connectu0022,n u0022Amazon Elastic Load Balancingu0022, u0022Elastic Load Balancingu0022,n u0022AWS Global Acceleratoru0022, u0022AWS PrivateLinku0022,n u0022AWS Certificate Manageru0022, u0022Certificate Manageru0022,n u0022AWS Secrets Manageru0022, u0022Secrets Manageru0022,n u0022AWS Key Management Serviceu0022, u0022Key Management Serviceu0022,n u0022AWS Identity and Access Managementu0022, u0022AWS IAM Identity Centeru0022,n u0022Amazon Cognitou0022, u0022AWS Directory Serviceu0022, u0022Directory Serviceu0022,n u0022AWS WAFu0022, u0022AWS Shieldu0022, u0022Amazon Inspectoru0022, u0022AWS Firewall Manageru0022,n u0022AWS Network Firewallu0022, u0022Amazon Macieu0022, u0022AWS Security Hubu0022,n u0022Amazon Backupu0022, u0022AWS Elastic Disaster Recoveryu0022, u0022AWS DataSyncu0022,n u0022AWS Transfer Familyu0022, u0022AWS Storage Gatewayu0022, u0022AWS Systems Manageru0022,n u0022Systems Manageru0022, u0022AWS CodeDeployu0022, u0022AWS CodePipelineu0022, u0022AWS CodeBuildu0022,n u0022AWS CodeCommitu0022, u0022AWS CodeArtifactu0022, u0022Amazon EFSu0022, u0022AWS Fargateu0022,n }nn ADMINISTRATIVE = {n u0022Amazon CloudWatchu0022, u0022CloudWatchu0022, u0022AWS CloudTrailu0022, u0022AWS Configu0022, u0022Configu0022,n u0022Amazon GuardDutyu0022, u0022GuardDutyu0022, u0022AWS Security Hubu0022, u0022Amazon Detectiveu0022,n u0022Amazon Inspectoru0022, u0022Amazon Inspector2u0022, u0022AWS Trusted Advisoru0022,n u0022AWS Cost Exploreru0022, u0022AWS Budgetsu0022, u0022AWS Control Toweru0022, u0022AWS Organizationsu0022,n u0022AWS Service Catalogu0022, u0022AWS Audit Manageru0022, u0022AWS License Manageru0022,n u0022AWS Resource Access Manageru0022, u0022AWS Compute Optimizeru0022,n u0022AWS Health Dashboardu0022, u0022AWS Personal Health Dashboardu0022, u0022AWS Supportu0022,n u0022AWS Artifactu0022, u0022Amazon Managed Grafanau0022,n u0022Amazon Managed Service for Prometheusu0022, u0022AWS X-Rayu0022, u0022AWS Chatbotu0022,n u0022Amazon DevOps Guruu0022, u0022AWS Fault Injection Simulatoru0022, u0022AWS Resilience Hubu0022,n u0022AWS Migration Hubu0022, u0022AWS Application Migration Serviceu0022,n u0022AWS Database Migration Serviceu0022, u0022AWS Well-Architected Toolu0022,n u0022AWS Service Quotasu0022, u0022Taxu0022, u0022AWS Taxu0022,n u0022Savings Plans for AWS Compute usageu0022, u0022Savings Plans Upfront Feeu0022,n }nn EXCLUDE_FROM_DENOMINATOR = {u0022Taxu0022, u0022AWS Taxu0022, u0022AWS Supportu0022}nn WARNING_THRESHOLDS = {n u0022Amazon CloudWatchu0022: 50, u0022CloudWatchu0022: 50,n u0022AWS Configu0022: 30, u0022Configu0022: 30,n u0022Amazon GuardDutyu0022: 100, u0022GuardDutyu0022: 100,n u0022Amazon OpenSearch Serviceu0022: 500,n u0022AWS Directory Serviceu0022: 200, u0022Directory Serviceu0022: 200,n u0022Amazon Route 53u0022: 200, u0022Route 53u0022: 200,n u0022AWS Certificate Manageru0022: 100, u0022Certificate Manageru0022: 100,n u0022AWS Secrets Manageru0022: 50, u0022Secrets Manageru0022: 50,n }nn def classify(service_name):n if service_name in VALUE_GENERATING:n return u0022PRODUCTu0022n if service_name in OVERHEAD:n return u0022ENABLEMENTu0022n if service_name in ADMINISTRATIVE:n return u0022GOVERNANCEu0022n sl = service_name.lower()n if any(k in sl for k in (u0022ec2u0022, u0022rdsu0022, u0022lambdau0022, u0022sagemakeru0022, u0022bedrocku0022,n u0022ecsu0022, u0022eksu0022, u0022opensearchu0022, u0022elasticacheu0022,n u0022redshiftu0022, u0022emru0022, u0022aurorau0022, u0022dynamodbu0022,n u0022kinesisu0022, u0022msku0022)):n return u0022PRODUCTu0022n if any(k in sl for k in (u0022cloudwatchu0022, u0022configu0022, u0022guarddutyu0022, u0022cloudtrailu0022,n u0022auditu0022, u0022inspectoru0022, u0022macieu0022, u0022detectiveu0022)):n return u0022GOVERNANCEu0022n if any(k in sl for k in (u0022vpcu0022, u0022route 53u0022, u0022certificateu0022, u0022directoryu0022,n u0022secretsu0022, u0022kmsu0022, u0022iamu0022, u0022cognitou0022, u0022wafu0022,n u0022shieldu0022, u0022firewallu0022, u0022backupu0022, u0022load balancu0022,n u0022transit gatewayu0022, u0022cloudfrontu0022)):n return u0022ENABLEMENTu0022n return u0022UNKNOWNu0022nn def get_date_range(months_back):n today = date.today()n end = today.replace(day=1)n start = end - relativedelta(months=months_back)n return start.strftime(u0022%Y-%m-%du0022), end.strftime(u0022%Y-%m-%du0022)nn def fetch_costs(ce_client, start, end):n costs = {}n paginator_token = Nonen while True:n kwargs = dict(n TimePeriod={u0022Startu0022: start, u0022Endu0022: end},n Granularity=u0022MONTHLYu0022,n Metrics=[u0022BlendedCostu0022],n GroupBy=[{u0022Typeu0022: u0022DIMENSIONu0022, u0022Keyu0022: u0022SERVICEu0022}],n )n if paginator_token:n kwargs[u0022NextPageTokenu0022] = paginator_tokenn response = ce_client.get_cost_and_usage(**kwargs)n for result in response.get(u0022ResultsByTimeu0022, []):n for group in result.get(u0022Groupsu0022, []):n service = group[u0022Keysu0022][0]n amount = float(group[u0022Metricsu0022][u0022BlendedCostu0022][u0022Amountu0022])n costs[service] = costs.get(service, 0.0) + amountn paginator_token = response.get(u0022NextPageTokenu0022)n if not paginator_token:n breakn return costsnn def urgency(service, cost, category):n threshold = WARNING_THRESHOLDS.get(service)n if threshold and cost u003e= threshold:n return u0022URGENTu0022n if category == u0022GOVERNANCEu0022 and cost u003e= 200:n return u0022REVIEWu0022n if category == u0022ENABLEMENTu0022 and cost u003e= 500:n return u0022REVIEWu0022n return u0022u0022nnEXCLUDE_FROM_DENOMINATOR = {u0022Taxu0022, u0022AWS Taxu0022, u0022AWS Supportu0022, u0022Savings Plans for AWS Compute usageu0022}nn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Metrics computationn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef compute_metrics(costs, months):n total_raw = sum(costs.values())n excluded = sum(v for s, v in costs.items() if s in EXCLUDE_FROM_DENOMINATOR)n total_adj = total_raw - excludednn breakdown = {n u0022PRODUCTu0022: sum(v for s, v in costs.items() if classify(s) == u0022PRODUCTu0022),n u0022ENABLEMENTu0022: sum(v for s, v in costs.items() if classify(s) == u0022ENABLEMENTu0022),n u0022GOVERNANCEu0022: sum(v for s, v in costs.items() if classify(s) == u0022GOVERNANCEu0022),n u0022UNKNOWNu0022: sum(v for s, v in costs.items() if classify(s) == u0022UNKNOWNu0022),n }nn bvr = breakdown[u0022PRODUCTu0022] / total_adj if total_adj u003e 0 else 0.0n glr_num = breakdown[u0022GOVERNANCEu0022] + breakdown[u0022ENABLEMENTu0022]n glr = glr_num / breakdown[u0022PRODUCTu0022] if breakdown[u0022PRODUCTu0022] u003e 0 else float(u0022infu0022)n monthly_avg = total_raw / max(months, 1)n is_new = monthly_avg u003c NEW_ACCOUNT_SPEND_THRESHOLD and breakdown[u0022PRODUCTu0022] u003c 1.0nn urgent_services = [n s for s, v in costs.items()n if urgency(s, v, classify(s)) == u0022URGENTu0022n ]nn return {n u0022total_rawu0022: total_raw,n u0022total_adju0022: total_adj,n u0022monthly_avgu0022: monthly_avg,n u0022breakdownu0022: breakdown,n u0022bvru0022: bvr,n u0022glru0022: glr,n u0022is_newu0022: is_new,n u0022urgentu0022: urgent_services,n u0022costsu0022: costs,n }nnndef bvr_status(bvr, is_new):n if is_new:n return u0022NEWu0022n if bvr u003e= 0.60:n return u0022HEALTHYu0022n if bvr u003e= 0.35:n return u0022WARNINGu0022n return u0022CRITICALu0022nnndef glr_status(glr):n if glr == float(u0022infu0022):n return u0022NO_PRODUCTu0022n if glr u003c 0.25:n return u0022HEALTHYu0022n if glr u003c 0.75:n return u0022REVIEWu0022n return u0022HEAVYu0022nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Organizations enumerationn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef list_accounts_in_ou(org_client, ou_id, recursive=True):n accounts = []nn paginator = org_client.get_paginator(u0022list_accounts_for_parentu0022)n for page in paginator.paginate(ParentId=ou_id):n for acct in page.get(u0022Accountsu0022, []):n if acct[u0022Statusu0022] == u0022ACTIVEu0022:n accounts.append({n u0022idu0022: acct[u0022Idu0022],n u0022nameu0022: acct[u0022Nameu0022],n u0022emailu0022: acct.get(u0022Emailu0022, u0022u0022),n })nn if recursive:n child_paginator = org_client.get_paginator(u0022list_childrenu0022)n for page in child_paginator.paginate(ParentId=ou_id, ChildType=u0022ORGANIZATIONAL_UNITu0022):n for child_ou in page.get(u0022Childrenu0022, []):n accounts.extend(list_accounts_in_ou(org_client, child_ou[u0022Idu0022], recursive=True))nn return accountsnnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Per-account scanningn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef assume_role(account_id, role_name, session_name=u0022BVRScanu0022):n sts = boto3.client(u0022stsu0022)n role_arn = fu0022arn:aws:iam::{account_id}:role/{role_name}u0022n try:n resp = sts.assume_role(RoleArn=role_arn, RoleSessionName=session_name)n creds = resp[u0022Credentialsu0022]n return boto3.Session(n aws_access_key_id=creds[u0022AccessKeyIdu0022],n aws_secret_access_key=creds[u0022SecretAccessKeyu0022],n aws_session_token=creds[u0022SessionTokenu0022],n )n except ClientError:n return Nonennndef scan_account(account, role_name, start, end, months, out_dir):n account_id = account[u0022idu0022]n account_name = account[u0022nameu0022]n safe_name = account_name.replace(u0022 u0022, u0022_u0022).replace(u0022/u0022, u0022_u0022)n detail_file = out_dir / fu0022{account_id}_{safe_name}.csvu0022nn session = assume_role(account_id, role_name)n if session is None:n return {n u0022idu0022: account_id,n u0022nameu0022: account_name,n u0022erroru0022: u0022role_assumption_failedu0022,n u0022detailu0022: str(detail_file),n }nn ce = session.client(u0022ceu0022, region_name=u0022us-east-1u0022)n try:n costs = fetch_costs(ce, start, end)n except ClientError as e:n return {n u0022idu0022: account_id,n u0022nameu0022: account_name,n u0022erroru0022: str(e.response[u0022Erroru0022][u0022Codeu0022]),n u0022detailu0022: str(detail_file),n }nn metrics = compute_metrics(costs, months)nn with open(detail_file, u0022wu0022, newline=u0022u0022) as f:n writer = csv.writer(f)n writer.writerow([u0022Account_IDu0022, u0022Account_Nameu0022, u0022Serviceu0022, u0022Categoryu0022,n u0022Cost_USDu0022, u0022Share_Pctu0022, u0022Flagu0022])n total = metrics[u0022total_rawu0022]n for svc, cost in sorted(costs.items(), key=lambda x: x[1], reverse=True):n if cost u003c 0.01:n continuen cat = classify(svc)n flag = urgency(svc, cost, cat)n share = cost / total * 100 if total u003e 0 else 0n writer.writerow([account_id, account_name, svc, cat,n fu0022{cost:.2f}u0022, fu0022{share:.2f}u0022, flag])nn return {n u0022idu0022: account_id,n u0022nameu0022: account_name,n u0022erroru0022: None,n u0022detailu0022: str(detail_file),n u0022detail_fileu0022: detail_file.name,n **metrics,n }nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# HTML summary reportn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nnSTATUS_COLOUR = {n u0022HEALTHYu0022: u0022#2d7a2du0022,n u0022WARNINGu0022: u0022#b8860bu0022,n u0022CRITICALu0022: u0022#cc2200u0022,n u0022NEWu0022: u0022#5566aau0022,n u0022NO_PRODUCTu0022: u0022#888888u0022,n u0022REVIEWu0022: u0022#b8860bu0022,n u0022HEAVYu0022: u0022#cc2200u0022,n}nndef render_html(results, failed, ou_id, months, start, end, out_path):n generated = datetime.utcnow().strftime(u0022%Y-%m-%d %H:%M UTCu0022)nn product_rows = [r for r in results if not r.get(u0022is_newu0022) and r.get(u0022erroru0022) is None]n new_rows = [r for r in results if r.get(u0022is_newu0022) and r.get(u0022erroru0022) is None]n sorted_product = sorted(product_rows, key=lambda r: r[u0022bvru0022])nn def pct(v):n return fu0022{v:.1%}u0022nn def usd(v):n return fu0022${v:,.0f}u0022nn def glr_fmt(v):n return u0022N/Au0022 if v == float(u0022infu0022) else fu0022{v:.2f}u0022nn def status_badge(label, colour):n return (f'u003cspan style=u0022background:{colour};color:#fff;'n f'padding:2px 8px;border-radius:3px;font-size:0.85em;'n f'font-weight:boldu0022u003e{label}u003c/spanu003e')nn rows_html = u0022u0022n for r in sorted_product:n bs = bvr_status(r[u0022bvru0022], r[u0022is_newu0022])n gs = glr_status(r[u0022glru0022])n bc = STATUS_COLOUR.get(bs, u0022#888u0022)n gc = STATUS_COLOUR.get(gs, u0022#888u0022)n urg = u0022, u0022.join(r[u0022urgentu0022]) if r[u0022urgentu0022] else u0022u0022n rows_html += fu0022u0022u0022n u003ctru003en u003ctdu003eu003ca href=u0022{r['detail_file']}u0022u003e{r['name']}u003c/au003eu003c/tdu003en u003ctd style=u0022font-family:monospaceu0022u003e{r['id']}u003c/tdu003en u003ctd style=u0022text-align:rightu0022u003e{usd(r['monthly_avg'])}u003c/tdu003en u003ctd style=u0022text-align:rightu0022u003e{usd(r['breakdown']['PRODUCT'])}u003c/tdu003en u003ctd style=u0022text-align:rightu0022u003e{usd(r['breakdown']['ENABLEMENT'])}u003c/tdu003en u003ctd style=u0022text-align:rightu0022u003e{usd(r['breakdown']['GOVERNANCE'])}u003c/tdu003en u003ctd style=u0022text-align:centeru0022u003e{status_badge(pct(r['bvr']), bc)}u003c/tdu003en u003ctd style=u0022text-align:centeru0022u003e{status_badge(glr_fmt(r['glr']), gc)}u003c/tdu003en u003ctd style=u0022font-size:0.8em;color:#c00u0022u003e{urg}u003c/tdu003en u003c/tru003eu0022u0022u0022nn new_rows_html = u0022u0022n for r in new_rows:n new_rows_html += fu0022u0022u0022n u003ctr style=u0022color:#5566aau0022u003en u003ctdu003eu003ca href=u0022{r['detail_file']}u0022u003e{r['name']}u003c/au003eu003c/tdu003en u003ctd style=u0022font-family:monospaceu0022u003e{r['id']}u003c/tdu003en u003ctd style=u0022text-align:rightu0022u003e{usd(r['monthly_avg'])}u003c/tdu003en u003ctd colspan=u00226u0022 style=u0022color:#5566aau0022u003eNew account: no product workload detectedu003c/tdu003en u003c/tru003eu0022u0022u0022nn failed_rows_html = u0022u0022n for f in failed:n failed_rows_html += fu0022u0022u0022n u003ctr style=u0022color:#888u0022u003en u003ctdu003e{f['name']}u003c/tdu003en u003ctd style=u0022font-family:monospaceu0022u003e{f['id']}u003c/tdu003en u003ctd colspan=u00227u0022u003e{f['error']}u003c/tdu003en u003c/tru003eu0022u0022u0022nn total_spend = sum(r[u0022total_rawu0022] for r in product_rows)n total_product = sum(r[u0022breakdownu0022][u0022PRODUCTu0022] for r in product_rows)n org_bvr = total_product / total_spend if total_spend u003e 0 else 0n critical_count = sum(1 for r in product_rows if bvr_status(r[u0022bvru0022], False) == u0022CRITICALu0022)n warning_count = sum(1 for r in product_rows if bvr_status(r[u0022bvru0022], False) == u0022WARNINGu0022)nn html = fu0022u0022u0022u003c!DOCTYPE htmlu003enu003chtml lang=u0022enu0022u003enu003cheadu003enu003cmeta charset=u0022UTF-8u0022u003enu003ctitleu003eAWS BVR OU Summary: {ou_id}u003c/titleu003enu003cstyleu003en body {{ font-family: -apple-system, BlinkMacSystemFont, u0022Segoe UIu0022, sans-serif;n margin: 40px; color: #222; background: #fafafa; }}n h1 {{ font-size: 1.4em; margin-bottom: 4px; }}n h2 {{ font-size: 1.1em; margin-top: 32px; border-bottom: 1px solid #ddd;n padding-bottom: 6px; }}n table {{ border-collapse: collapse; width: 100%; font-size: 0.9em; margin-top: 12px; }}n th {{ background: #222; color: #fff; padding: 8px 10px; text-align: left; }}n td {{ padding: 7px 10px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }}n tr:hover td {{ background: #f0f4ff; }}n .summary-box {{ display: inline-block; background: #fff; border: 1px solid #ddd;n border-radius: 6px; padding: 16px 24px; margin: 8px 8px 8px 0;n min-width: 140px; }}n .summary-box .val {{ font-size: 1.6em; font-weight: bold; }}n .summary-box .lbl {{ font-size: 0.8em; color: #666; margin-top: 2px; }}n a {{ color: #1a5fb4; }}nu003c/styleu003enu003c/headu003enu003cbodyu003enu003ch1u003eAWS Business Value Ratio: OU Summary Reportu003c/h1u003enu003cp style=u0022color:#666;font-size:0.85emu0022u003en OU: u003cstrongu003e{ou_id}u003c/strongu003e u0026nbsp;|u0026nbsp;n Period: {start} to {end} ({months} months) u0026nbsp;|u0026nbsp;n Generated: {generated}nu003c/pu003ennu003cdivu003en u003cdiv class=u0022summary-boxu0022u003en u003cdiv class=u0022valu0022u003e{len(product_rows)}u003c/divu003en u003cdiv class=u0022lblu0022u003eProduct accounts scannedu003c/divu003en u003c/divu003en u003cdiv class=u0022summary-boxu0022u003en u003cdiv class=u0022valu0022 style=u0022color:{STATUS_COLOUR['CRITICAL']}u0022u003e{critical_count}u003c/divu003en u003cdiv class=u0022lblu0022u003eCritical (BVR u0026lt; 35%)u003c/divu003en u003c/divu003en u003cdiv class=u0022summary-boxu0022u003en u003cdiv class=u0022valu0022 style=u0022color:{STATUS_COLOUR['WARNING']}u0022u003e{warning_count}u003c/divu003en u003cdiv class=u0022lblu0022u003eWarning (BVR 35-60%)u003c/divu003en u003c/divu003en u003cdiv class=u0022summary-boxu0022u003en u003cdiv class=u0022valu0022u003e{pct(org_bvr)}u003c/divu003en u003cdiv class=u0022lblu0022u003eAggregate OU BVRu003c/divu003en u003c/divu003en u003cdiv class=u0022summary-boxu0022u003en u003cdiv class=u0022valu0022u003e{usd(total_spend)}u003c/divu003en u003cdiv class=u0022lblu0022u003eTotal OU spend ({months}m)u003c/divu003en u003c/divu003enu003c/divu003ennu003ch2u003eProduct accounts: ranked by BVR, worst firstu003c/h2u003enu003ctableu003en u003ctru003en u003cthu003eAccount nameu003c/thu003en u003cthu003eAccount IDu003c/thu003en u003cthu003eAvg monthly spendu003c/thu003en u003cthu003eProduct spendu003c/thu003en u003cthu003eEnablement spendu003c/thu003en u003cthu003eGovernance spendu003c/thu003en u003cthu003eBVRu003c/thu003en u003cthu003eGLRu003c/thu003en u003cthu003eUrgent servicesu003c/thu003en u003c/tru003en {rows_html}nu003c/tableu003ennu003ch2u003eNew accounts: no product workload detectedu003c/h2u003enu003ctableu003en u003ctru003en u003cthu003eAccount nameu003c/thu003eu003cthu003eAccount IDu003c/thu003eu003cthu003eAvg monthly spendu003c/thu003en u003cth colspan=u00226u0022u003eStatusu003c/thu003en u003c/tru003en {new_rows_html if new_rows_html else 'u003ctru003eu003ctd colspan=u00229u0022 style=u0022color:#888u0022u003eNoneu003c/tdu003eu003c/tru003e'}nu003c/tableu003ennu003ch2u003eFailed scansu003c/h2u003enu003ctableu003en u003ctru003en u003cthu003eAccount nameu003c/thu003eu003cthu003eAccount IDu003c/thu003eu003cth colspan=u00227u0022u003eErroru003c/thu003en u003c/tru003en {failed_rows_html if failed_rows_html else 'u003ctru003eu003ctd colspan=u00229u0022 style=u0022color:#888u0022u003eNoneu003c/tdu003eu003c/tru003e'}nu003c/tableu003ennu003cp style=u0022font-size:0.75em;color:#aaa;margin-top:40pxu0022u003en Generated by aws_ou_bvr_scan.py u0026mdash; BVR and GLR are heuristic indicators.n Account names link to per-account CSV detail files in the same directory.nu003c/pu003enu003c/bodyu003enu003c/htmlu003eu0022u0022u0022nn with open(out_path, u0022wu0022) as f:n f.write(html)nnn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-n# Entry pointn# u002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002du002d-nndef main():n parser = argparse.ArgumentParser(description=u0022AWS OU Business Value Cost Scanneru0022)n parser.add_argument(u0022u002du002dou-idu0022, required=True, help=u0022OU ID to scan (ou-xxxx-xxxxxxxx)u0022)n parser.add_argument(u0022u002du002dmonthsu0022, type=int, default=3, help=u0022Months to analyse (default: 3)u0022)n parser.add_argument(u0022u002du002drole-nameu0022, default=u0022BVRAuditRoleu0022,n help=u0022IAM role name to assume in each accountu0022)n parser.add_argument(u0022u002du002dout-diru0022, default=u0022./bvr-reportsu0022,n help=u0022Output directory for reportsu0022)n parser.add_argument(u0022u002du002dprofileu0022, default=None, help=u0022AWS profile for the management accountu0022)n parser.add_argument(u0022u002du002drecursiveu0022, action=u0022store_trueu0022, default=True,n help=u0022Recurse into child OUs (default: true)u0022)n parser.add_argument(u0022u002du002dno-recursiveu0022, dest=u0022recursiveu0022, action=u0022store_falseu0022)n args = parser.parse_args()nn out_dir = Path(args.out_dir)n out_dir.mkdir(parents=True, exist_ok=True)nn session_kwargs = {}n if args.profile:n session_kwargs[u0022profile_nameu0022] = args.profilenn session = boto3.Session(**session_kwargs)n org_client = session.client(u0022organizationsu0022)nn print(fu0022Enumerating accounts under {args.ou_id} ...u0022)n try:n accounts = list_accounts_in_ou(org_client, args.ou_id, args.recursive)n except ClientError as e:n print(fu0022ERROR: {e}u0022, file=sys.stderr)n sys.exit(1)nn print(fu0022Found {len(accounts)} active accounts. Scanning ...u0022)n start, end = get_date_range(args.months)nn results = []n failed = []nn for i, account in enumerate(accounts, 1):n print(fu0022 [{i:u003e3}/{len(accounts)}] {account['name']} ({account['id']}) ... u0022, end=u0022u0022, flush=True)n result = scan_account(account, args.role_name, start, end, args.months, out_dir)n if result.get(u0022erroru0022):n print(fu0022FAILED ({result['error']})u0022)n failed.append(result)n else:n bs = bvr_status(result[u0022bvru0022], result.get(u0022is_newu0022, False))n print(fu0022BVR={result['bvr']:.1%} GLR={result['glr']:.2f} [{bs}]u0022)n results.append(result)nn summary_path = out_dir / u0022bvr_summary.htmlu0022n render_html(results, failed, args.ou_id, args.months, start, end, summary_path)nn print(fu0022u005cnSummary report: {summary_path}u0022)n print(fu0022Per-account CSVs: {out_dir}/u0022)nn critical = [r for r in results if bvr_status(r[u0022bvru0022], r.get(u0022is_newu0022)) == u0022CRITICALu0022]n if critical:n print(fu0022u005cnCRITICAL accounts ({len(critical)}):u0022)n for r in sorted(critical, key=lambda x: x[u0022bvru0022]):n print(fu0022 {r['name']:u003c40} BVR={r['bvr']:.1%} GLR={r['glr']:.2f} u0022n fu0022spend={r['monthly_avg']:,.0f}/mou0022)nnnif __name__ == u0022__main__u0022:n main()nEOFnchmod +x aws_ou_bvr_scan.py Install dependencies:
pip install boto3 rich python-dateutil jinja2 6.1 IAM setup
The scanner runs from the management account or a delegated administrator account. It requires two sets of permissions. The scanning identity needs organizations:ListAccountsForParent, organizations:ListChildren, organizations:DescribeAccount, and sts:AssumeRole. Each member account needs a role named BVRAuditRole (or whatever is passed to --role-name) that trusts the scanning identity and grants ce:GetCostAndUsage.
Create the member account role using this trust policy, substituting the ARN of your scanning identity:
{n u0022Versionu0022: u00222012-10-17u0022,n u0022Statementu0022: [n {n u0022Effectu0022: u0022Allowu0022,n u0022Principalu0022: {n u0022AWSu0022: u0022arn:aws:iam::MANAGEMENT_ACCOUNT_ID:role/YOUR_SCANNING_ROLEu0022n },n u0022Actionu0022: u0022sts:AssumeRoleu0022n }n ]n} With a permission policy of:
{n u0022Versionu0022: u00222012-10-17u0022,n u0022Statementu0022: [n {n u0022Effectu0022: u0022Allowu0022,n u0022Actionu0022: u0022ce:GetCostAndUsageu0022,n u0022Resourceu0022: u0022*u0022n }n ]n} If the BVRAuditRole is already deployed by the landing zone template, the scanner can run immediately without any member account changes. Many organisations already have a read-only audit role; pass its name via --role-name if it differs from the default.
6.2 Usage
# Scan an OU for the last 3 months and write reports to ./bvr-reportsnpython aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxxnn# Scan for 6 months, write to a custom directorynpython aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxx u002du002dmonths 6 u002du002dout-dir ./q2-auditnn# Use a non-default role name and management account profilenpython aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxx u005cn u002du002drole-name ReadOnlyAuditRole u005cn u002du002dprofile mgmt-accountnn# Scan only the immediate children of the OU, not nested child OUsnpython aws_ou_bvr_scan.py u002du002dou-id ou-xxxx-xxxxxxxx u002du002dno-recursive 6.3 Output
The scanner produces two categories of output in the target directory. Each account gets a CSV detail file named ACCOUNT_ID_AccountName.csv containing the full service breakdown with category, cost, share, and urgency flag. These are the same columns as the single-account script and can be opened directly in a spreadsheet.
The summary report is bvr_summary.html, an HTML file that can be opened in any browser or dropped into an internal wiki. It contains five summary tiles at the top showing the number of product accounts scanned, the count of critical and warning accounts, the aggregate OU BVR computed across all product account spend, and total OU spend for the period. Below the tiles, product accounts are ranked from worst BVR to best in a table with columns for account name, account ID, average monthly spend, the three spend category totals, BVR badge, GLR badge, and a list of any urgent services flagged in that account. Account names in the table are links to the per-account CSV detail file. New accounts and failed scans each have their own section below the main table so they do not distort the product account rankings.
The aggregate OU BVR in the summary tiles is computed as total product spend divided by total adjusted spend across all product accounts in the OU, which gives a single number representing the cost composition health of the entire OU that can be tracked month over month as a BVR Velocity metric at the organisational level.
7. The conversation with finance and leadership
The BVR is deliberately expressed as a single ratio because it is legible to non technical stakeholders without requiring any knowledge of AWS service pricing mechanics. A CTO, a CFO, or a board level technology committee does not need to understand the difference between ACM PCA and public certificates to understand that an account with a one percent BVR is spending ninety nine cents of every dollar on infrastructure rather than on the product, and that comparison maps directly onto concepts finance already uses: a gross margin of one percent on your cloud infrastructure investment is not a healthy operating model.
The framing for that conversation is that cloud accounts should carry a target BVR of sixty percent or above for production product accounts, that accounts below that threshold are identified in the monthly audit with a remediation plan, an owner, and a timeline, and that BVR is reported alongside total cloud spend in the same way that gross margin is reported alongside revenue. Reporting cloud spend without BVR hides the most important signal in the number.
For organisations running account vending at scale, BVR at account creation is a separate metric worth tracking independently of operational BVR. An account that starts below twenty percent BVR before any application workload is deployed has a standards problem rather than a governance problem, and those are fixed in different places: standards problems are fixed in the vending template, while governance problems are fixed in tagging policies, budget controls, and periodic review cycles.
BVR also has a directional form worth tracking month over month:
BVR Velocity = (Current BVR - Previous BVR) / Months Velocity must be read alongside the absolute spend delta for each category, not in isolation. A BVR rising from 10% to 20% looks positive until you observe that total spend doubled in the same period, meaning product spend grew but overhead grew faster in absolute terms. The informative version of the trend presents three numbers together: the change in product spend, the change in enablement spend, and the change in governance spend. A falling BVR velocity with stable absolute spend means overhead is accumulating. A rising BVR velocity with growing absolute spend means product is scaling faster than fixed costs, which is the correct trajectory. A flat low BVR with near zero velocity across all three categories is the structural problem signature: the account was built wrong and has stayed that way. Tracking velocity turns BVR from a point in time audit into an operational indicator that finance and engineering can monitor together.
8. When a low BVR is correct
Not every product account with a low BVR has a problem. There are account configurations where governance and enablement spend legitimately dominates and a low BVR is the expected outcome of correct architecture.
A shared observability account that centralises CloudWatch, Prometheus, Grafana, and log aggregation across an organisation is entirely governance and enablement spend by design; its BVR will be near zero and that is correct. A central security account running GuardDuty, Security Hub, and SIEM ingestion for the organisation is in the same position. A disaster recovery environment running minimal compute with full infrastructure replication will show a low BVR because the product tier is intentionally underutilised. A pre launch product account provisioned for an upcoming workload will trigger the new account detection path in the script. A machine learning training environment with heavy S3 and SageMaker spend but no customer facing endpoint yet will score well on BVR because those services are classified as product, but the spend is not yet generating customer value and the metric does not capture that distinction. AI experimentation accounts running Bedrock or SageMaker at scale for model evaluation rather than inference have the same characteristic: high product classified spend with no corresponding customer outcome, producing a BVR that reads as healthy when the account is in a pre revenue state.
The framework applies to accounts where product workload delivery is the stated purpose. Shared services, security, DR, and platform accounts should be assessed against their own purpose appropriate benchmarks rather than the product account BVR thresholds above. If your organisation is unclear about which category a given account falls into, that ambiguity is itself a governance finding worth addressing before running the audit.
9. What to do this week
Run the script against your product accounts and work through any account where the BVR falls below thirty five percent in the following sequence.
Check Certificate Manager first. Any amount above the $20 detection floor in a product account almost certainly means one or more active private CAs, each billing at $400 per month before a single certificate is issued; confirm how many are active, whether any can be decommissioned, and whether they can be consolidated under a single CA hierarchy, since this is consistently the fastest large dollar recovery available.
Check OpenSearch next. Run the index name diagnostic described in section 4.2 and confirm whether the cluster is serving product functionality or acting as a log sink. If it is a log sink, raise a migration project with a concrete delivery date rather than a deferred intention; the economics of running a provisioned cluster indefinitely to support operational log queries are difficult to justify against the S3 plus Athena or OpenSearch Serverless alternatives.
Run the CloudWatch log group retention query and apply policies to every group that lacks one. This takes less time than the meeting required to decide to do it, and an EventBridge rule enforcing a default retention policy on new log groups means the work does not need to be repeated.
List active directories and flag any with no recent authentication activity for decommission review, particularly in accounts that have completed a Windows to Linux migration or moved away from SQL Server with Windows Authentication.
Review GuardDuty add ons against the team’s actual response capability and disable protection types that do not correspond to an existing playbook; Malware Protection on a serverless workload and EKS Runtime Monitoring without a runtime security tool are the most common cases where spend has outrun the team’s ability to act on findings.
If the account surfaces the new account finding rather than a BVR percentage, the action is upstream: take the account vending template to whoever owns cloud standards and present the pre workload cost baseline as evidence that the template is provisioning shared services infrastructure into product accounts. A product account costing $130 per month before a single service is deployed is not a product account configuration; it is a shared services deployment billed to the wrong owner, and the fix belongs in the template.
If your top five AWS services do not directly touch customers, you do not have a cloud cost problem. You have an account vending problem.
Andrew Baker is Group CIO at Capitec Bank and writes on cloud infrastructure, security architecture, and engineering leadership at andrewbaker.ninja.