What ran up the AWS bill, why nobody noticed for four days, and the specific guardrails that would have caught it. Written for Fede, answering the five questions he asked in #agent-smith.
2026-09-17 · AWS account 194925971150, us-east-1 · every figure read live from the Cost Explorer, Config, Budgets, SNS and CloudTrail APIs on 2026-09-17 · Sep 16 and Sep 17 figures are still settling and are marked *
A CI system started up on Sep 13 and accidentally switched on a very expensive audit log. AWS Config charges per change it records. Our CI creates and destroys cloud machines constantly, and Config was set to write a paid record for every single one of those events, forever. It cost $1,266.69 in September — 65% of everything the account used this month.
#agent-smith. Nothing automated carried the news — a person forwarded an email.Second pass by a separate session, asked by Fede to check Smith's and Trinity's work, review this RCA, and audit everything else still costing money or close to timing out. Every number below was read from AWS, GitHub and Slack on 2026-09-17 between 14:00 and 17:00 UTC. Sep 17 is not in Cost Explorer yet, so anything labelled "pace" is a projection from Sep 13–16 actuals.
| Claim in the thread | Verdict | Evidence |
|---|---|---|
| Four CI resource types excluded from Config; recorder healthy; nothing else lost | confirmed | Recorder shows the four exclusions, recording on, last status SUCCESS. It took three separate changes by gera-admin (daily on 3 types, daily on 4, then exclusion), not one. |
| Config stopped discovering new CI fleets after the fix | confirmed | Discovery cutoff 07:55 UTC; tracked fleet count flat since, while CI launched runners every minute. Sep 17 dollars will not show in Cost Explorer until Sep 18. |
Budget and anomaly alerts now reach #alerts | confirmed | Budget notifies propflow-alerts; topic policy allows Budgets and Cost Anomaly Detection to publish; forwarder Lambda subscribed, zero errors in 7 days; the real budget alert and the labelled test both landed. Actual window 07:58–08:08 UTC, an hour later than the thread said. |
| Every CloudWatch alarm has a live target | confirmed | All alarms publish to propflow-alerts. No orphan topics carry alarms any more. |
| Smith's daily cost check is "scheduled and running" | not proven | The Temporal schedule (agent-smith #587) ships paused by design and needs a worker restart plus an explicit unpause. No unpause, no run, no row in today's morning queue, nothing in #alerts. The guardrail for the next incident is not armed. |
| Cost-check dedupe fix merged | open | agent-smith #586 still open: without it one anomaly pages every day for 14 days. #588 (a Mac-mini cron for the same job) is also open and conflicts with the no-prod-crons-on-the-mini rule; leaving it unmerged is correct. |
| Spend page shows real dollars after the 10:00 UTC refresh | unverified | #9063 merged; the page needs an app login to check, out of this audit's reach. |
| "CI running on-demand instead of spot, ~$550/mo" | confirmed, low | c7g.2xlarge on-demand hours alone would save ~$636/mo at the spot rate (estimate). |
| "Projected ~$2,000/month run rate" | understated | Sep 13–16 actuals put the pace at ~$3,150–3,200/mo (section 2). |
One scary-looking finding that is false. CloudTrail shows 28 code updates to the Slack-forwarder Lambda overnight under the fede-admin key from cloud hosts. Those are GitHub-hosted CI runners running the deploy workflow on every merge, which redeploys every Lambda whether or not it changed. Not a hand edit. It is wasteful (a dozen redeploys per merge) but it is the sanctioned CI/CD path.
| Line | Sep 13–16 avg / day | Monthly pace | State |
|---|---|---|---|
| EC2 compute — CI runners | $60.82 | $1,849 | Not fixed. Section 3a. |
| EC2 other — runner disks, public IPv4 | $4.66 | $142 | Follows the runner count. |
| DynamoDB | $12.51 | $380 | Elevated (+$200 vs Aug). Root-caused; fix PR open. Section 3b. |
| Lambda | $9.19 | $279 | Elevated (+$246 vs Aug). Root-caused; needs a decision. Section 3c. |
| AWS Config (baseline, CI excluded) | ~$3.30 | ~$100 → ~$30 | changed 17:20 UTC Fede: "keep the smallest set that is critically required". Recorder now records 12 types only (IAM roles/policies/users/groups, secrets, Lambdas, tables, queues, topics, buckets, KMS keys, schedules). Half the old baseline was every merge redeploying all 12 Lambdas unchanged; a deploy-workflow fix to skip unchanged Lambdas is the follow-up. |
| Security Hub | $0 | $0 | disabled 17:20 UTC Nobody read its findings and its 30-day free trial (from Aug 15) had just ended. Re-enable when a customer security review asks for it. |
| Everything else (CloudWatch, ECS, GuardDuty, VPC, Secrets, KMS, SQS, S3) | ~$10 | ~$304 | Flat with August. |
The RunsOn design (2026-09-13) priced the fleet at ~$300/mo on spot. Compared with what happened:
| Assumption | Actual | Verdict |
|---|---|---|
| ~$300/mo compute | $61/day → ~$1,850/mo | missed 6× |
| Spot ≈ 40–46% of on-demand price | 52% | close |
| Fleet stays on spot | 56–71% of dollars on-demand; live snapshots 31 on-demand vs 21 spot, later 4 vs 9 | missed |
| Checks job ~82 s | median 77 s, p90 110 s (n=798) | held |
| Affected-tests job 31–66 s | median 110 s, p90 167 s | 2–3× slower |
| Full-suite shards 89–133 s | median 109–142 s, p90 up to 150 s | 1.2–1.5× slower |
| Queue wait ~76 s | median 23–26 s | better |
| Dry run: 11/12 shards green, zero reclaims | Prod: 89.7% success, 4.3% fail, 6.0% cancelled; fleet launches failing on the account vCPU quota | missed |
| Runs/day unchanged by the move | ~1,874 before, ~1,877 after | held |
| AWS Config / Security Hub side cost | Never considered. $1,530 in five days. | blind spot |
Why so much on-demand. Not the circuit breaker: its circuit_breaker_active flag was false in all 49 interruption events sampled, and only 1 of 570 spot requests failed for capacity. The wall is the account quota: 256 vCPU for spot and 256 for on-demand, each worth 32 of these 8-core runners. At peak (34 runners launched in one 30-second window) the spot pool is full, RunsOn falls over to on-demand, and on-demand then hits its own cap (VcpuLimitExceeded in the RunsOn logs). Volume is the other half: ~1,877 runs/day × ~14 jobs/run, and 53% of runs are triggered by review events (deliberate, see the workflow header, but it is the single biggest volume lever).
Levers, all estimates: raise the spot vCPU quota to 512 or more (free, AWS form) ~$500–700/mo; broaden the instance families beyond c7g so spot has more pools ~$50–150/mo; fewer shards per run (14 jobs today) ~$200–300/mo; stop rerunning the full lane on review events, or run only the cheap checks then, up to half the volume; re-add the S3 build cache once per-ref isolation is verified ~$20–40/mo. Even with all of them, at today's CI volume the fleet lands above $300/mo. The plan underpriced the volume, not the machine.
The household shell sweep inside the AppFolio sync calls listHouseholds(organizationId), an org-wide read on entityType-createdAt-index, once per property per tick, then filters to one property in memory. Western Slope's portfolio went from 2–15 in-scope properties to 71 around Sep 12, so the same query now runs ~44× per tick per account, every 1–5 minutes, around the clock. That is the flat ~640k reads/hour. The phone index climbs with it (one lookup per candidate household). Reads on that index Sep 3–16: 69.5M. Fix: read the org's households once per run and hand the list to each property. Dark PR propflowai #9104 merged 15:51 UTC. It did not reach the Lambda until 16:29 UTC: the deploy workflow builds whichever commit's main CI just finished, and that commit's CI went red on a test-runner timeout (all 7,761 tests passed), so two later deploys shipped older code. Rerunning the failed job fixed it. Expected effect: ~44 org-wide queries per tick → 1, roughly $150–200/mo. Before → after (household index, reads per 5 minutes): 43k–52k before the 16:29 UTC deploy → 18k in the first window, then 4k–11k. Roughly 85–90% fewer reads on that index, from ~600k/hour to ~80k/hour.
Found while taking over Smith and Gera's bridge question. Table writes went from ~0.5M units/day (Sep 12) to ~3.35M/day (Sep 13 onward). Read one tick apart in prod: about 64 prospect rows fleet-wide are rewritten every minute with only updatedAt and version changing (version counters 5,000–69,000; Camellia 23 of 25 rows, Western Slope 26 of 153). Each rewrite replicates to 4 indexes.
First guess was wrong. I attributed it to the household step re-saving members (PR #9122, merged and live 17:25 UTC). Its counters show it was not the writer; the guard is a valid cleanup, not the fix. The rows kept moving.
Confirmed cause (rental-application writer, ~39 rewrites/min): for approved or lease-signed rows whose stored approvedAt is missing, the writer sets it to "now" every tick, its change check compares it, and the DynamoDB projection allowlist never saves it. Rewritten every minute, never self-heals. Fix: persist approvedAt through the projection (dark PR, one concern).
Guest-card writer (~25 rewrites/min): its change check also fails on rows that look identical, but the failing field cannot be named from the stored data. Next deploy adds a churnFields= field-name count to the per-property summary line so production names it, then the fix follows. The guest-card fingerprint bug from Gera's session is real but small: two AppFolio guest cards mapped onto one prospect on ~1 in 10 churning rows; separate PR after.
Update 18:45 UTC — approvedAt fix live (PR #9134, deployed 18:22 UTC): rental-application rewrites at Camellia went from 28 per minute to 16–17; one row that had been rewritten 6,000 times stopped. The field-name counter (PR #9136) then named the rest, fleet-wide over 10 minutes: source flipped by both writers (52 + 49 times), af.coApplications and leadFacts judged different because of key order (24 + 9), af.unitId written by both writers (4 per tick per property), and a two-guest-cards-on-one-prospect case (17). Fixes in flight: structural compare for the order-only fields (auto-merge); "once an application is linked the guest card cedes source and unit to it" (a change in which lead source wins, waiting on Fede's yes).
Counting DynamoDB requests by operation (not by write units) showed the prospect loop was never the majority. Per 5 minutes the table takes about 2,500 creates, 1,300 updates and 2,000 deletes. Nothing in the product deletes 400 rows a minute. The deletes are the sync job lock added on Sep 10 (PR #7582) to stop two overlapping sync runs from creating duplicate prospects: before each job on each property the Lambda writes a lock row, and after the job it deletes it. That is one create plus one delete per property, per job, per minute, and the 1-minute schedule runs three jobs plus the household step: 87 properties × 4 × 2 ≈ 700 writes a minute, all pure overhead. It became visible on Sep 13 at 14:00 UTC, the hour Western Slope's 87 properties came online (deletes went from ~300/hour to 21,000/hour and stayed there).
Where the write units go now, per 5 minutes: table ~7,700 (locks ~4,200 of it, 55%); indexes ~7,100, driven mostly by the prospect rewrites because each rewrite is copied into 4 indexes. The lock rows also feed the table's change stream into two Lambdas (stage replicator, org drift detector) 40,000 times an hour, and the replicator logs an error for every lock row it does not recognise: ~560,000 error lines a day.
Decisions (engineering, no customer effect): one lock per property per tick instead of one per job and one for the household step (same protection, 4× fewer lock writes; dark PR); the replicator learns to skip lock rows quietly (one-line PR). Left for the replicator's owner: HOUSEHOLD rows are also unrecognised by the replicator today (~14 per minute), so households are not reaching the stage table — flagged on the bridge.
Result, 19:49 UTC — all four fixes live. The lock change (PR #9146) reached the Lambda at 19:38 UTC; the writer fixes (#9144 lead-source/unit ownership with Fede's yes, #9145 structural compare) at 19:17 UTC; the replicator quiet-skip (#9142) at 18:59 UTC. Per 5 minutes, before (18:39–19:14 UTC) → after (19:39–19:49 UTC):
| Measure (per 5 min) | Before | After |
|---|---|---|
| Table deletes (lock releases) | ~1,970 | ~670 |
| Table creates | ~2,450 | ~1,050 |
| Table write units | ~7,300 | ~3,500 |
| Phone / spine index write units (each) | ~1,520 | ~540 |
| Stream Lambda invocations (each of two) | ~1,590 | ~930 |
| Prospect rewrites per 10 min (guest card + application) | 404 | 130 |
Projected: table plus index write units ~3.8M/day → ~1.9M/day. What remains is named by the counter: two AppFolio guest cards resolving to one prospect (~44 rewrites per 10 min, issue #9150 with a proposed rule; it is a prospect-identity decision), and small tails on lead facts, inquiry id and application-received timestamps. Locks still cost one create and one delete per property per tick; the next step down, if ever wanted, is one lock per account per tick.
PR #7723 (Sep 10) merged the per-connection sync workers into one worker per AppFolio account, which walks every property in series with a 1-second pacing floor to protect AppFolio's rate limit. Western Slope's 71 properties now take 40–85 s per run instead of 8 s; the slowest runs hit 650–750 s against a 900 s timeout. Zero errors, zero throttles, just long. Correction after Fede asked "wasn't it supposed to be per org": the fetch is per account (one request per report per account since #7723), but the 1-second pacing sleep from the older per-property design (#7457) was left in and still fires once per property, including the ~65 properties that make no AppFolio call at all. That is ~70 s of pure waiting per Western Slope run, ~3,200 runs a day. Fix: sleep only when a property actually makes a live AppFolio request. Dark PR #9114 (152 lines, red-before/green-after test, new spacingSleeps= log field), auto-merge armed. Rate-limit protection is unchanged for the properties that still call out. No concurrency change needed.
propflow-stage-replicator-prod went from ~45–120k invocations/day to 330–650k/day on Sep 13, with 95–239 errors/day on Sep 11–13, and now writes ~1.15 GB of logs a day into a log group that never expires. Some invocations hang to the full 900 s timeout while the p95 is 7.5 s. Not root-caused in this audit; it tracks prod write volume plus the DynamoDB churn above. Needs its own look (Gera's lane).
| Finding | Number | Fix |
|---|---|---|
outlook-subscription-renewer-prod is failing | 12.9% error rate; p95 59 s of a 60 s timeout | Real failure, not noise: a customer's Outlook mail/calendar subscriptions can lapse. Investigate and raise the timeout. |
| Inbound SMS queue backlog | Oldest message 47.5 min on Sep 14; one message hit the dead-letter queue; visibility timeout 1.2× the consumer's timeout | Raise visibility timeout to ~1800 s (AWS recommends 6×); find the Sep 14 dead-lettered message; check consumer concurrency. |
| Agent-jobs queues undersized the same way | Visibility 1.05× and 1.25× the consumer timeout | Raise to ~3600 s. Free; prevents duplicate delivery on slow jobs. |
metric-snapshot-prod-real hits Lambda's hard ceiling | Max 900 s of 900 s; p95 866 s; 10% errors on the worst day; uses 25% of 4 GB | Cannot get a longer timeout. Split the work or move it off Lambda. |
DynamoDB spine-index throttling | 1,075 read-throttle events Sep 9–15 (761 on Sep 11) on an on-demand table | A single hot partition on the index; retries inflate reads. Look at the Sep 11 caller. |
| Prod table has deletion protection off | 12 of 14 tables, including propflow-prod (1.39M items) | One flag. Turn it on for every prod table. |
| Log groups never expire | 26 of 46 groups, 4.4 GB, three of them up 10–20× since Sep 12 | Blanket 30–90 day retention. Also check what turned logging up 10× on Sep 12–13. |
| Audit buckets grow forever | CloudTrail +300 MB/day, Config +250 MB/day, no lifecycle | Lifecycle: cold storage at 90 days, expire at a year or two. |
| Two Lambdas at the memory ceiling | mailbox poller 97%, metrics-alerts checker 94% of 256 MB | Raise to 512 MB (cents). |
| Willows sync tail never catches up | Property appfolio-45: 400 of 467 rows per tick, 67 rows only re-synced if AppFolio touches them | Not a cost item. A data-completeness gap the sync itself logs as "escalate to engineering". |
Clean: no Lambda throttles in 14 days, no table scans in prod, every EventBridge schedule and every dead-letter queue empty, no NAT gateways or databases to tune, no Lambda timeouts on the customer voice path.
propflow-alerts → #alerts. Still missing: an account-wide daily budget (all three budgets are tag-filtered to CI, so the Config spend was invisible to them).What was good. The mechanism was found within 15 minutes and explained plainly. The credits-hide-spend trap and the $0 spend page were real catches. The topic-policy gap (repointing the budget would have silently delivered nothing) was caught before it bit. Smith checked for a newer decision before restoring his own overwritten change. Both bots said what they were not claiming.
What to fix next time.
rca-2026-09-16-aws-config-runs-on-spend), written by two sessions that did not see each other. One document per initiative. Both were also left describing a state that had already changed.All are AWS production settings, so each needs a yes. None touches a customer-facing behaviour.
#alerts. done 16:30 UTC Budget account-daily-guardrail: $60/day, usage not net of credits, no tag filter, alerts at 100% to propflow-alerts. AWS already shows it in ALARM ($523 counted against today), so the first message to #alerts should arrive on the next Budgets evaluation cycle.But the instinct was not wrong. Lambda did have its own genuine cost anomaly, and it is the most uncomfortable finding in this document: AWS detected it on Sep 10 — three days before the Config blow-up — and it was never sent to anyone. Lambda ran 548% over its expected cost, $59.81 actual against $9.23 expected, an impact of $50.58. It sat below the alerting bar and was silently dropped. The question asked about Lambda was a question AWS had already answered internally and kept to itself.
What drove it was run time and memory, not traffic: the charge is almost entirely GB-seconds ($54.34 on ARM, $10.91 on x86), with request charges under $1. Functions got slower and stayed busy longer; they were not called more often.
| Day | Lambda usage | relative | note |
|---|---|---|---|
| Sep 5–9 (each) | ~$1.20 | normal | |
| Sep 10 | $3.56 | AWS opens anomaly 838d2d9c | |
| Sep 11 | $8.88 | ||
| Sep 12 | $17.97 | peak | |
| Sep 13 | $6.85 | ||
| Sep 14 | $14.85 | ||
| Sep 15 | $7.70 | ||
| Sep 16* | $4.72 | *incomplete |
Three functions account for the extra run time. This is duration attribution from CloudWatch, not per-function billing — AWS does not bill per function in Cost Explorer, so treat this as "who got busier", not "who spent what":
| Function | Memory | Arch | Sep 5–9, per day | Sep 10–16, per day | Change |
|---|---|---|---|---|---|
propflow-appfolio-sync-prod | 2048 MB | arm64 | 83.6M ms | 268.9M ms | 3.2× |
propflow-stage-replicator-prod | 512 MB | x86_64 | 46.8M ms | 238.9M ms | 5.1× |
propflow-org-drift-detector-prod | 256 MB | arm64 | 3.4M ms | 13.8M ms | 4.1× |
The memory and architecture line up with the billed usage types: appfolio-sync (2048 MB, arm64) explains the ARM GB-second charge, stage-replicator (512 MB, x86_64) explains the x86 one. Those two dominate. Why they got slower is a separate open question and is not answered here.
AWS Config is $1,266.69 of $1,935.80 — 65.4% of September. It is the single largest line by a factor of six.
| Service | Sep 1–17 usage | Share | |
|---|---|---|---|
| AWS Config | $1,266.69 | 65.4% | |
| EC2 — Compute | $214.21 | 11.1% | |
| AWS Security Hub | $188.76 | 9.8% | |
| DynamoDB | $100.84 | 5.2% | |
| AWS Lambda | $78.00 | 4.0% | |
| ECS | $23.09 | 1.2% | |
| EC2 — Other | $17.26 | 0.9% | |
| Total | $1,935.80 | 100% |
Think of Config as a security camera pointed at the cloud account. Every time anything is created, changed or deleted, it writes a permanent record of what the thing looked like — and AWS charges per record written.
Two settings combined badly:
ALL_SUPPORTED_RESOURCE_TYPES) — no filter on what is worth photographing.On 2026-09-13 at 02:51:14Z the runs-on CloudFormation stack finished creating (verified via describe-stacks, state CREATE_COMPLETE). That stack runs our CI on spot instances: machines are launched to run a build and destroyed minutes later, all day. Each machine is not one record — it is a machine, a network interface, a disk volume and a fleet object, each written on creation, on every change, and on deletion. Config started photographing a machine that never stops moving.
| Day | AWS Config usage | relative | |
|---|---|---|---|
| Sep 5–12 (each) | $1.53 – $5.22 | baseline | |
| Sep 13 | $337.69 | CI stack live 02:51Z | |
| Sep 14 | $355.52 | ||
| Sep 15 | $377.53 | peak | |
| Sep 16* | $156.60 | *still settling | |
| Sep 17* | $0.00 | *not yet reported |
AWS's own anomaly detector put the Config event at 8,200% over expected: $1,070.75 actual against $12.90 expected across Sep 13–15, on the usage type ConfigurationItemRecorded — literally "we wrote a record" — contributing $1,062.03 of the impact. That is anomaly cb999913.
Security Hub bills per security check it runs, and it runs checks in response to Config records. So the record flood produced a check flood, one day behind it. It had been costing $0.00/day through Sep 13, then:
| Day | Security Hub | relative |
|---|---|---|
| Sep 5–13 (each) | $0.00 | |
| Sep 14 | $80.18 | |
| Sep 15 | $79.05 | |
| Sep 16* | $29.53 |
This matters for the fix, not just the accounting. $188.76 of the damage was collateral — a second service reacting to the first. It means turning the Config recording down does not only save Config money, and it means any future Config change has a cost consequence one service over that will not show up under the word "Config". AWS raised its own anomaly for it (65e73754, $29.53 against $10.12 expected, 191.8% over). Nobody was told.
The honest answer: AWS did detect it. Our side had nothing listening. Every automated guardrail that should have carried this news either did not exist, was pointed at the wrong place, or was set to a bar the event never cleared.
runs-on CI stack finishes creating. CI machines begin churning.#agent-smith by hand. This is the moment the team finds out. It was a person, not a system.PutConfigurationRecorder, gera-admin) — 15 minutes after the message. Smith reported it in Slack at ~05:20Z, 29 minutes later.Four days from the first dollar to the team seeing it — and the only thing that closed the gap was a human reading his own inbox. The response, once anyone knew, took fifteen minutes.
The account has exactly one cost-anomaly subscription, Default-Services-Subscription, and exactly one subscriber: fedechagu@gmail.com, type EMAIL. No SNS topic, no bot, no Slack, no second person. If that inbox is not read, or the person reading it is on a plane, AWS's warning reaches nobody. The path from AWS into this company is currently one man forwarding an email.
Frequency: DAILY. On its own that adds up to a full day of lag before the first notification is even composed.
The subscription only forwards an anomaly whose impact is ≥ $100 and ≥ 40% over expected. Both conditions, not either. That is why a mailbox with one email in it can sit on top of a much busier situation.
There are three budgets on the account in total, and all three are breached and all three are tag-filtered. None of them watches the account as a whole:
| Budget | Period | Limit | Actual | Filter | Who it notifies |
|---|---|---|---|---|---|
runs-on-app-daily-budget | daily | $5.00 | $72.107 | user:stack$runs-on | SNS topic with 0 subscribers |
runs-on-ci | monthly | $150.00 | $155.369 | user:stack$runs-on | email: fede@propflowai.co |
runs-on-ci-monthly | monthly | $150.00 | $245.699 | user:Purpose$ci | email: fede@propflowai.co |
Account-wide budget with no tag filter: none exists. Read live from budgets describe-budgets on 2026-09-17. | |||||
That is the literal answer to the instruction, and it is the gap being closed first. The cheap part is that the delivery pipe already exists and already works: the SNS topic propflow-alerts feeds a Lambda called propflow-sns-slack-forwarder-prod which posts into Slack. Anything pointed at that topic reaches Slack today. The runs-on budget was simply pointed at a different, orphaned topic.
| Count | Resource type | Share of all resources | Covered by the Sep 17 fix? |
|---|---|---|---|
| 35,153 | AWS::EC2::EC2Fleet | 57.2% | NO — still CONTINUOUS |
| 12,942 | AWS::Config::ResourceCompliance | 21.1% | no |
| 5,021 | AWS::EC2::NetworkInterface | 8.2% | yes |
| 5,016 | AWS::EC2::Volume | 8.2% | yes |
| 2,654 | AWS::EC2::Instance | 4.3% | yes |
We have deliberately not closed this ourselves. Live changes to the Config recorder are infrastructure with real blast radius, and the standing rule is that we do not apply them unilaterally. The exact command is in section (c), item 4, and it is waiting on Fede's explicit yes. Until then, treat the EC2Fleet gap as open.
A pull request is being opened in agent-smith right now, on branch smith/cost-anomaly-check (agent-smith#583, merged b1ff93a4 on 2026-09-17), that gives Smith the thing it has never had: a daily job that calls ce get-anomalies, and posts anything AWS has flagged straight into #alerts. (Note these are two different rooms: this thread ran in #agent-smith — C0BDW7G1Z62 — while the watcher posts to #alerts, C0BGTNSP04C.)
It does not depend on anyone's threshold, anyone's digest frequency, or anyone's inbox. It reads everything AWS detected, including the fourteen anomalies that were silently dropped this month. It would have posted the Lambda anomaly into #alerts on Sep 10 — seven days before anyone found out about any of this.
Each of these is a one-command change to live AWS infrastructure. They are written out in full so a human can read exactly what would happen and run it, rather than approving a description of it.
The runs-on-app-daily-budget alarm is already firing; it is just wired to an orphaned topic. Repointing it at propflow-alerts puts it into Slack with no new infrastructure.
Do the topic policy first. propflow-alerts currently carries only AWS's default access policy, which grants publish rights to principals in this account — AWS services like Budgets and Cost Anomaly Detection are not covered by it. Skip this step and the alarm fails silently in exactly the same shape as today's bug.
# 1a. let the AWS billing services publish to the topic
aws sns set-topic-attributes \
--topic-arn arn:aws:sns:us-east-1:194925971150:propflow-alerts \
--attribute-name Policy \
--attribute-value '{"Version":"2008-10-17","Id":"__default_policy_ID","Statement":[
{"Sid":"__default_statement_ID","Effect":"Allow","Principal":{"AWS":"*"},
"Action":["SNS:GetTopicAttributes","SNS:SetTopicAttributes","SNS:AddPermission",
"SNS:RemovePermission","SNS:DeleteTopic","SNS:Subscribe",
"SNS:ListSubscriptionsByTopic","SNS:Publish"],
"Resource":"arn:aws:sns:us-east-1:194925971150:propflow-alerts",
"Condition":{"StringEquals":{"AWS:SourceOwner":"194925971150"}}},
{"Sid":"AllowBudgets","Effect":"Allow",
"Principal":{"Service":"budgets.amazonaws.com"},"Action":"SNS:Publish",
"Resource":"arn:aws:sns:us-east-1:194925971150:propflow-alerts"},
{"Sid":"AllowCostAnomalyDetection","Effect":"Allow",
"Principal":{"Service":"costalerts.amazonaws.com"},"Action":"SNS:Publish",
"Resource":"arn:aws:sns:us-east-1:194925971150:propflow-alerts"}]}'
# 1b. move the budget's subscriber off the orphaned topic
aws budgets update-subscriber \
--account-id 194925971150 \
--budget-name runs-on-app-daily-budget \
--notification NotificationType=ACTUAL,ComparisonOperator=GREATER_THAN,Threshold=100 \
--old-subscriber SubscriptionType=SNS,Address=arn:aws:sns:us-east-1:194925971150:runs-on-AlertTopic-ZGRxpFzLf1Xy \
--new-subscriber SubscriptionType=SNS,Address=arn:aws:sns:us-east-1:194925971150:propflow-alerts
# 1c. prove it — this must NOT come back empty
aws sns list-subscriptions-by-topic \
--topic-arn arn:aws:sns:us-east-1:194925971150:propflow-alerts
All three existing budgets filter on a tag, which is why the $1,266 of Config spend was invisible to every one of them. A budget with no filter sees everything. July's usage was $578.48 across the month — roughly $19/day — so a $60/day line is about three times normal and would have fired on Sep 13, when Config alone spent $337.69.
aws budgets create-budget --account-id 194925971150 \
--budget '{"BudgetName":"account-daily-guardrail",
"BudgetLimit":{"Amount":"60","Unit":"USD"},
"TimeUnit":"DAILY","BudgetType":"COST"}' \
--notifications-with-subscribers '[{
"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN",
"Threshold":100,"ThresholdType":"PERCENTAGE"},
"Subscribers":[{"SubscriptionType":"SNS",
"Address":"arn:aws:sns:us-east-1:194925971150:propflow-alerts"}]}]'
Requires step 1a (the topic policy) to have been run first.
This adds a second subscription and leaves Fede's existing email one completely untouched, because AWS only accepts Frequency=IMMEDIATE when the subscriber is an SNS topic — an email subscriber is limited to DAILY or WEEKLY. Dropping the bar to $25 with no percentage clause would have delivered the Lambda anomaly ($50.58) on Sep 10 — seven days before anyone found out. $25 is a deliberate choice, not a floor: it is low enough to catch a real problem early and high enough that the sub-$5 noise in the appendix table stays out of Slack.
aws ce create-anomaly-subscription --anomaly-subscription '{
"SubscriptionName":"propflow-alerts-immediate",
"MonitorArnList":["arn:aws:ce::194925971150:anomalymonitor/3aedab90-655d-4a60-9d9f-84a3a9d46e30"],
"Subscribers":[{"Type":"SNS",
"Address":"arn:aws:sns:us-east-1:194925971150:propflow-alerts",
"Status":"CONFIRMED"}],
"Frequency":"IMMEDIATE",
"ThresholdExpression":{"Dimensions":{
"Key":"ANOMALY_TOTAL_IMPACT_ABSOLUTE","Values":["25.0"],
"MatchOptions":["GREATER_THAN_OR_EQUAL"]}}}'
Optionally, to lower the bar on the existing email subscription as well — this replaces the "$100 AND 40%" pair with a single $25 test:
aws ce update-anomaly-subscription \
--subscription-arn arn:aws:ce::194925971150:anomalysubscription/ebe2a2a4-0c21-452e-991d-66be22bb1117 \
--threshold-expression '{"Dimensions":{
"Key":"ANOMALY_TOTAL_IMPACT_ABSOLUTE","Values":["25.0"],
"MatchOptions":["GREATER_THAN_OR_EQUAL"]}}'
This is the material residual from section (a): 35,153 EC2Fleet objects, 57% of everything in the account, still recorded on every change. We have not run this and will not run it without an explicit yes — live Config recorder changes are infrastructure with real blast radius, and that is the standing rule.
⚠ Read this before running it. put-configuration-recorder replaces the entire recorder object, it does not merge. The JSON below deliberately carries the existing three types plus EC2Fleet, so it extends the Sep 17 fix. A version of this command listing only AWS::EC2::EC2Fleet would silently undo that fix and put Instance, NetworkInterface and Volume back on CONTINUOUS.
aws configservice put-configuration-recorder --configuration-recorder '{
"name":"default",
"roleARN":"arn:aws:iam::194925971150:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig",
"recordingGroup":{"allSupported":true,"includeGlobalResourceTypes":true,
"resourceTypes":[],"exclusionByResourceTypes":{"resourceTypes":[]},
"recordingStrategy":{"useOnly":"ALL_SUPPORTED_RESOURCE_TYPES"}},
"recordingMode":{"recordingFrequency":"CONTINUOUS","recordingModeOverrides":[{
"description":"smith-cost-fix-20260916 + EC2Fleet: CI spot-fleet churn drove Config to $1070.75/3d (anomaly cb999913). Daily snapshot suffices for ephemeral CI compute.",
"resourceTypes":["AWS::EC2::Instance","AWS::EC2::NetworkInterface",
"AWS::EC2::Volume","AWS::EC2::EC2Fleet"],
"recordingFrequency":"DAILY"}]},
"recordingScope":"PAID"}'
# prove it landed and the recorder is still healthy
aws configservice describe-configuration-recorders
aws configservice describe-configuration-recorder-status
What success looks like: Sep 17's Config figure back near the $1.53–$5.22/day baseline. What partial success looks like: a large drop that still settles well above baseline — that would be the EC2Fleet gap, and it would make recommendation (c)(4) urgent rather than optional. Security Hub should be checked at the same time, since its charges follow Config's by roughly a day.
Read from ce get-anomalies on 2026-09-17. The subscription forwards only anomalies at ≥ $100 AND ≥ 40% over expected. Exactly two of these sixteen clear that bar — AWS Config and EC2 Compute — and those are exactly the two services in the email Fede received. The threshold accounts for the delivered set precisely; there is no residual to explain.
The other fourteen all fall below the $100 floor. The largest of them is the Lambda anomaly from Sep 10 that Fede later had to ask about by hand, at $50.58 — barely half the bar, and detected a full week before anyone in the company knew any of this was happening.
| ID | Service | Window | Impact (actual − expected) | Over expected | Clears ≥$100 AND ≥40%? |
|---|---|---|---|---|---|
cb999913 | AWS Config | Sep 13 – 15 | $1,057.85 | 8,200.4% | YES — delivered |
f33147a4 | EC2 — Compute | Sep 13 – 15 | $200.37 | n/a — expected $0.00 | YES — delivered |
838d2d9c | AWS Lambda | Sep 10 – 15 | $50.58 | 548.0% | no — under $100 |
65e73754 | AWS Security Hub | Sep 16 | $19.41 | 191.8% | no — under $100 |
8517c997 | Elastic Block Store | Sep 13 – 15 | $15.80 | n/a — expected $0.00 | no — under $100 |
63f6ab01 | GuardDuty | Sep 11 – 15 | $5.07 | 539.4% | no — under $100 |
26942799 | Virtual Private Cloud | Sep 13 – 15 | $4.85 | 898.1% | no — under $100 |
a5cb95f5 | CloudWatch | Sep 14 – 15 | $3.73 | 321.6% | no — under $100 |
cf464a13 | Elastic Container Service | Sep 2 – 3 | $1.94 | 156.4% | no — under $100 |
4d0b594c | Virtual Private Cloud | Sep 2 – 7 | $1.07 | 187.7% | no — under $100 |
b9279c69 | Simple Storage Service | Sep 13 – 15 | $0.66 | 2,200.0% | no — under $100 |
1788a88c | Simple Queue Service | Sep 13 – 15 | $0.66 | 366.7% | no — under $100 |
825a1b2d | Key Management Service | Sep 12 – 15 | $0.35 | 64.8% | no — under $100 |
3acaafcc | GuardDuty | Sep 3 | $0.14 | 82.3% | no — under $100 |
814bca96 | EC2 Container Registry | Sep 12 – 13 | $0.05 | 62.5% | no — under $100 |
fefc7ae4 | Simple Storage Service | Sep 8 | $0.01 | 100.0% | no — under $100 |
Two notes on reading this table. AWS returns no percentage at all for EC2 Compute and EBS because their expected spend was $0.00 — the increase is undefined rather than zero, which is why those cells read n/a. And the Security Hub anomaly only opened on Sep 16, so recency may be a second reason it was not in the Sep 16 email — but at $19.41 it would never have been emailed regardless. Neither note disturbs the finding: the $100 floor let two signals through and silenced fourteen, and the loudest thing it silenced was the Lambda anomaly Fede had to go and ask about himself.
Everything above was read live from AWS on 2026-09-17, account 194925971150, region us-east-1. No figure is estimated or carried over from a previous report.
ce get-cost-and-usage, grouped by RECORD_TYPE and by SERVICE.RECORD_TYPE — Jul usage $578.48 + tax $23.46; Aug usage $309.43 fully offset by a $309.43 credit; Sep 1–17 usage $1,935.80 fully offset by a $1,935.80 credit.ce get-anomalies and ce get-anomaly-subscriptions.configservice describe-configuration-recorders, describe-configuration-recorder-status, get-discovered-resource-counts.budgets describe-budgets, describe-subscribers-for-notification, sns list-subscriptions-by-topic, sns get-topic-attributes.cloudformation describe-stacks for the CI stack, CloudTrail PutConfigurationRecorder for the fix.One important trap for anyone re-checking this: a plain ce get-cost-and-usage on this account reports roughly $0, because the credits cancel the usage out. The whole event is invisible unless you filter on RECORD_TYPE=Usage.