<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rick Wise</title>
    <description>The latest articles on DEV Community by Rick Wise (@cloudwiseteam).</description>
    <link>https://dev.to/cloudwiseteam</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3582447%2Fe7a88946-c7a3-4aad-9242-6d52380c09f1.png</url>
      <title>DEV Community: Rick Wise</title>
      <link>https://dev.to/cloudwiseteam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cloudwiseteam"/>
    <language>en</language>
    <item>
      <title>A Client Cost Review With the Credentials You Already Have</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/a-client-cost-review-with-the-credentials-you-already-have-ia8</link>
      <guid>https://dev.to/cloudwiseteam/a-client-cost-review-with-the-credentials-you-already-have-ia8</guid>
      <description>&lt;p&gt;You already have a profile for the client's account. An SSO role, a read-only role you assume, a &lt;code&gt;ReadOnlyAccess&lt;/code&gt; user someone set up for the audit.&lt;/p&gt;

&lt;p&gt;A cost review should not need anything more than that.&lt;/p&gt;

&lt;p&gt;Most waste tools start by asking the client for something new: a cross-account role for the vendor, a stack to deploy, a data export. That is a security review and a week of email before you've looked at a single volume. The tool below skips it. It runs on your laptop, with the credentials you already hold, and sends nothing to us.&lt;/p&gt;

&lt;p&gt;This is a walk-through of that review, including the places where it can mislead you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it against the client profile
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;cloudcostwise
cloudcostwise scan &lt;span class="nt"&gt;--profile&lt;/span&gt; client-acme &lt;span class="nt"&gt;--regions&lt;/span&gt; all &lt;span class="nt"&gt;--format&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; waste.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--profile&lt;/code&gt; is the AWS profile name from your own config. Without it the tool uses the standard credential chain: environment variables, SSO, an instance role.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--regions all&lt;/code&gt; scans every enabled region, a few at a time (the default is four in parallel). The README puts a full multi-region run at roughly two to three minutes rather than ten. Leave &lt;code&gt;--regions&lt;/code&gt; off and you get &lt;code&gt;us-east-1&lt;/code&gt; plus the profile's own region.&lt;/p&gt;

&lt;p&gt;Pick the output for the audience. &lt;code&gt;table&lt;/code&gt; is for you, &lt;code&gt;markdown&lt;/code&gt; pastes into a ticket, &lt;code&gt;json&lt;/code&gt; is the one to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it needs
&lt;/h2&gt;

&lt;p&gt;AWS's managed &lt;code&gt;ReadOnlyAccess&lt;/code&gt; is enough. If you'd rather scope a role down, the minimal policy the checks use ships in the repository as &lt;code&gt;docs/iam-policy.json&lt;/code&gt;. It was derived from a scan of an account with most of the supported resource types, so if a check lacks a permission, the scan says so in its data warnings instead of failing.&lt;/p&gt;

&lt;p&gt;That matters when the client's role is narrower than you expected. You'll get a gap, labelled as a gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it will not do
&lt;/h2&gt;

&lt;p&gt;Every AWS call the scanner makes goes through a guard that refuses any operation not starting with &lt;code&gt;Describe&lt;/code&gt;, &lt;code&gt;List&lt;/code&gt;, &lt;code&gt;Get&lt;/code&gt;, &lt;code&gt;Search&lt;/code&gt; or &lt;code&gt;Lookup&lt;/code&gt;, whatever your credentials would allow. I wrote up the mechanism, with the code, last week in &lt;a href="https://dev.to/blog/read-only-by-construction-guard-in-front-of-every-aws-call"&gt;Read-Only by Construction&lt;/a&gt;, so I won't retell it.&lt;/p&gt;

&lt;p&gt;Two things worth saying to a client who asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The guard is a second layer. Your credentials should be read-only too. Two layers that fail differently beat one.&lt;/li&gt;
&lt;li&gt;It trusts AWS's naming. A few read-named operations have side effects, and the guard guards its own process, not a hostile one. The post above lists them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is source-available under FSL-1.1-ALv2, so the client's engineer can read the guard instead of taking your word, or mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one cost: Cost Explorer
&lt;/h2&gt;

&lt;p&gt;The Cost Explorer API is not free. AWS bills it at $0.01 per request, and a scan makes about 13 of them, so roughly $0.13. That lands on the account the calls run in.&lt;/p&gt;

&lt;p&gt;Those requests feed the Reserved Instance and Savings Plans checks. If the client has a cost-sensitive view of any charge appearing in their bill, skip them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudcostwise scan &lt;span class="nt"&gt;--profile&lt;/span&gt; client-acme &lt;span class="nt"&gt;--regions&lt;/span&gt; all &lt;span class="nt"&gt;--no-cost-explorer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You lose the commitment checks, and extended-support surcharges fall back to estimates. The rest of the scan is unchanged. The output says which one you got.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it checks
&lt;/h2&gt;

&lt;p&gt;The local CLI runs 20 AWS services: EC2, Lambda, SageMaker, WorkSpaces, Lightsail, EBS, S3, EFS, ECR, RDS, DynamoDB, ElastiCache, Elastic IPs, NAT gateways and load balancers, VPC endpoints, dangling DNS records, CloudWatch logs and dashboards, security posture, RI and Savings Plans opportunities, and Compute Optimizer.&lt;/p&gt;

&lt;p&gt;It is not a full audit of the account. It is a first pass at the things that bill quietly: unattached volumes, log groups that never expire, container images nobody cleaned up, idle load balancers. For idle EC2, RDS and NAT gateways, and for rightsizing, AWS's own tools cover the same ground, and you should use them too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the result honestly
&lt;/h2&gt;

&lt;p&gt;This is the part that decides whether the review is worth the client's time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The savings figure is an estimate.&lt;/strong&gt; Each finding carries an estimated monthly saving and a confidence. Treat the total as "worth a look", not "will appear on the next invoice". Some waste types are marked advisory: they are listed, but never added to the total, because they could not be validated against a real AWS resource.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed read is reported as missing, not zero.&lt;/strong&gt; If the credentials can't read a metric, the check says so in the data warnings (run with &lt;code&gt;--verbose&lt;/code&gt; to see them). A finding that is absent because the read failed is not a clean bill of health. Look at &lt;code&gt;warnings&lt;/code&gt;, &lt;code&gt;errors&lt;/code&gt; and &lt;code&gt;permission_errors&lt;/code&gt; in the JSON before you tell a client a service is clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A finding is a lead, not an instruction.&lt;/strong&gt; Whether that volume is really unattached, or that load balancer really idle, is a question for the people who own the workload. Check before you delete anything. The tool never deletes anything, which is the point, but you will.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you hand the client
&lt;/h2&gt;

&lt;p&gt;The JSON is the useful artifact, and not only for the findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;findings&lt;/code&gt;: waste type, region, resource, estimated monthly saving, confidence and the suggested action.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_api_calls&lt;/code&gt;: every AWS call the scan made, by service. This is the audit trail for the client's security person. They can check it against their own CloudTrail.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;warnings&lt;/code&gt;, &lt;code&gt;errors&lt;/code&gt;, &lt;code&gt;permission_errors&lt;/code&gt;, &lt;code&gt;coverage_notes&lt;/code&gt;: what the scan could not see.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;checks_run&lt;/code&gt;: which checks ran at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pair it with a short note of your own: which findings you'd act on first, which need the owner's input, and what the scan didn't cover. That note is the part a client is paying for. The tool just saves you the afternoon of clicking through consoles.&lt;/p&gt;

&lt;p&gt;Hand over the file, and the account's resource names stay between you and them. Nothing was sent to CloudWise.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you find a wrong answer
&lt;/h2&gt;

&lt;p&gt;A false positive, a miss, or a dollar figure that looks off: post it in &lt;a href="https://github.com/cloudwise-app/cloudcostwise/issues/1" rel="noopener noreferrer"&gt;issue #1 on the repository&lt;/a&gt;, or email &lt;a href="mailto:cloudwise@cloudcostwise.io"&gt;cloudwise@cloudcostwise.io&lt;/a&gt;. The waste type and counts are enough; leave out account IDs and resource names. A confirmed false positive gets fixed in the next release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Free, read-only, source-available. Nothing is sent to CloudWise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;cloudcostwise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details and the install line: &lt;a href="https://cloudcostwise.io" rel="noopener noreferrer"&gt;cloudcostwise.io&lt;/a&gt;. If you run reviews like this for several clients, there is a page for consultancies at &lt;a href="https://cloudcostwise.io/partners" rel="noopener noreferrer"&gt;cloudcostwise.io/partners&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>finops</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Read-Only by Construction: The Guard in Front of Every AWS Call</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/read-only-by-construction-the-guard-in-front-of-every-aws-call-3f7f</link>
      <guid>https://dev.to/cloudwiseteam/read-only-by-construction-the-guard-in-front-of-every-aws-call-3f7f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://cloudcostwise.io/blog/read-only-by-construction-guard-in-front-of-every-aws-call?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=read-only-by-construction-guard-in-front-of-every-aws-call" rel="noopener noreferrer"&gt;CloudWise blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://cloudcostwise.io/blog/zero-strangers-scanner-runs-locally?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=read-only-by-construction-guard-in-front-of-every-aws-call" rel="noopener noreferrer"&gt;launch post&lt;/a&gt; says the scanner "cannot change anything."&lt;/p&gt;

&lt;p&gt;That is a big claim from one person you've never met, about a tool you're about to point at your AWS account. The fair reaction is: how do you know?&lt;/p&gt;

&lt;p&gt;I'd rather you not take my word for it. So here is the mechanism: a small guard that sits in front of every AWS call the scanner makes. You can copy the pattern for anything you ship that touches someone else's cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Read-only" is usually a promise
&lt;/h2&gt;

&lt;p&gt;Look at how most tools earn the word:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The docs say it.&lt;/li&gt;
&lt;li&gt;The IAM role you create for them only allows reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is real protection. But it protects you from the vendor's &lt;em&gt;permissions&lt;/em&gt;, not from the vendor's &lt;em&gt;code&lt;/em&gt;. If the role is wrong, or a later release asks for more, the promise and the behavior drift apart and nobody notices until something is deleted.&lt;/p&gt;

&lt;p&gt;I wanted a second, independent layer that lives in the code path itself. If the tool is ever wrong about what it's about to do, the call should die on the way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  One chokepoint
&lt;/h2&gt;

&lt;p&gt;Every boto3 client call, for every AWS service, ends up in one method: &lt;code&gt;BaseClient._make_api_call&lt;/code&gt; in botocore. That is the chokepoint. Wrap it and you see every operation by name, before any request is signed or sent.&lt;/p&gt;

&lt;p&gt;The guard in the scanner is a context manager around that method. Trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;READ_ONLY_PREFIXES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Describe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;List&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lookup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@contextmanager&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_only_guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocked_services&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GuardLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;botocore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_make_api_call&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operation_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service_model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service_name&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;operation_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;READ_ONLY_PREFIXES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;operation_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ReadOnlyViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused non-read AWS call &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;operation_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;original&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operation_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;botocore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_make_api_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;guarded&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;botocore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_make_api_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;original&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The real version also handles &lt;code&gt;blocked_services&lt;/code&gt;, which I'll get to.)&lt;/p&gt;

&lt;p&gt;That's the whole idea. Four things in it are doing the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. An allow-list, not a deny-list.&lt;/strong&gt; The guard doesn't try to list every dangerous verb (&lt;code&gt;Put&lt;/code&gt;, &lt;code&gt;Create&lt;/code&gt;, &lt;code&gt;Delete&lt;/code&gt;, &lt;code&gt;Update&lt;/code&gt;, &lt;code&gt;Modify&lt;/code&gt;, &lt;code&gt;Start&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;, &lt;code&gt;Tag&lt;/code&gt;, and whatever AWS invents next year). It lists the five prefixes that mean "read" and refuses everything else. A new write operation is blocked by default without anyone remembering to add it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Refuse before send.&lt;/strong&gt; The check runs before the original method, so a refused call never leaves the machine. It doesn't matter what the credentials would have allowed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The refusal is loud, twice.&lt;/strong&gt; This is the easy part to miss. The scan's detectors catch their own exceptions, because one failing check shouldn't kill a scan of 20 services. That means a raised &lt;code&gt;ReadOnlyViolation&lt;/code&gt; can be swallowed and logged as a warning, and the scan would carry on looking fine. So the guard &lt;em&gt;also&lt;/em&gt; appends every refusal to &lt;code&gt;log.violations&lt;/code&gt;, and the caller checks that list when each region finishes. If it is not empty, the whole scan fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ReadOnlyViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused non-read AWS call(s): &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A safety check that a &lt;code&gt;try/except&lt;/code&gt; can quietly turn into a warning is not a safety check. There is a test for exactly this: a refused write in any region fails the whole scan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Scoped, and always restored.&lt;/strong&gt; The patch exists only inside the &lt;code&gt;with&lt;/code&gt; block and is undone in &lt;code&gt;finally&lt;/code&gt;. Nothing else in the process keeps a patched client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same hook does a second job
&lt;/h2&gt;

&lt;p&gt;Because the guard sees every call by service and operation, it counts them. The JSON output includes the number of calls made to each service, so you can see what the scan actually did against your account.&lt;/p&gt;

&lt;p&gt;It also answers one service locally. The Cost Explorer checks cost $0.01 per request on AWS's side, about $0.13 per scan, so &lt;code&gt;--no-cost-explorer&lt;/code&gt; adds &lt;code&gt;ce&lt;/code&gt; to &lt;code&gt;blocked_services&lt;/code&gt;. Those calls are never sent. The guard answers them with &lt;code&gt;AccessDenied&lt;/code&gt;, exactly what the scanner would see if your role had no Cost Explorer permission, and the scan falls back the same way. They are counted separately from violations, because skipping a paid read is not a safety event.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not protect you from
&lt;/h2&gt;

&lt;p&gt;This is the part that matters for trust, so, plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It trusts AWS's naming.&lt;/strong&gt; The guard is a prefix allow-list. An operation called &lt;code&gt;Get...&lt;/code&gt; is allowed because its name says it is a read, and that is a rule about names, not a proof about behavior. Some read-named operations do have side effects: STS &lt;code&gt;GetSessionToken&lt;/code&gt; mints temporary credentials, and Cognito Identity's &lt;code&gt;GetId&lt;/code&gt; can generate a new identity ID. Neither changes your infrastructure, but the guard cannot tell the difference, and it would not catch a &lt;code&gt;Get...&lt;/code&gt; that did. The scan's JSON output lists the calls it actually made per service, which is how you audit what ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It guards the scanner's own process.&lt;/strong&gt; It wraps botocore inside the same Python process as the code it protects. It stops mistakes: a detector that tries to be helpful, a future change, a bad merge. It is not a sandbox against hostile code, which could remove the patch. That is why the code is source-available (FSL-1.1-ALv2): the guard is a few lines you can read before you run anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only is not "reads nothing."&lt;/strong&gt; Reads can still return sensitive data if the credentials allow it. Only give the scanner a profile that can see what you're comfortable having it see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a substitute for a read-only role.&lt;/strong&gt; Use a read-only IAM role anyway. The guard and the role fail in different ways, which is the point of having both: the role stops the guard's bugs, and the guard stops the role's drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more check sits outside the guard. When I recorded every network connection during a full scan, it was 48 hosts, all of them &lt;code&gt;*.amazonaws.com&lt;/code&gt;. Nothing is sent to CloudWise. The guard answers "will it change anything?" and the network capture answers "who is it talking to?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy this for your own tool
&lt;/h2&gt;

&lt;p&gt;If you ship something that runs inside a customer's cloud account, the pattern generalizes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find the one function every call goes through.&lt;/li&gt;
&lt;li&gt;Allow-list the safe operations by name. Refuse everything else by default.&lt;/li&gt;
&lt;li&gt;Refuse before the request is sent, not after.&lt;/li&gt;
&lt;li&gt;Record the refusal somewhere an exception handler can't swallow it, and fail the run.&lt;/li&gt;
&lt;li&gt;Scope the hook to the work, and restore it in &lt;code&gt;finally&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep the role read-only too. Two layers that fail differently beat one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The scanner is free and runs on your machine, with your own credentials. It checks 20 AWS services, and &lt;code&gt;--format json&lt;/code&gt; lists how many calls it made to each.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;cloudcostwise
cloudcostwise scan &lt;span class="nt"&gt;--profile&lt;/span&gt; my-profile &lt;span class="nt"&gt;--regions&lt;/span&gt; all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Free, read-only, a few minutes: &lt;a href="https://cloudcostwise.io/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=read-only-by-construction-guard-in-front-of-every-aws-call" rel="noopener noreferrer"&gt;cloudcostwise.io&lt;/a&gt;. The guard lives in &lt;code&gt;runtime.py&lt;/code&gt; in the &lt;a href="https://github.com/cloudwise-app/cloudcostwise" rel="noopener noreferrer"&gt;repo&lt;/a&gt;. If you can find a way past it, &lt;a href="https://github.com/cloudwise-app/cloudcostwise/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt;. I'd rather hear it from you.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>python</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Zero Strangers Connected an AWS Account. So the Scanner Runs on Your Machine.</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/zero-strangers-connected-an-aws-account-so-the-scanner-runs-on-your-machine-119p</link>
      <guid>https://dev.to/cloudwiseteam/zero-strangers-connected-an-aws-account-so-the-scanner-runs-on-your-machine-119p</guid>
      <description>&lt;p&gt;For fourteen months I built CloudWise, a hosted tool that finds AWS waste and fixes it safely.&lt;/p&gt;

&lt;p&gt;This summer I stopped building and ran an experiment with exactly one metric: &lt;strong&gt;would ten strangers connect an AWS account by October 12?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer was zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why zero is the right answer
&lt;/h2&gt;

&lt;p&gt;It took me too long to see it, but it's obvious from the other side of the table. Connecting a cost tool means creating an IAM role in your account and handing it to a vendor. When the vendor is one person you've never heard of, the sensible answer is no. It doesn't matter how good the scans are. Nobody gets far enough to see them.&lt;/p&gt;

&lt;p&gt;I had been trying to fix a trust problem with features. Fourteen months of detectors, dashboards and an AI copilot, aimed at a step nobody would take.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the scanner comes to you
&lt;/h2&gt;

&lt;p&gt;Today the scanner is free and source-available (FSL-1.1-ALv2) as &lt;strong&gt;cloudcostwise&lt;/strong&gt;. It runs on your machine, with your own read-only credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;cloudcostwise
cloudcostwise scan &lt;span class="nt"&gt;--profile&lt;/span&gt; my-profile &lt;span class="nt"&gt;--regions&lt;/span&gt; all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks 20 AWS services, including EC2, EBS, S3, RDS, DynamoDB, ElastiCache, Lambda, NAT gateways and load balancers, Elastic IPs, VPC endpoints, CloudWatch logs and dashboards, ECR, EFS, SageMaker, Lightsail, WorkSpaces, dangling DNS records, RI and Savings Plans opportunities, and Compute Optimizer. All 17 regions take a few minutes, four regions at a time.&lt;/p&gt;

&lt;p&gt;It's also an MCP server. With &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt; installed, add it to Claude Code or Codex and ask "where am I wasting money on AWS?":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin marketplace add cloudwise-app/cloudcostwise
/plugin install cloudcostwise@cloudcostwise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three promises, enforced in code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It cannot change anything.&lt;/strong&gt; Every AWS call goes through a guard that refuses any operation that isn't &lt;code&gt;Describe&lt;/code&gt;, &lt;code&gt;List&lt;/code&gt;, &lt;code&gt;Get&lt;/code&gt;, &lt;code&gt;Search&lt;/code&gt; or &lt;code&gt;Lookup&lt;/code&gt;, before the request leaves your machine. If one were ever attempted, the scan stops with an error. Read-only by construction, not by policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing is sent to CloudWise.&lt;/strong&gt; No signup, no telemetry, no update checks. I recorded every network connection during a full scan: 48 hosts, all of them &lt;code&gt;*.amazonaws.com&lt;/code&gt;. If you use it through Claude Code or Codex, the findings go to that assistant like any other tool output, so treat them the way you treat the rest of your session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It tells you what it costs you.&lt;/strong&gt; The RI and Savings Plans checks call Cost Explorer, which AWS bills at $0.01 per request, about $0.13 a scan. The tool says so before it runs, and &lt;code&gt;--no-cost-explorer&lt;/code&gt; skips them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AWS's free tools don't flag
&lt;/h2&gt;

&lt;p&gt;The fair question: AWS already has Cost Optimization Hub and Compute Optimizer for free, and Trusted Advisor's cost checks if you pay for a Business or Enterprise support plan. Use them. For idle EC2, RDS and NAT gateways and for rightsizing, this tool overlaps with them.&lt;/p&gt;

&lt;p&gt;So I went through AWS's own lists of what those three cover (&lt;a href="https://docs.aws.amazon.com/awssupport/latest/user/trusted-advisor-check-reference.html" rel="noopener noreferrer"&gt;Trusted Advisor&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/cost-management/latest/userguide/cost-optimization-hub.html" rel="noopener noreferrer"&gt;Cost Optimization Hub&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/compute-optimizer/latest/ug/supported-resources.html" rel="noopener noreferrer"&gt;Compute Optimizer&lt;/a&gt;) and compared every check this tool has proven on real resources. &lt;strong&gt;Of its 45 proven checks, 17 flag waste that none of AWS's tools flag, and 10 more go further than the closest AWS check.&lt;/strong&gt; A few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log groups set to never expire.&lt;/strong&gt; They grow every month. Trusted Advisor does check log retention, but it flags retention that is too &lt;em&gt;short&lt;/em&gt;, for compliance, so "never expire" passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old and untagged ECR images.&lt;/strong&gt; Trusted Advisor checks that a repository &lt;em&gt;has&lt;/em&gt; a lifecycle policy. Nothing flags the images already piling up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EFS file systems with no mount targets.&lt;/strong&gt; Nothing can read them and they still bill. Trusted Advisor's EFS checks look at redundancy, throughput and backups, not this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda functions that would be about 20% cheaper on arm64, Redis clusters that would be about 20% cheaper on Valkey, ElastiCache versions paying the Extended Support surcharge, and anything in Lightsail.&lt;/strong&gt; None of the three looks at these.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list, with the closest AWS check for each and the AWS page that says so, is in the README. The hosted product adds 16 more proven checks none of AWS's tools flag, across Glue, Step Functions, AWS Backup, AppSync, Elastic Beanstalk and KMS.&lt;/p&gt;

&lt;p&gt;On my test account, where the resources are deliberately tiny, the scan reports some of them like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;old_ecr_images  x1  $0.06/mo
    us-east-1       cwfx-old-ecr-images                         $0.06/mo
untagged_ecr_images  x1  $0.02/mo
    us-east-1       cwfx-untagged-ecr-images                    $0.02/mo
idle_efs  x1  $0.01/mo
    us-east-1       fs-090bfb32bd49de8b7                        $0.01/mo
no_retention_log_group  x1  $0.01/mo
    us-east-1       /aws/lambda/cwfx-clo464-docdb-connect       $0.01/mo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How I know the checks are right
&lt;/h2&gt;

&lt;p&gt;Cost tools have a credibility problem: a wrong finding is worse than no finding. So every waste type is tracked in a validation ledger with levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;L1&lt;/strong&gt;: reviewed against current AWS documentation and pricing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;L2&lt;/strong&gt;: fired on a real AWS resource built to trigger it, and stayed silent on a healthy twin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;L3&lt;/strong&gt;: its finding rendered correctly in the product&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of the 91 waste types this tool can report, &lt;strong&gt;51 are proven at L2 or above&lt;/strong&gt; against real resources in a test account. The other 40 are desk-reviewed. Findings that couldn't be validated within budget are labelled advisory and never added to a savings total. The full table is public: &lt;a href="https://github.com/cloudwise-app/cloudcostwise/blob/main/VALIDATION.md" rel="noopener noreferrer"&gt;VALIDATION.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I wrote most of this with Claude Code. The ledger is how I keep that honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's free and what isn't
&lt;/h2&gt;

&lt;p&gt;The free scanner is 20 of the 46 service checks in CloudWise. The other 26 (Glue, ECS, Step Functions, Backup, CloudFront, commitments and more) stay in the hosted product, along with history, alerts, savings reconciled against your actual bill, and fixes you approve, with rollback. If the local scan earns your trust, that's where the rest lives.&lt;/p&gt;

&lt;p&gt;The license is the &lt;a href="https://fsl.software" rel="noopener noreferrer"&gt;Functional Source License&lt;/a&gt;: read it, run it, use it at work. The one thing you can't do is sell it as a competing product. Each release becomes Apache 2.0 two years after it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ask
&lt;/h2&gt;

&lt;p&gt;Run it on a real account and tell me where it's wrong. A false positive in your account is the most useful thing you could send me. &lt;a href="https://github.com/cloudwise-app/cloudcostwise/issues" rel="noopener noreferrer"&gt;Open an issue&lt;/a&gt;, or reply wherever you found this.&lt;/p&gt;

&lt;p&gt;The repo: &lt;a href="https://github.com/cloudwise-app/cloudcostwise" rel="noopener noreferrer"&gt;github.com/cloudwise-app/cloudcostwise&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>finops</category>
      <category>security</category>
    </item>
    <item>
      <title>A Cost-Anomaly Job Can Run Green and Check Nothing: CUR Lag and a date == today Gate</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 07 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/a-cost-anomaly-job-can-run-green-and-check-nothing-cur-lag-and-a-date-today-gate-ae</link>
      <guid>https://dev.to/cloudwiseteam/a-cost-anomaly-job-can-run-green-and-check-nothing-cur-lag-and-a-date-today-gate-ae</guid>
      <description>&lt;p&gt;Our cost-anomaly sweep runs every day at 08:00 UTC. It reads daily cost rows built from AWS billing data, and it only evaluated a service if that service had a row dated today.&lt;/p&gt;

&lt;p&gt;Billing data lags real usage. At 08:00 UTC a row dated today almost never exists. So the sweep evaluated zero services on every run, with no error.&lt;/p&gt;

&lt;p&gt;This is the full story behind a correction we added to one of our own posts, and the three things we would tell anyone building on Cost and Usage Report (CUR) data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest link first
&lt;/h2&gt;

&lt;p&gt;In August we published &lt;a href="https://cloudcostwise.io/blog/catching-a-cost-spike-the-same-day-it-happens" rel="noopener noreferrer"&gt;Catching a Cost Spike the Same Day It Happens&lt;/a&gt;. It walked through the z-score math behind our anomaly detector. The math was right. The promise in the title was not.&lt;/p&gt;

&lt;p&gt;On September 21 we added a correction note to the top of that post. It says, in short: when the post went out, the scheduled sweep did not catch spikes the same day, because it only looked at rows dated today, and those rows almost never existed yet. It also says a second bug stopped the sweep from resolving Shield-tier users at all.&lt;/p&gt;

&lt;p&gt;That note is two sentences long. This post is the rest of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: a &lt;code&gt;date == today&lt;/code&gt; gate on lagged data
&lt;/h2&gt;

&lt;p&gt;Here is the shape of the old code, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daily_costs&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;service_costs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;today_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cost&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;daily_costs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;today_cost&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;

    &lt;span class="c1"&gt;# ... z-score against the trailing history ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;continue&lt;/code&gt; line with lagged data in mind. At 08:00 UTC the newest cost row for an account is usually yesterday or the day before. No row matches &lt;code&gt;today&lt;/code&gt;. Every service hits &lt;code&gt;continue&lt;/code&gt;. The loop finishes, the function returns an empty list, and the Lambda exits cleanly.&lt;/p&gt;

&lt;p&gt;Nothing about that run looks wrong from the outside. No exception, no timeout, no error log. An empty list of anomalies is also exactly what a quiet, healthy account produces. The job was indistinguishable from a job that checked everything and found nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That is the trap.&lt;/strong&gt; A scheduled job's success status tells you it finished. It says nothing about how much it covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: a lookup on an attribute no row ever had
&lt;/h2&gt;

&lt;p&gt;The second bug sat one step earlier. The sweep resolved each Shield-tier user through a &lt;code&gt;cognito_user_sub&lt;/code&gt; attribute. No row in our users table has ever had that attribute: the table's key is &lt;code&gt;id&lt;/code&gt;, which holds the same Cognito identifier.&lt;/p&gt;

&lt;p&gt;So every user's ID resolved to &lt;code&gt;None&lt;/code&gt;, and the sweep could not process a single Shield user.&lt;/p&gt;

&lt;p&gt;Our tests didn't catch it because the test fixtures gave fake users a &lt;code&gt;cognito_user_sub&lt;/code&gt; field. The fixtures agreed with the code, so the tests passed. The fix changed the lookup to &lt;code&gt;id&lt;/code&gt;, and the tests now use users shaped like real rows (an &lt;code&gt;id&lt;/code&gt;, no &lt;code&gt;cognito_user_sub&lt;/code&gt;). Each new or updated test was confirmed to fail against the pre-fix handler before we trusted it.&lt;/p&gt;

&lt;p&gt;Two bugs, stacked. Either one alone was enough to make the sweep evaluate nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: evaluate the newest complete day, and say so when it's too old
&lt;/h2&gt;

&lt;p&gt;We fixed both on September 15 and 16, 2026. The date fix landed in both places that run detection, the scheduled sweep and the on-demand path, and it changed four things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Evaluate the newest date that has data.&lt;/strong&gt; Instead of &lt;code&gt;today&lt;/code&gt;, the sweep picks the newest date with a cost row for the account, across all of its services, and evaluates that day. Detection now runs behind real usage by the billing lag, typically a day or two, not on the same day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Skip accounts whose data is too old, loudly.&lt;/strong&gt; If an account's newest cost row is more than 3 days old, the sweep skips it instead of evaluating a stale day. Two things record the skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A log line at WARNING. Not INFO, because INFO is dropped in our API Lambda, which runs the same logic when someone triggers detection on demand. We learned that one separately, and it applies here too: a log you never see is not a signal.&lt;/li&gt;
&lt;li&gt;A CloudWatch metric, &lt;code&gt;CloudWise/Anomaly&lt;/code&gt; / &lt;code&gt;StaleCostData&lt;/code&gt;, with an &lt;code&gt;Environment&lt;/code&gt; dimension only. The commit priced it at about $0.30 a month per environment, about $0.60 across staging and production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Alert once per evaluated day.&lt;/strong&gt; Evaluating a lagged day means two consecutive daily runs can look at the same date. A separate dedupe keyed on (account, service, evaluated_date) makes sure that day alerts exactly once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Keep newer rows out of the baseline.&lt;/strong&gt; The baseline now only uses rows &lt;em&gt;before&lt;/em&gt; the evaluated day (&lt;code&gt;&amp;lt; target_date&lt;/code&gt;, not &lt;code&gt;!= target_date&lt;/code&gt;). Otherwise a newer, partial CUR row could slip into the history and manufacture a spike.&lt;/p&gt;

&lt;p&gt;The z-score thresholds and the $5 floor from the original post are unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell anyone building on CUR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Know your data's lag before you write "today".&lt;/strong&gt; Billing data is not a live feed. Pick the evaluation date from the data you actually have, not from the clock. If your code has &lt;code&gt;date == today&lt;/code&gt; anywhere near cost data, check what time the job runs and what the newest row is at that moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count what you evaluated.&lt;/strong&gt; The number that would have exposed this bug on day one was not "anomalies found". It was "services evaluated". A run that evaluates zero services is not a quiet day. It is a broken job, and it should look different from one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alarm on zero coverage, not only on errors.&lt;/strong&gt; An error alarm would not have caught this, because there was no error. Coverage needs its own signal.&lt;/p&gt;

&lt;p&gt;To be straight about where we are with that last one: today the sweep emits the &lt;code&gt;StaleCostData&lt;/code&gt; metric, and it logs the skip at WARNING. There is no alarm on the metric yet, and the sweep does not yet publish a count of services evaluated per run. That is the gap still open, and it's the one we would close first in anyone else's system too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make test data look like production data.&lt;/strong&gt; The Shield bug survived because the fixtures had a field no real row has. A fixture shaped from a real row, with the real keys and nothing extra, would have failed on the first run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for a cost tool
&lt;/h2&gt;

&lt;p&gt;A cost tool that errors is annoying. A cost tool that runs, reports success, and checks nothing is worse, because it hands you confidence you didn't earn. The fix here was small. Finding it required asking a question the job's status could never answer: how many things did you actually look at?&lt;/p&gt;

&lt;p&gt;If you want to see what a read-only pass over your own account finds, CloudWise runs 187 waste checks across 40+ AWS services, and it takes about five minutes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Free, read-only AWS waste scan: &lt;a href="https://cloudcostwise.io" rel="noopener noreferrer"&gt;cloudcostwise.io&lt;/a&gt;. Five minutes, no card.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>observability</category>
      <category>finops</category>
      <category>python</category>
    </item>
    <item>
      <title>SR&amp;ED documentation as a solo founder: log technological uncertainty while you build</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:27:37 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/sred-documentation-as-a-solo-founder-log-technological-uncertainty-while-you-build-b4</link>
      <guid>https://dev.to/cloudwiseteam/sred-documentation-as-a-solo-founder-log-technological-uncertainty-while-you-build-b4</guid>
      <description>&lt;p&gt;The SR&amp;amp;ED claim took me longer than the feature I was claiming for.&lt;/p&gt;

&lt;p&gt;Not the form. The reconstruction. CRA wants the technological uncertainty documented: what you didn't know, what you tried, why the obvious approach failed. I had none of that written down. I had commits.&lt;/p&gt;

&lt;p&gt;Rebuilding that narrative from about 14 months of git history and AI-assisted coding sessions took roughly three weeks I had not budgeted. This post is about the habit that would have made it a few days: a running log, two lines per dead end, written on the day the dead end happens.&lt;/p&gt;

&lt;p&gt;Context, so you can weigh this properly. I'm the solo founder of CloudWise, an AWS cost optimization tool: 189 waste checks across 40+ AWS services, built by one person, pre-revenue, no paying customers. I paused feature development in August 2026 to work on distribution. I filed the SR&amp;amp;ED claim myself, without a consultant, and later received a CRA T2 notice of reassessment that resulted in a refund. I have filed exactly once. Everything below is one founder's experience, not advice on whether your work is eligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: reconstructing SR&amp;amp;ED documentation after the fact
&lt;/h2&gt;

&lt;p&gt;If you are mid-build, your record of the work is probably the same as mine was: a git log, some pull request descriptions, a ticket tracker, and a pile of chat sessions with an AI coding assistant.&lt;/p&gt;

&lt;p&gt;That record answers one question well: what changed. It answers a different question badly: what did you not know at the time, and how did you find out.&lt;/p&gt;

&lt;p&gt;A commit message like &lt;code&gt;fix: handle untagged resources in attribution&lt;/code&gt; tells you a fix landed. It does not tell you whether that fix was the first approach or the fourth, why the earlier ones failed, or what each failure taught you. The failed approaches are often not in the history at all. They lived on a branch you deleted, or in a session you never saved, or only in your head.&lt;/p&gt;

&lt;p&gt;So the three weeks were not writing time. They were archaeology: reading my own diffs in order, working out which ones marked a change of approach, then trying to recover why I had abandoned the previous one. Fourteen months later, the why is the part that has decayed the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CRA actually asks you to document on the T661
&lt;/h2&gt;

&lt;p&gt;I am going to stay close to the source here, because this is the part where founder blog posts tend to freelance.&lt;/p&gt;

&lt;p&gt;The claim form is the T661. Its project section asks, in order, for three things: the scientific or technological uncertainties you faced, the work you did to try to overcome them, and the advancement you achieved or attempted. CRA's &lt;a href="https://www.canada.ca/en/revenue-agency/services/forms-publications/publications/t4088/guide-form-t661-scientific-research-experimental-development-expenditures-claim-guide-form-t661.html" rel="noopener noreferrer"&gt;guide to Form T661 (T4088)&lt;/a&gt; walks through those lines and their word limits. The same guide has you indicate what supporting evidence exists for the work, and notes that you keep that evidence rather than submit it with the claim.&lt;/p&gt;

&lt;p&gt;CRA's &lt;a href="https://www.canada.ca/en/revenue-agency/services/scientific-research-experimental-development-tax-incentive-program/sred-policies-guidelines/guidelines-eligibility-work-sred-tax-incentives.html" rel="noopener noreferrer"&gt;guidelines on the eligibility of work&lt;/a&gt; describe technological uncertainty as a situation where it is unknown whether, or how, a result can be achieved because the available technological knowledge is insufficient. The same page tells claimants to keep the evidence that is generated as the work progresses.&lt;/p&gt;

&lt;p&gt;Read those two pages yourself before you read anyone's summary of them, including mine. They are shorter than you expect.&lt;/p&gt;

&lt;p&gt;The practical consequence for technological uncertainty in an SR&amp;amp;ED claim is this: the form asks for a narrative with a specific shape. Uncertainty, then systematic work, then what was learned. A git log has a different shape. It is a list of things that worked well enough to commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had vs. what I needed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the form asks about&lt;/th&gt;
&lt;th&gt;What I had&lt;/th&gt;
&lt;th&gt;What I needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What was uncertain at the start&lt;/td&gt;
&lt;td&gt;Nothing written. Memory.&lt;/td&gt;
&lt;td&gt;A dated note stating what I did not know and why existing approaches did not settle it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What I tried, in order&lt;/td&gt;
&lt;td&gt;Commits for the approaches that survived&lt;/td&gt;
&lt;td&gt;A record of the approaches that did not survive, with the reason each was dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why the obvious approach failed&lt;/td&gt;
&lt;td&gt;Occasionally a PR description&lt;/td&gt;
&lt;td&gt;One sentence, written the day it failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What was learned&lt;/td&gt;
&lt;td&gt;The final code&lt;/td&gt;
&lt;td&gt;A plain statement of the conclusion, including "this does not work"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When the work happened&lt;/td&gt;
&lt;td&gt;Commit timestamps&lt;/td&gt;
&lt;td&gt;The same, which was the one thing git gave me for free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Timestamps were the only column where my existing record was enough. Everything else I rebuilt.&lt;/p&gt;

&lt;p&gt;The AI-assisted coding sessions were the other source. Where a session survived, it sometimes held the reasoning I was missing, because I had explained the problem to the assistant in plain language before asking for help. But I had never kept them with this purpose in mind, so coverage was patchy and finding the relevant ones was slow.&lt;/p&gt;

&lt;p&gt;One more thing I learned during the reconstruction: most of what I had built did not belong in the claim at all. Standard integration work, UI, and infrastructure plumbing did not qualify. What qualified was a narrow slice, cross-account cost attribution where tagging is incomplete and the answer has to be inferred, because that was where I did not know whether the approach would work. Sorting fourteen months of work into those two piles was part of the reconstruction. A log would have done that sorting for me as I went.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lightweight SR&amp;amp;ED documentation habit: two lines per dead end
&lt;/h2&gt;

&lt;p&gt;Here is the whole system. One file in the repo, &lt;code&gt;docs/uncertainty-log.md&lt;/code&gt;, append-only. An entry only when an approach fails or you hit something you do not know how to do. Not a journal. Not a standup.&lt;/p&gt;

&lt;p&gt;Each entry is two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## 2026-03-14&lt;/span&gt;
Tried: inferring the owning account for untagged resources from the creating IAM principal.
Failed because: a shared deploy role creates resources for several accounts, so the principal does not identify the owner. Next: try usage-pattern correlation. (branch: attribution-v2, commit abc1234)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That entry is an illustration of the format in my problem area, not a record from my actual claim.&lt;/p&gt;

&lt;p&gt;The rules I would follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write it the day it fails.&lt;/strong&gt; The reason is obvious today and gone in a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line one is what you tried. Line two is why it failed and what that told you.&lt;/strong&gt; If you cannot write line two, you have not understood the failure yet, which is worth knowing anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link the commit or branch.&lt;/strong&gt; The log carries the reasoning, git carries the dates and the diffs. Together they cover each other's gaps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the question before the answer.&lt;/strong&gt; When you start on something you do not know how to do, write one line saying so. That line is the start of the narrative the form asks for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not log routine work.&lt;/strong&gt; If the path was known and it was just effort, it does not go in. This keeps the file short and gives you, or whoever prepares your claim, a first rough sort of uncertain work from routine work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save the AI sessions that contain reasoning.&lt;/strong&gt; Export them into a folder next to the log, named by date. They are timestamped explanations of what you did not know, written at the time. I had these by accident. Have them on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cost: a couple of minutes per dead end. I would have traded that for three weeks without thinking about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start the log on day one of the build&lt;/strong&gt;, not when I first hear the word SR&amp;amp;ED.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop deleting failed branches.&lt;/strong&gt; Tag them and leave them. A failed approach with a commit history is evidence. A deleted one is an anecdote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write PR descriptions that say what was ruled out&lt;/strong&gt;, not only what was done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the T661 guide before the build, not after.&lt;/strong&gt; Knowing the shape of the questions changes what you bother to write down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget the time.&lt;/strong&gt; Even with a good log, the claim is real work. Without one, it was three weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate uncertain work from routine work as I go&lt;/strong&gt;, because that line decided what went into the claim, and drawing it afterwards was slow.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this post is not
&lt;/h2&gt;

&lt;p&gt;I am a founder who filed once. I am not an SR&amp;amp;ED advisor, I cannot assess your work, and I cannot tell you whether you qualify. If you are a Canadian founder working on SR&amp;amp;ED for startups and you think some of your work involved real technological uncertainty, talk to a qualified claim preparer and read CRA's own pages linked above.&lt;/p&gt;

&lt;p&gt;What I can tell you is narrower and I am confident in it: whatever you end up claiming, and whoever prepares it, the record you keep while building is the raw material. Two lines per dead end. Start today.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Rick Wise is the solo founder of &lt;a href="https://cloudcostwise.io" rel="noopener noreferrer"&gt;CloudWise&lt;/a&gt;, an AWS cost optimization tool with 189 waste checks across 40+ AWS services. He filed one SR&amp;amp;ED claim, himself, and is not a tax advisor. He helps technical founders reconstruct the engineering narrative from their repo and AI coding sessions at &lt;a href="https://sredlab.ca/?ref=cw-blog" rel="noopener noreferrer"&gt;SR&amp;amp;ED Lab&lt;/a&gt;. Your accountant or claim preparer still files.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>git</category>
      <category>saas</category>
      <category>career</category>
    </item>
    <item>
      <title>How We Prove an AWS Waste Check Works: Fire on a Real Cluster, Stay Silent on Its Twin</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/how-we-prove-an-aws-waste-check-works-fire-on-a-real-cluster-stay-silent-on-its-twin-5275</link>
      <guid>https://dev.to/cloudwiseteam/how-we-prove-an-aws-waste-check-works-fire-on-a-real-cluster-stay-silent-on-its-twin-5275</guid>
      <description>&lt;p&gt;A unit test tells you your code does what you think AWS does. It can't tell you what AWS actually does.&lt;/p&gt;

&lt;p&gt;For a tool that reads CloudWatch metrics and billing data to decide whether a resource is wasted, that gap is the whole product. So we test each check the same way: build the wasted thing for real, and make the check prove itself on it.&lt;/p&gt;

&lt;p&gt;The rule is two halves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixture:&lt;/strong&gt; a real resource that &lt;em&gt;should&lt;/em&gt; trigger the check. The check has to fire on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control:&lt;/strong&gt; the healthy version of the same thing. The check has to stay silent on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fire without silence and you've built an alarm that always rings. Silence without fire and you've built nothing. You need both, measured against live AWS, before you trust the check.&lt;/p&gt;

&lt;p&gt;Here is what that looked like for one check: idle DocumentDB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check, and the real cluster we pointed it at
&lt;/h2&gt;

&lt;p&gt;The rule: flag a DocumentDB cluster with &lt;strong&gt;zero connections&lt;/strong&gt; and combined read plus write IOPS &lt;strong&gt;under 10 per second&lt;/strong&gt;, averaged over 7 days.&lt;/p&gt;

&lt;p&gt;We stood up a real &lt;code&gt;db.t3.medium&lt;/code&gt; cluster in a separate test AWS account and left it alone. It costs $1.87 a day and it's a short-lived test resource, not something we keep. Measured on 2026-09-19:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (7-day window)&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DatabaseConnections&lt;/td&gt;
&lt;td&gt;0.0000&lt;/td&gt;
&lt;td&gt;must be 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Combined read + write IOPS&lt;/td&gt;
&lt;td&gt;5.46 / s&lt;/td&gt;
&lt;td&gt;under 10 / s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is an idle cluster by any reasonable reading. The check should fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the real cluster told us that the unit tests couldn't
&lt;/h2&gt;

&lt;p&gt;It did not fire. Not on the first try, and not for one reason. Three separate bugs were stacked in front of it, and every one of them lived in the gap between our code and AWS's behavior.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What was wrong&lt;/th&gt;
&lt;th&gt;How it hid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wiring&lt;/td&gt;
&lt;td&gt;The detector called a metrics method that didn't exist on the live data provider, so it always got nothing back. Fixed 2026-07-09.&lt;/td&gt;
&lt;td&gt;A missing answer reads as "no data", which reads as "nothing wrong here".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Units&lt;/td&gt;
&lt;td&gt;CloudWatch reports DocumentDB IOPS as a &lt;strong&gt;rate&lt;/strong&gt; (Count per Second). We read it with &lt;code&gt;Statistics=Sum&lt;/code&gt; over 7 days, which adds a rate up as if it were a running total. A steady ~5.15 per second over 10,080 one-minute datapoints sums to roughly &lt;strong&gt;52,000&lt;/strong&gt;, against a gate expecting a small number.&lt;/td&gt;
&lt;td&gt;Our regression tests pinned the same &lt;code&gt;Sum&lt;/code&gt;-shaped mock, so they enshrined the bug.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Naming&lt;/td&gt;
&lt;td&gt;Cost data calls the product "Amazon DocumentDB (with MongoDB compatibility)". Our service-to-detector map only knew "Amazon DocumentDB" and "AmazonDocDB", and the lookup is exact, so on every scheduled scan the DocumentDB detector was skipped, even on accounts whose costs listed DocumentDB.&lt;/td&gt;
&lt;td&gt;A skipped detector reports nothing, which looks exactly like an account with no waste.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row is the one worth sitting with. The tests were not sloppy. They were written against a mock that matched the code's assumption, so they could only ever confirm the assumption. Only a real cluster reporting a real rate could disagree, and it did.&lt;/p&gt;

&lt;p&gt;The fix for the second bug reads the &lt;strong&gt;average&lt;/strong&gt; of the rate instead of the sum, and gates on combined IOPS averaging under 10 per second with zero connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first real firing, and why we almost missed it
&lt;/h2&gt;

&lt;p&gt;With all three fixed, the 2026-09-19 06:01 UTC scan ran the DocumentDB detector on that cluster for the first time and it fired: idle cluster, list price $56.94 a month for one &lt;code&gt;db.t3.medium&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You would not have seen it in the findings list, though. Our scan reconciles every finding against the account's actual billing before showing it, and the test account was on DocumentDB's free trial, so the finding reconciled to $0.00 and was dropped from the customer-facing list on purpose. It only existed in the pre-floor debug output we keep for exactly this kind of question.&lt;/p&gt;

&lt;p&gt;That's the system working as designed, and it's also why "look at the findings page" is not a test. We had to read the layer beneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control: the same cluster, connected
&lt;/h2&gt;

&lt;p&gt;A healthy twin usually means a second resource. For this check the healthy state is simply "someone is using it", and the two states are mutually exclusive by construction: the check requires zero connections, so any connection at all rules idle out.&lt;/p&gt;

&lt;p&gt;So the control was the same cluster with one variable changed. On 2026-09-19 we opened a handful of connections from a short-lived Lambda inside the VPC and deleted it the same day.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Fixture (idle)&lt;/th&gt;
&lt;th&gt;Control (connected)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DatabaseConnections&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 to 6 per minute while connected, 7-day average 0.0169&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Combined IOPS&lt;/td&gt;
&lt;td&gt;5.46 / s&lt;/td&gt;
&lt;td&gt;5.44 / s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan on 2026-09-20&lt;/td&gt;
&lt;td&gt;fired the day before&lt;/td&gt;
&lt;td&gt;detector ran, &lt;strong&gt;0 findings&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing moved except the connections. IOPS stayed under the gate, and one afternoon of traffic was enough to pull the 7-day average off zero and silence the check. Fired on the fixture, silent on the control.&lt;/p&gt;

&lt;p&gt;One honest caveat, because it would be easy to leave out: those two observations are separated in time, not simultaneous. It's one cluster measured in two states, and the code guarantees the states can't both be true. That's a weaker claim than "two clusters side by side", and we say so in the write-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls need checking too
&lt;/h2&gt;

&lt;p&gt;The same sweep caught the opposite failure. A Glue job we built as a healthy control set off the "missing timeout" check. We had left the timeout out when creating it, and Glue quietly defaults to 2,880 minutes, which is exactly the number the check looks for. The control was never healthy.&lt;/p&gt;

&lt;p&gt;A control that fires isn't a control. We now set the timeout explicitly, and we verify every control is silent across &lt;em&gt;all&lt;/em&gt; checks, not only its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A passing unit test is a statement about your assumptions.&lt;/strong&gt; Real resources are the only thing that can contradict them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the whole path, not the function.&lt;/strong&gt; All three bugs lived outside the detector's decision logic: in how metrics were fetched and read, and in how the check got selected to run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove silence as hard as you prove firing.&lt;/strong&gt; A check nobody has watched stay quiet is a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's cheap.&lt;/strong&gt; One small cluster for roughly a week costs about what a lunch does, and it found bugs our mock-based tests couldn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the method we use when we validate a check against real AWS. CloudWise runs 189 waste checks across 40+ AWS services, and if you want to see what a read-only pass finds in your own account, it takes about five minutes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Free, read-only AWS waste scan: &lt;a href="https://cloudcostwise.io" rel="noopener noreferrer"&gt;cloudcostwise.io&lt;/a&gt; — five minutes, no card.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>testing</category>
      <category>devops</category>
      <category>finops</category>
    </item>
    <item>
      <title>Your Container Tag Is Lying to You: A Mutable ECR Tag Put the Wrong Build in Production</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/your-container-tag-is-lying-to-you-a-mutable-ecr-tag-put-the-wrong-build-in-production-1o57</link>
      <guid>https://dev.to/cloudwiseteam/your-container-tag-is-lying-to-you-a-mutable-ecr-tag-put-the-wrong-build-in-production-1o57</guid>
      <description>&lt;p&gt;On 2026-08-21, a routine docs-only PR merged to &lt;code&gt;main&lt;/code&gt;. CI built the frontend image, pushed it, and called &lt;code&gt;start-deployment&lt;/code&gt; on the App Runner service — and got rejected. The pipeline went red. &lt;strong&gt;"Deploy to Production" failed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That part is normal; pipelines go red. What isn't normal is what CloudTrail and ECR showed once we went looking: production wasn't broken by the red step. It was broken by the deploy that &lt;em&gt;succeeded&lt;/em&gt; 3 seconds earlier — from a laptop, not CI — and the pipeline's red error message never once mentioned it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline, from CloudTrail and ECR, not from memory
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (EDT)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10:52:25&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;StartDeployment&lt;/code&gt; on &lt;code&gt;cloudwise-production-frontend&lt;/code&gt;, called by an IAM user from a local machine (&lt;code&gt;aws-cli/2.34.2&lt;/code&gt;, macOS, arm64) — not CI. At this instant the &lt;code&gt;production&lt;/code&gt; tag points at the &lt;strong&gt;previous&lt;/strong&gt; build.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:52:43&lt;/td&gt;
&lt;td&gt;CI pushes the new frontend image and moves the &lt;code&gt;production&lt;/code&gt; tag to point at it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:52:44&lt;/td&gt;
&lt;td&gt;CI calls &lt;code&gt;StartDeployment&lt;/code&gt; as its own deploy role → &lt;code&gt;InvalidRequestException: Can't start a deployment … because it isn't in RUNNING state&lt;/code&gt; → exit 254 → &lt;strong&gt;pipeline red&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:55:49&lt;/td&gt;
&lt;td&gt;The manual deployment from 10:52:25 &lt;strong&gt;succeeds&lt;/strong&gt; — having resolved the &lt;code&gt;production&lt;/code&gt; tag back at 10:52:25, before CI moved it. It deploys the &lt;strong&gt;old&lt;/strong&gt; image.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Net state: the &lt;code&gt;production&lt;/code&gt; tag pointed at the image CI had just built. That image was never deployed. App Runner was &lt;code&gt;RUNNING&lt;/code&gt; on the previous build, and the pipeline was red for a reason that named none of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;18 seconds&lt;/strong&gt; is the whole story. That's the window between the manual deploy resolving the tag and CI moving it. Move a mutable tag inside that window and the registry and the running container permanently disagree about which image "production" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a mutable tag is the actual defect
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ECR_TAG=production&lt;/code&gt; for the frontend repository always points at the newest push. App Runner doesn't pin a digest — it resolves whatever &lt;code&gt;production&lt;/code&gt; points to &lt;strong&gt;at the moment a deployment starts&lt;/strong&gt;, not at the moment the container swap completes. Those are two different instants, and normally nothing happens in between.&lt;/p&gt;

&lt;p&gt;Two deployments racing to start closes that gap. One of them read the tag old; the other wrote it new. Whichever deployment actually executes the swap runs whatever it resolved, and there's no signal anywhere in App Runner's own state that says "the tag has moved since I started."&lt;/p&gt;

&lt;p&gt;This had already been flagged as a hazard in the abstract — two CI runs landing close together can serve an older image, all green, no laptop involved. This incident reached the same failure by a different route: &lt;strong&gt;the second actor wasn't CI at all&lt;/strong&gt;, so none of the existing concurrency guards between pipeline runs applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: verify the digest, don't trust the tag (PR #1181)
&lt;/h2&gt;

&lt;p&gt;Two independent changes, both in &lt;code&gt;.github/workflows/deploy-environment.yml&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Bounded retry on &lt;code&gt;start-deployment&lt;/code&gt;.&lt;/strong&gt; A &lt;code&gt;RUNNING&lt;/code&gt;-state rejection now retries for up to 20 minutes instead of failing on the first attempt — a wait can only delay a deploy, never corrupt one, so it's a safe response to "something else is deploying right now." But a retry alone doesn't fix the race; it just makes CI patient. The actual fix is next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Re-read the tag's digest on every retry attempt, and refuse to deploy if it moved.&lt;/strong&gt; Before each &lt;code&gt;start-deployment&lt;/code&gt; call, CI re-reads what &lt;code&gt;production&lt;/code&gt; currently points to and compares it against the digest of the image &lt;em&gt;this run&lt;/em&gt; pushed. If they no longer match — because something else moved the tag in the interim — CI stops and errors loudly instead of deploying an image it can no longer identify:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The &lt;code&gt;production&lt;/code&gt; tag no longer points at the image this run built. Built &lt;code&gt;&amp;lt;digest&amp;gt;&lt;/code&gt;, tag now &lt;code&gt;&amp;lt;digest&amp;gt;&lt;/code&gt;. Something pushed to this tag outside this pipeline; deploying now would ship an unknown image."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That single check closes the 18-second window: CI will now only ever call &lt;code&gt;start-deployment&lt;/code&gt; when it can prove the tag still means what it thinks it means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A post-swap check — warn-only, on purpose.&lt;/strong&gt; After the deployment completes, CI reads the digest App Runner actually deployed and compares it against what CI pushed. If they don't match, it logs a warning and keeps going rather than failing the pipeline.&lt;/p&gt;

&lt;p&gt;That's a deliberate asymmetry, and it's the design decision worth explaining: the &lt;em&gt;pre-swap&lt;/em&gt; digest check is a hard gate, because it's checkable before anything ships and a mismatch there means CI is about to deploy the wrong thing. The &lt;em&gt;post-swap&lt;/em&gt; check can't be a hard gate, because by the time it runs, the running container already &lt;strong&gt;is&lt;/strong&gt; what it is — the thing that ran ahead was the registry tag, not production. Failing the pipeline red at that point would block a legitimate deploy for a discrepancy that already happened and that the running service had no part in causing. Warn, don't block, on a fact you can no longer change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it worth writing about
&lt;/h2&gt;

&lt;p&gt;The merge that triggered this was &lt;strong&gt;docs-only&lt;/strong&gt;. The image that never got deployed differed from the one that did by markdown and release notes — nothing user-visible. Production was healthy and serving the whole time.&lt;/p&gt;

&lt;p&gt;That's luck, not design, and it's the actual point: the same race on a real frontend change ships a red pipeline &lt;strong&gt;and&lt;/strong&gt; production quietly serving the old build, and the red error points at App Runner deployment state — not at "prod is stale." You'd fix the wrong problem. &lt;code&gt;InvalidRequestException: isn't in RUNNING state&lt;/code&gt; is a true statement about App Runner. It was never a true statement about what mattered, which was that the tag we were about to deploy had already been consumed by someone else. A pipeline that goes red for the right reason but tells you the wrong story is worse than one that goes red honestly — you close the ticket on the error you can see instead of the one that actually happened.&lt;/p&gt;

&lt;p&gt;Boring fixes are the ones worth shipping: retry with a bound, verify a digest instead of trusting a tag, and only fail hard on the check that happens before anything ships.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We hit this on our own production App Runner service, running the app you'd be scanning your AWS bill with. Curious what a read-only pass over your own account finds? Free, read-only AWS waste scan: &lt;a href="https://cloudcostwise.io" rel="noopener noreferrer"&gt;cloudcostwise.io&lt;/a&gt; — five minutes, no card.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cicd</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Catching a Cost Spike the Same Day It Happens: Inside Our Z-Score Anomaly Detector</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:03:55 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/catching-a-cost-spike-the-same-day-it-happens-inside-our-z-score-anomaly-detector-4mmo</link>
      <guid>https://dev.to/cloudwiseteam/catching-a-cost-spike-the-same-day-it-happens-inside-our-z-score-anomaly-detector-4mmo</guid>
      <description>&lt;p&gt;AWS Cost Explorer's own data can lag up to 24 hours behind real usage, and nobody's on call for "the bill." A forgotten load test, a runaway Auto Scaling event, a script that didn't tear down its own infrastructure — by the time a human opens the console, whatever ran already ran.&lt;/p&gt;

&lt;p&gt;CloudWise's anomaly detector is built to close that gap: a daily scheduled scan, not a dashboard you have to remember to check. Here's the actual statistics behind it — the real thresholds from &lt;code&gt;lambdas/anomaly_detector/handler.py&lt;/code&gt; — and the exact test case, already in our shipped suite, that proves it catches a spike.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanic: z-score, not a percentage
&lt;/h2&gt;

&lt;p&gt;The naive version of this feature checks "is today more than X% above yesterday?" That breaks immediately: a service that costs $2/day naturally swings 200% day to day out of pure noise, while a service that costs $4,000/day moving 15% is a real four-figure problem. Percentage-of-yesterday has no sense of a service's &lt;em&gt;normal&lt;/em&gt; variance.&lt;/p&gt;

&lt;p&gt;The detector instead computes a &lt;a href="https://en.wikipedia.org/wiki/Standard_score" rel="noopener noreferrer"&gt;z-score&lt;/a&gt; per AWS service, per account, every day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lambdas/anomaly_detector/handler.py
&lt;/span&gt;&lt;span class="n"&gt;SPIKE_THRESHOLD_STD_DEV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;   &lt;span class="c1"&gt;# standard deviations from the mean
&lt;/span&gt;&lt;span class="n"&gt;MIN_ABSOLUTE_CHANGE_USD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;   &lt;span class="c1"&gt;# minimum dollar change to trigger, at all
&lt;/span&gt;&lt;span class="n"&gt;MIN_DATA_POINTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;             &lt;span class="c1"&gt;# minimum days of history required
&lt;/span&gt;
&lt;span class="n"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;historical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;std_dev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stdev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;historical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;historical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;today_cost&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;
&lt;span class="n"&gt;z_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;today_cost&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;std_dev&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;std_dev&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;z_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;SPIKE_THRESHOLD_STD_DEV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ...flag it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;historical&lt;/code&gt; is the trailing daily costs for that service, excluding today. If a service doesn't have at least 7 days of history yet, it's skipped — no history means no baseline, and a false "anomaly" on day one of a brand-new resource is worse than useless. And &lt;strong&gt;before z-score even runs&lt;/strong&gt;, there's a $5 absolute-change floor: if today's cost moved by less than five dollars, the detector doesn't care how many standard deviations that represents. A service that normally costs $0.02/day moving to $0.11/day is a 450% swing, a huge z-score, and completely irrelevant to anyone's bill. The dollar floor is what keeps a statistically-driven detector from paging you about noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity isn't binary
&lt;/h2&gt;

&lt;p&gt;Once something clears the 2.0 z-score bar, it gets bucketed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SEVERITY_THRESHOLDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CRITICAL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MEDIUM&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Yes, &lt;code&gt;LOW&lt;/code&gt; is below the 2.0 trigger threshold in the table — that band exists for a second call site that widens the net for manual/on-demand scans. The scheduled daily job only ever emits &lt;code&gt;MEDIUM&lt;/code&gt; and up, because the scheduled job's trigger &lt;em&gt;is&lt;/em&gt; 2.0.) The severities aren't decoration — they're what a Shield-tier customer's Slack alert leads with, and what determines whether the message reads as "worth a look this week" or "look now."&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that proves it
&lt;/h2&gt;

&lt;p&gt;Rather than a hypothetical dollar figure, here's the exact fixture from our own shipped test suite (&lt;code&gt;lambdas/anomaly_detector/test_handler.py::test_detects_anomaly_with_high_z_score&lt;/code&gt;) — real, committed, running in CI on every change to this file:&lt;/p&gt;

&lt;p&gt;An EC2 line item costs $9–$11/day for seven straight days: $10, $11, $9, $10.5, $9.5, $10, $10. Mean &lt;strong&gt;$10.00&lt;/strong&gt;, standard deviation &lt;strong&gt;$0.65&lt;/strong&gt;. Then it jumps to &lt;strong&gt;$100&lt;/strong&gt; in a day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;change     = 100 - 10        = $90
z_score    = (100 - 10) / 0.65 = 139.4
percentage = 90 / 10 * 100     = 900%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A z-score of 139.4 isn't a borderline call by any measure — the test asserts the detector flags it as &lt;code&gt;CRITICAL&lt;/code&gt; or &lt;code&gt;HIGH&lt;/code&gt; severity, and it does, deterministically, every run. The absolute dollars here are small on purpose: it's a unit test, not a customer account. The statistical shape — a service ten times its normal cost, standard deviations off its own baseline — is exactly what the 2.0 threshold exists to catch on day one, whether the line item is $10 or $10,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is Shield-tier, and why it's read-only
&lt;/h2&gt;

&lt;p&gt;The detector runs once a day via EventBridge, over the last 30 days of each Shield-tier account's cost data, and writes findings to an alerts table before dispatching Slack and email notifications. It doesn't touch anything in your AWS account — no remediation, no API calls beyond reading Cost Explorer data CloudWise already ingests for the dashboard. That's deliberate: detecting a spike and &lt;em&gt;deciding what to do about it&lt;/em&gt; are different problems, and this one only does the first.&lt;/p&gt;

&lt;p&gt;The mechanic is boring on purpose — mean, standard deviation, a threshold, a dollar floor. Boring is what you want from something that's deciding whether to interrupt your weekend.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>finops</category>
      <category>observability</category>
      <category>python</category>
    </item>
    <item>
      <title>I Shipped a Redesign That Orphaned My Own Product</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:53:46 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/i-shipped-a-redesign-that-orphaned-my-own-product-5g74</link>
      <guid>https://dev.to/cloudwiseteam/i-shipped-a-redesign-that-orphaned-my-own-product-5g74</guid>
      <description>&lt;p&gt;This month I rebuilt CloudWise around an AI agent.&lt;/p&gt;

&lt;p&gt;Not a chatbot bolted into a corner. The agent &lt;strong&gt;is&lt;/strong&gt; the product now. You open the app and you're talking to it. "Where's my money going?" — and it pulls your AWS spend, ranks the waste, shows you the dollars per month per finding, and tells you what's safe to fix.&lt;/p&gt;

&lt;p&gt;It looked done. The screens were beautiful. The agent answered. I shipped it as the front door.&lt;/p&gt;

&lt;p&gt;Then I used it like a customer would. And I discovered I had quietly orphaned half my own product.&lt;/p&gt;

&lt;p&gt;This is the story of that month, because it taught me the single most useful lesson I've learned as a solo founder: &lt;strong&gt;a redesign isn't done when it looks done.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;For most of this year, CloudWise looked like every other AWS cost tool: a dashboard. Charts, tables, a sidebar of pages. Useful, but passive. You had to know what to look for.&lt;/p&gt;

&lt;p&gt;The bet I made for the overhaul was that the &lt;em&gt;interface&lt;/em&gt; should be a conversation, not a dashboard. Most people don't want to read a cost dashboard. They want to ask a question and get an answer.&lt;/p&gt;

&lt;p&gt;So the centerpiece became a conversational workspace backed by a real tool-calling agent loop on Claude (running on AWS Bedrock). "Real" is the important word. This isn't a model that summarizes a page of text. It's a model with tools — it actually queries your cost data, your findings, your Reserved Instance and Savings Plan coverage, and composes an answer from live numbers. It remembers your account between conversations.&lt;/p&gt;

&lt;p&gt;You can ask it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Where's my money going?" → it breaks down spend by service, in gold, with the month-over-month delta.&lt;/li&gt;
&lt;li&gt;"What's safe to fix?" → it ranks your waste findings: idle NAT Gateways, forgotten SageMaker notebooks, unattached EBS snapshots, each with a dollar figure and a safe-to-fix flag.&lt;/li&gt;
&lt;li&gt;"Compare my two accounts." → and it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than it sounds, and it's where the trouble started.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part where it looked finished
&lt;/h2&gt;

&lt;p&gt;By the start of June, the redesign was, on paper, complete. New design system. Dark, native. A conversational workspace. A guided tour. A redesigned dashboard. The agent loop. Cross-session memory. I'd migrated every screen.&lt;/p&gt;

&lt;p&gt;I flipped the new workspace to be the post-login front door and moved on.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth about building alone: &lt;strong&gt;you stop seeing your own product.&lt;/strong&gt; You navigate it the way the author navigates it — from the inside, knowing every shortcut, never actually starting cold the way a real user does.&lt;/p&gt;

&lt;p&gt;So I made myself start cold. I logged in like a brand-new customer and tried to do the boring things. Switch to my other AWS account. Open settings. Change a notification. Log out.&lt;/p&gt;

&lt;p&gt;I couldn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The front door had hidden the house
&lt;/h2&gt;

&lt;p&gt;None of those features were gone. Every settings page still existed. The account switcher still existed. Logout still existed. The old navigation still existed, sitting in the codebase, fully functional.&lt;/p&gt;

&lt;p&gt;They just weren't &lt;em&gt;reachable&lt;/em&gt; from the place users now landed.&lt;/p&gt;

&lt;p&gt;The new workspace shell had a clean little user footer — a static label with the account name. No menu. No logout. No link to subscription, notifications, alerts, API keys, AWS accounts, or password. From the new front door, there was literally no path to any of it.&lt;/p&gt;

&lt;p&gt;Worse, the half of the product that &lt;em&gt;did&lt;/em&gt; still have navigation rendered in the &lt;strong&gt;old&lt;/strong&gt; chrome. So a new user would land in a slick dark conversational workspace, click one thing, and get bounced into the previous design — a completely different layout. Two products wearing different clothes, stitched together at a seam the user falls straight through.&lt;/p&gt;

&lt;p&gt;I wrote it down plainly in my own audit doc at the time, because I needed to see it without flinching:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The redesign did not delete your features — the cutover orphaned them.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence reframed the entire month. This wasn't a teardown. It was a &lt;em&gt;finishing&lt;/em&gt; problem. The work wasn't to rebuild — it was to take ownership of every job the old interface used to do, and make the new one do it better.&lt;/p&gt;




&lt;h2&gt;
  
  
  The agent was also lying about multi-account
&lt;/h2&gt;

&lt;p&gt;While I was in there, I found a real bug — the kind that only surfaces when you use the product for real, with more than one account.&lt;/p&gt;

&lt;p&gt;CloudWise is a multi-account tool. You connect your AWS Organization and it discovers all your accounts. But the agent was collapsing them. When you asked about cost, it blended every account together with no way to scope to one. And when you asked about Reserved Instance and Savings Plan coverage, it reported coverage for &lt;strong&gt;exactly one account&lt;/strong&gt; — the first one it happened to grab — and silently ignored the rest.&lt;/p&gt;

&lt;p&gt;That's not a cosmetic miss. That's the tool confidently giving you a wrong answer about money. If you had three accounts and asked "how's my RI coverage," it answered for one-third of your footprint and didn't tell you.&lt;/p&gt;

&lt;p&gt;The backend could already filter by account. The old reports page could already do multi-account selection. The &lt;em&gt;agent&lt;/em&gt; — the new centerpiece — was strictly less capable than both. The redesign had, in this one spot, made the product worse while looking like it made it better.&lt;/p&gt;

&lt;p&gt;So the fix had three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A header account switcher&lt;/strong&gt; that scopes the entire workspace — single or multi-select, capped by your plan tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-account arguments on the agent's tools&lt;/strong&gt;, so the model can honor that switcher &lt;em&gt;and&lt;/em&gt; answer in-conversation requests like "compare my two accounts."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing the coverage bug&lt;/strong&gt; so it reports across all accounts, not the first one.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: commitments reported for whatever account came first.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_commitments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# silently ignores the rest
&lt;/span&gt;
&lt;span class="c1"&gt;# After: the agent scopes to what you asked for — one, some, or all.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_commitments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scope_ids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scope_ids&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;fetch_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small diff. A big difference in whether you can trust the answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thirty days of taking ownership
&lt;/h2&gt;

&lt;p&gt;That's what the month actually was. Not "add features." Re-own the basics, and make the agent the real front door instead of a beautiful demo sitting on top of a product it had stopped being responsible for.&lt;/p&gt;

&lt;p&gt;In order, here's what changed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One app shell, not two.&lt;/strong&gt; Unified chrome around every authenticated page — one persistent sidebar, the account switcher, every settings link, and logout — so you never fall through the seam between the new workspace and the old layout again. The workspace became one surface &lt;em&gt;inside&lt;/em&gt; a consistent app, not a separate world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The account switcher scopes everything.&lt;/strong&gt; Pick an account (or several) in the header and the whole workspace re-scopes to it — and the agent answers for exactly that selection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent learned to count accounts.&lt;/strong&gt; Per-account tool arguments, the coverage bug fixed, and the ability to genuinely compare accounts on request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A deep, cross-page guided tour.&lt;/strong&gt; Instead of a checklist nobody reads, a docked "CloudWise agent" companion walks a new user through the real product — navigating the actual screens across seventeen steps, not narrating a slideshow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole product went dark-mode-native.&lt;/strong&gt; Every deep page — cost reports, remediation, savings plans, settings, the dashboard — rebuilt on one design system. Money is always gold. No more half-migrated screens where one page is dark and the next is white.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A shareable cost-health score.&lt;/strong&gt; One number, 0–100, for how efficiently you're running AWS — with a public, sanitized share card carrying no account details or PII. Because the first question every engineer actually asks is "are we good, or not?"&lt;/p&gt;

&lt;p&gt;Across the month that came out to a few dozen shipped changes and around fifty releases — versions 1.54 through 1.104. Most of them were not glamorous. Most of them were re-owning a job the old interface used to do, quietly, that the new one had dropped on the floor.&lt;/p&gt;




&lt;h2&gt;
  
  
  The lesson, stated plainly
&lt;/h2&gt;

&lt;p&gt;I've shipped a lot of software in twenty-five years. I still fell for this one, which is why I think it's worth writing down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Looks done" and "is the product" are not the same state.&lt;/strong&gt; They can be a hundred releases apart.&lt;/p&gt;

&lt;p&gt;A redesign is not finished when the new screens are beautiful and the happy path works. It's finished when the new thing has quietly taken over &lt;em&gt;every&lt;/em&gt; job the old thing did — logout, account switching, the boring settings page nobody screenshots — and does each of them at least as well. Until then you don't have a new product. You have a beautiful front door on a house whose rooms you've locked.&lt;/p&gt;

&lt;p&gt;And the cruelest part: &lt;strong&gt;users don't grade you on the demo.&lt;/strong&gt; They grade you on the one ordinary day they need the single feature you forgot to bring across. The day they need to switch accounts, or check their RI coverage across all three, or just sign out. That's the moment your redesign is actually judged — and it's never the moment you rehearsed.&lt;/p&gt;

&lt;p&gt;So now I have a rule. Before I call any cutover done, I log in cold and do the boring things. All of them. If I can't sign out, I'm not done. No matter how good the front door looks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;CloudWise is an AWS cost optimization tool for startups — 191 automated waste checks, a real agent that runs against your actual usage, air-gapped mode for security teams, starting at $19/month. If you want to ask an agent where your AWS money is going, it's at &lt;a href="https://cloudcostwise.io" rel="noopener noreferrer"&gt;cloudcostwise.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>finops</category>
      <category>buildinpublic</category>
      <category>saas</category>
    </item>
    <item>
      <title>EBS Snapshot Sprawl: The Waste Cost Explorer Can't Show You</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:51:19 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/ebs-snapshot-sprawl-the-waste-cost-explorer-cant-show-you-5had</link>
      <guid>https://dev.to/cloudwiseteam/ebs-snapshot-sprawl-the-waste-cost-explorer-cant-show-you-5had</guid>
      <description>&lt;p&gt;Last week's short covered the 101 on old EBS snapshots: the 90-day threshold, the $0.05/GB-month rate, &lt;code&gt;aws ec2 delete-snapshot&lt;/code&gt;. If you saw it, you already know snapshots are cheap-per-unit and expensive-in-aggregate. What that 42 seconds couldn't fit in is the actual reason snapshot sprawl is so hard to find in the first place — and it isn't the price. It's that Cost Explorer structurally cannot show you who's responsible for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Explorer shows you a bill, not a culprit
&lt;/h2&gt;

&lt;p&gt;Group your AWS costs by usage type and EBS snapshots show up as one line: &lt;code&gt;EBS:SnapshotUsage&lt;/code&gt;, rolled up per account and region. That's it. Not per-snapshot, not per-volume, not per-AMI. If that line is $340/month, Cost Explorer will tell you the total and nothing about which of your 200 snapshots — or which of your 40 AMIs pinning them — put it there.&lt;/p&gt;

&lt;p&gt;Part of why this is so opaque is how the billing actually works. EBS snapshots are &lt;strong&gt;incremental&lt;/strong&gt;: the first snapshot of a volume captures every block, but every snapshot after that only stores blocks that changed since the previous one. Delete an "old" snapshot in the middle of a chain and AWS doesn't just drop its unique blocks — it can merge the still-referenced blocks from that snapshot into the next one to keep the chain valid. The result is a genuinely well-designed storage model that happens to make per-snapshot cost attribution close to meaningless from the outside. You can't look at snapshot #14 in a chain of 20 and know what deleting it actually frees, and Cost Explorer doesn't even try — it just gives you the account-wide sum and moves on.&lt;/p&gt;

&lt;p&gt;So the number you see going up every month is real. The reason is invisible from the billing console. You have to go look at the resources directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;deregistered ≠ deleted&lt;/code&gt; — the AMI-pinning trap
&lt;/h2&gt;

&lt;p&gt;Here's the gap that costs teams the most and gets found the least: deregistering an AMI does not delete the snapshot backing it.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;CreateImage&lt;/code&gt; against an EC2 instance, AWS creates an AMI and, silently, one or more EBS snapshots to back it — you'll find the linkage in the snapshot's own description field, something like &lt;code&gt;Created by CreateImage(i-0abc123def456789) for ami-0fedcba987654321&lt;/code&gt;. Deregister that AMI later (cleaning up an old release, retiring a pipeline, whatever the reason) and AWS removes the AMI. The snapshot stays. Forever. Nothing in the console flags it, nothing in Cost Explorer changes shape, and nothing tells you the thing that snapshot was created for no longer exists.&lt;/p&gt;

&lt;p&gt;This is exactly the pattern our &lt;code&gt;AMI_ORPHANED_SNAPSHOT&lt;/code&gt; detector checks for. It's not a guess — it's a direct read of the relationship AWS itself records: the detector regex-matches each snapshot's description against the &lt;code&gt;CreateImage(...)  for (ami-...)&lt;/code&gt; pattern, pulls the referenced AMI ID, and checks it against the account's currently-registered AMIs. If the AMI isn't there anymore, the snapshot is flagged — at higher priority than a generic "old snapshot" check, because an orphaned-AMI snapshot has a &lt;strong&gt;certain&lt;/strong&gt; reason to be dead, not just an age-based guess.&lt;/p&gt;

&lt;p&gt;That priority ordering matters. Our storage detector dedups three overlapping checks against the same snapshot inventory: AMI-orphan first, then volume-orphan (the source volume was deleted), then plain age (over the 90-day default threshold, at the $0.05/GB-month rate from last week's short). A snapshot only gets counted once, under whichever explanation is strongest — an AMI-orphaned snapshot isn't also reported as merely "old," because "old" undersells why it's actually safe to delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lifecycle policies are the actual fix — for snapshots you haven't made yet
&lt;/h2&gt;

&lt;p&gt;None of the above is a criticism of EBS snapshots as a backup mechanism. They're cheap, they're incremental, and they're the right tool. The problem is entirely operational: nothing deletes them automatically unless you tell it to.&lt;/p&gt;

&lt;p&gt;AWS Data Lifecycle Manager (DLM) exists for exactly this — attach a policy to a tag or resource type and it will create snapshots on a schedule and expire them on a schedule, so "backup taken 400 days ago for a server that's been gone for 399 of them" stops being possible going forward. If you're not running DLM policies today, that's the highest-leverage 20-minute fix here, full stop.&lt;/p&gt;

&lt;p&gt;But DLM only prevents new sprawl. It does nothing for the snapshots already sitting in your account from AMIs someone deregistered two years ago, or backups nobody automated before DLM was set up. That backlog needs to be found once, by hand or by a scan, before a policy can keep it clean going forward. That's the gap our detector is built for — not a replacement for lifecycle policies, the thing that finds what predates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding yours
&lt;/h2&gt;

&lt;p&gt;If you want to see this in your own account instead of grepping AMI descriptions by hand, &lt;a href="https://cloudcostwise.io?utm_source=blog&amp;amp;utm_medium=hub&amp;amp;utm_campaign=ebs-snapshot-sprawl-waste-cost-explorer-cant-show-you" rel="noopener noreferrer"&gt;a free scan&lt;/a&gt; checks this along with 190+ other waste patterns across 40+ AWS services — read-only, five minutes, nothing gets deleted without you clicking it. If you'd rather watch the 90-day/$0.05-per-GB basics first, we posted a 30-second walkthrough of that detector last week.&lt;/p&gt;

&lt;p&gt;Or check the AMI-pinning trap yourself right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Snapshots whose description names a CreateImage/AMI pairing&lt;/span&gt;
aws ec2 describe-snapshots &lt;span class="nt"&gt;--owner-ids&lt;/span&gt; self &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Snapshots[?contains(Description, 'CreateImage')].[SnapshotId,Description]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# Currently-registered AMI IDs&lt;/span&gt;
aws ec2 describe-images &lt;span class="nt"&gt;--owners&lt;/span&gt; self &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Images[].ImageId"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cross-reference the AMI ID in each snapshot's description against the second list. Anything missing has been quietly billing you since the day someone cleaned up an AMI and assumed the cleanup was done.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>finops</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>The Bug That Turned Every Bad Password Into a Server Outage</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:50:30 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/the-bug-that-turned-every-bad-password-into-a-server-outage-4dda</link>
      <guid>https://dev.to/cloudwiseteam/the-bug-that-turned-every-bad-password-into-a-server-outage-4dda</guid>
      <description>&lt;p&gt;Type your password wrong on CloudWise's login page, and for a while, the server told you it had a nervous breakdown.&lt;/p&gt;

&lt;p&gt;Not "invalid credentials." Not even a plain 401. An HTTP &lt;strong&gt;500&lt;/strong&gt; — the code reserved for "something on our end is broken" — for the most routine failure mode there is: a human mistyping a password.&lt;/p&gt;

&lt;p&gt;This happened on staging. It happened on production. It happened to &lt;code&gt;POST /api/v1/auth/login&lt;/code&gt; with wrong creds, an unknown email, an unconfirmed account, or an account mid-password-reset. Four different "you did something ordinary" situations, all reported back as "we did something wrong." It went undetected until an automated gate caught it and refused to let a release ship. This is that bug, the fix, and why the distinction matters more than it sounds like it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;_handle_cognito_auth_error&lt;/code&gt;, the function that translates AWS Cognito's auth exceptions into an HTTP response, raised a &lt;code&gt;CloudWiseException&lt;/code&gt; without ever setting a &lt;code&gt;status_code&lt;/code&gt;. No status code means the default. The default is 500.&lt;/p&gt;

&lt;p&gt;So every one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrong password&lt;/li&gt;
&lt;li&gt;Email that isn't registered&lt;/li&gt;
&lt;li&gt;Account that hasn't confirmed its email yet&lt;/li&gt;
&lt;li&gt;Account stuck in a forced password reset&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;came back as a server error. Reproduced live on both environments — for example, &lt;code&gt;POST /api/v1/auth/login&lt;/code&gt; with bad credentials on staging returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 500
{"detail":"Invalid email or password"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and on production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 500
{"detail":"Authentication failed: User does not exist."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that second one again. Production wasn't just returning the wrong status code — it was telling an anonymous caller whether a given email address had an account. That's a second, smaller bug riding along inside the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the status code is the actual bug
&lt;/h2&gt;

&lt;p&gt;It's tempting to shrug this off — the message was right there in the body, &lt;code&gt;"Invalid email or password"&lt;/code&gt;, so what's the harm in the wrong three-digit prefix?&lt;/p&gt;

&lt;p&gt;The harm is that the status code isn't decoration. It's the part of the response that infrastructure reads without understanding a word of the payload. A monitoring dashboard doesn't parse &lt;code&gt;detail&lt;/code&gt;. It counts 5xx rates. To CloudWise's own alerting, every single mistyped password looked identical to an actual outage — same bucket, same page-worthy signal, same "something is on fire" pattern, forever, at whatever rate normal users normally fat-finger their passwords. That's not a rare event. It's baseline noise, and the bug was quietly dressing it up as baseline crisis.&lt;/p&gt;

&lt;p&gt;It also meant every one of those routine rejections logged at &lt;code&gt;error&lt;/code&gt; level — the log level meant for things that need a human to look at them, now firing constantly for people who just needed to try again.&lt;/p&gt;

&lt;p&gt;And then it did something worse than annoy a dashboard: it blocked a production release.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it actually got caught
&lt;/h2&gt;

&lt;p&gt;CloudWise's release pipeline runs an E2E gate against staging before anything is allowed to promote to production. One of the negative-path assertions in that gate — &lt;code&gt;ci-gate-read.spec.ts:93&lt;/code&gt; — logs in with a wrong password and checks that the app shows an error and stays on &lt;code&gt;/auth/login&lt;/code&gt;, the way a real login form should behave.&lt;/p&gt;

&lt;p&gt;That test didn't expect a 500. Nothing in a normal auth flow should return one for a wrong password. The gate went red, and release &lt;code&gt;1.105.0&lt;/code&gt; sat there, unable to auto-promote to production, because the pipeline correctly refused to trust a build where the login form's error handling was behaving like a crash.&lt;/p&gt;

&lt;p&gt;That's the part worth sitting with: this bug was already live in production, unnoticed, for who knows how long. It took a &lt;em&gt;different&lt;/em&gt; release's gate run to surface it — not because the gate was looking for this specific bug, but because it was asserting the right general behavior (bad password → clean error, stay put) and the actual behavior didn't match. A negative-path test doesn't need to know about your bug in advance. It just needs to check that the ordinary failure case looks ordinary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix, in &lt;code&gt;backend/app/services/cognito_auth_service.py&lt;/code&gt;, maps Cognito's exceptions to the status codes they should have had all along:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NotAuthorizedException&lt;/code&gt; (wrong password) and &lt;code&gt;UserNotFoundException&lt;/code&gt; (unknown email) → &lt;strong&gt;401&lt;/strong&gt;, both returning the exact same message: &lt;code&gt;"Invalid email or password"&lt;/code&gt;. Same message for both cases on purpose — the response can no longer be used to tell whether an email is registered, closing that account-enumeration leak.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UserNotConfirmedException&lt;/code&gt; and &lt;code&gt;PasswordResetRequiredException&lt;/code&gt; → &lt;strong&gt;403&lt;/strong&gt; — the account exists, but the request is correctly rejected for a reason that isn't "try a different password."&lt;/li&gt;
&lt;li&gt;Anything else — a genuinely unexpected Cognito error — still defaults to 500. That default is correct in that case. An unrecognized failure mode &lt;em&gt;is&lt;/em&gt; a server-side concern worth alerting on. The bug was never that 500 existed; it was that the four most common, most expected auth rejections were routed into it by omission.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Expected-rejection logging dropped from &lt;code&gt;error&lt;/code&gt; to &lt;code&gt;warning&lt;/code&gt;, so the logs now reflect what actually happened: a routine, anticipated rejection, not an incident.&lt;/p&gt;

&lt;p&gt;The test suite got the fix that should have caught this the first time. &lt;code&gt;test_handle_cognito_auth_error_*&lt;/code&gt; previously asserted on the error &lt;em&gt;message&lt;/em&gt; only — never the status code. That's exactly how a 500-instead-of-401 slips through code review and CI both: the message text looked fine, so nobody noticed the number in front of it was wrong. The tests now assert status codes explicitly, plus two cases that weren't covered before: the no-leak &lt;code&gt;UserNotFoundException&lt;/code&gt; path, and &lt;code&gt;PasswordResetRequiredException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the sibling of an earlier fix, CLO-48, which cleaned up the same bug class on the refresh-token path — an expired refresh token was logging as &lt;code&gt;ERROR&lt;/code&gt; twice when a clean 401 was all that was warranted. That one didn't touch login. This one closes the login path CLO-48 didn't cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Status codes are a contract between your API and everything that reads it without understanding it — monitors, alerting rules, retry logic, browsers, other services. A wrong password is a client error. Treating it as a server error doesn't just mislabel one response; it teaches every downstream system that watches your 5xx rate to distrust the signal, right when you need that signal to mean something.&lt;/p&gt;

&lt;p&gt;The thing that actually caught this wasn't a code review, a manual QA pass, or a customer complaint. It was an automated negative-path assertion doing exactly what negative-path assertions are for: checking that the boring, expected failure looks boring and expected. It's staying in the gate. Nobody has to remember to test this again — the pipeline already refuses to ship a build that gets it wrong.&lt;/p&gt;

&lt;p&gt;If you want to see what else that kind of scrutiny turns up, &lt;a href="https://cloudcostwise.io?utm_source=blog&amp;amp;utm_medium=hub&amp;amp;utm_campaign=login-returned-500-bug-story" rel="noopener noreferrer"&gt;a free, read-only scan&lt;/a&gt; of your AWS account takes about five minutes and changes nothing without you approving it first.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>testing</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Inside a Real $11,871/mo AWS Waste Scan</title>
      <dc:creator>Rick Wise</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:50:30 +0000</pubDate>
      <link>https://dev.to/cloudwiseteam/inside-a-real-11871mo-aws-waste-scan-5536</link>
      <guid>https://dev.to/cloudwiseteam/inside-a-real-11871mo-aws-waste-scan-5536</guid>
      <description>&lt;p&gt;Most cost-optimization content shows you a screenshot of a dashboard and asks you to trust the number. So instead, here's an actual scan CloudWise runs internally as a demo profile — a mid-size SaaS company's AWS account, $28,500/mo in total spend, run through all 191 detectors. It came back with &lt;strong&gt;$11,871/mo in identified savings across 18 findings.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I want to walk through what's actually in that list, because the shape of it surprised me even after building the detectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 18 findings live
&lt;/h2&gt;

&lt;p&gt;Split by category, not dollar amount:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database — 9 findings.&lt;/strong&gt; By far the largest bucket. A mix of idle RDS instances, an oversized analytics database running at 12% CPU, stale manual snapshots, and a cluster of ElastiCache-specific findings (idle replicas, an engine migration opportunity, a serverless-fit opportunity, and one very large data-tiering opportunity — more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute — 2 findings.&lt;/strong&gt; An idle bastion host nobody SSHs into anymore, and one oversized EC2 fleet running at 15% CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage — 2 findings.&lt;/strong&gt; An unattached EBS volume left over from a migration, and a batch of aging EBS snapshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commitment Management — 2 findings.&lt;/strong&gt; A Savings Plan expiring in 52 days, and a Convertible Reserved Instance sitting on previous-generation hardware with a free exchange available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network — 2 findings.&lt;/strong&gt; Two idle Global Accelerators — one genuinely idle, one just disabled but still billing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purchase Optimization — 1 finding.&lt;/strong&gt; Production RDS instances that have been running on-demand, 24/7, for 90+ days with no Reserved Instance covering them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nine of eighteen findings sitting in "Database" isn't a coincidence — it's the category where the widest range of waste patterns overlap: idle instances, oversized instances, stale snapshots, and now a whole sub-family of ElastiCache-specific checks (engine choice, replication topology, traffic shape, data tiering) that didn't exist as separate detectors a year ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one number that matters most
&lt;/h2&gt;

&lt;p&gt;Of the $11,871/mo total, &lt;strong&gt;one single finding accounts for 68% of it: $8,057/mo&lt;/strong&gt;, from an ElastiCache data-tiering opportunity.&lt;/p&gt;

&lt;p&gt;The cluster in question runs 4× &lt;code&gt;cache.r6g.16xlarge&lt;/code&gt; nodes — memory-only, no local SSD — for 1,676 GiB of total cache capacity. ElastiCache's R6gd family adds local NVMe storage and &lt;em&gt;tiers&lt;/em&gt; data between RAM and SSD automatically, based on access frequency. For a dataset where most of the data isn't accessed on every request (true of almost every real cache), that means the same effective capacity fits on far less hardware: 1× &lt;code&gt;cache.r6gd.16xlarge&lt;/code&gt; instead of 4× &lt;code&gt;cache.r6g.16xlarge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's not a "you forgot to delete something" finding. It's an architecture-level rightsizing call that requires reading how your own cache is actually used before you touch it — which is exactly why it's flagged as &lt;code&gt;confidence: medium&lt;/code&gt;, not &lt;code&gt;high&lt;/code&gt;, and comes with an explicit risk note: SSD-resident data has slightly higher latency, so it's a good fit only when a meaningful chunk of your data is cold.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the next four biggest opportunities have in common
&lt;/h2&gt;

&lt;p&gt;Set aside the $8,057/mo ElastiCache finding and look at what else shows up near the top of this scan: an expiring Savings Plan, an unpurchased Reserved Instance opportunity on production RDS, an oversized analytics database at 12% CPU, and an oversized EC2 fleet at 15% CPU.&lt;/p&gt;

&lt;p&gt;None of those are "click delete." Every one of them is a standing decision that has to be made again, deliberately, on some recurring cadence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Savings Plan doesn't renew itself — someone has to decide, before it lapses, whether the workload underneath it still looks the same.&lt;/li&gt;
&lt;li&gt;An RDS instance rightsized today can be wrong again in six months if the workload grows, shrinks, or changes shape.&lt;/li&gt;
&lt;li&gt;"Buy the Reserved Instance" is itself a bet on the next 12 months looking like the last 3 — which is exactly the kind of call &lt;a href="https://dev.to/blog/aws-reserved-instance-commitment-risk"&gt;CloudWise's Commitment Risk Score&lt;/a&gt; exists to check before you make it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compare that to the &lt;em&gt;idle&lt;/em&gt;-resource findings in this same scan — the bastion host, the unattached EBS volume, the stale snapshots, the disabled accelerators. Those are real money too, and they're the easiest to fix: find it, delete it, done. But they're structurally small, because once you delete something, it stays deleted. It doesn't come back next quarter.&lt;/p&gt;

&lt;p&gt;Rightsizing and commitment decisions aren't like that. The right instance size and the right commitment level are correct &lt;em&gt;for right now&lt;/em&gt; — and they drift the moment your traffic pattern, team, or architecture changes, which for a growing company is constantly. That's the actual thesis worth taking from a scan like this: &lt;strong&gt;waste renews itself.&lt;/strong&gt; The idle-resource sweep is a one-time cleanup. The rightsizing and commitment layer is a standing job, and it's where most of the money in this particular scan actually lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for how you should read &lt;em&gt;your&lt;/em&gt; scan
&lt;/h2&gt;

&lt;p&gt;If you run a scan and the top finding is "delete this idle volume," fix it and move on — there's nothing recurring about it. But if your biggest findings look more like this account's — an oversized instance, an expiring commitment, a data-tiering opportunity that requires understanding your own traffic — treat that as a signal that the fix isn't a single action, it's a review cadence you don't currently have. That's a different kind of problem, and it's worth naming as one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;CloudWise is an AWS cost optimization tool for startups — 191 automated waste checks across 42 AWS services, read-only by design, starting at $19/month. Run a free scan at &lt;a href="https://cloudcostwise.io" rel="noopener noreferrer"&gt;cloudcostwise.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>finops</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
