Alarms that actually fire
Thresholds with reasons, and proving they work.
Part 1: The problem
QuickCart had fourteen alarms. Most of them fired most nights: CPU above 50% during the nightly report, a spike of errors during each deploy. Everyone set up an email rule, and the alarm emails went into a folder.
Then checkout started failing on a Saturday afternoon. The 5xx alarm fired, correctly. Its email went into the folder with the other two hundred. Customers noticed after ten minutes. The team noticed after forty, when someone checked the app store reviews.
The following week, Priya created a new alarm for failed payments. It never sent anything — its email subscription had never been confirmed. Nobody found out until the next incident.
So an alarm has two jobs, and both have to be proved:
- Fire when something is really wrong — and only then.
- Reach a person who will act on it.
Part 2: The idea
An alarm watches one metric, compares it with a threshold every period, and does something when the answer changes. Every CloudWatch alarm is in one of three states:
| State | Means | What you’d see |
|---|---|---|
OK | The metric is on the right side of the threshold | Nothing. Quiet is good |
ALARM | The metric crossed the threshold for long enough | A notification, if you set one up |
INSUFFICIENT_DATA | Not enough data to decide | Every new alarm starts here. It’s a problem if it stays here |
The five decisions in every alarm
| Decision | QuickCart’s error alarm | Why it matters |
|---|---|---|
| Metric, statistic and period | AppErrorCount, Sum, 1 minute | What is being measured, and in what slices |
| Threshold | Greater than 5 | Where “fine” ends |
| Datapoints to alarm | 1 out of 1 | How long it has to be bad before anyone is told |
| Missing data | Treat as good | What a silent minute means |
| Action | Email the quickcart-alerts topic | Who is told, and how |
Thresholds need reasons
A threshold picked because it’s a round number fires at the wrong times. Every threshold should answer “why this number?” These are the alarms QuickCart ended up with:
| Alarm | Condition | Why this threshold |
|---|---|---|
quickcart-unhealthy-hosts | UnHealthyHostCount ≥ 1, 2 of 2 minutes | Any unhealthy server is worth knowing about. Two minutes avoids firing during a normal deploy |
quickcart-alb-5xx | HTTPCode_ELB_5XX_Count Sum > 5, 1 of 1 | 5xx from the load balancer itself means there’s no healthy server to send to. These should be zero |
quickcart-target-5xx | HTTPCode_Target_5XX_Count Sum > 10, 2 of 2 | Errors from the app. A little higher, because the app returns 500 when the database has trouble |
quickcart-latency-p95 | TargetResponseTime p95 > 1 second, 3 of 3 | A percentile, not an average: an average hides a slow minority |
quickcart-cpu-high | CPUUtilization Average > 70%, 2 of 2 | Auto scaling aims for 50%. Staying above 70% means scaling isn’t keeping up |
quickcart-db-connections | DatabaseConnections Maximum > 40, 2 of 2 | The smallest database sizes allow only a few dozen connections. 40 leaves time to act before it’s full |
quickcart-db-storage | FreeStorageSpace Minimum under 2 GB, 1 of 1 at 5 minutes | A full disk stops the database hard, and recovery is slow |
quickcart-app-errors | AppErrorCount Sum > 5, 1 of 1 | The app’s own view of its errors, from the log metric filter |
“M out of N”: how long before it fires
Two settings work together:
| Setting | Means |
|---|---|
| Evaluation periods (N) | How many of the most recent periods to look at |
| Datapoints to alarm (M) | How many of those must be bad to fire |
| Setting | Fires when | Good for |
|---|---|---|
| 1 out of 1 | The latest minute is bad | Things that should never happen, like load balancer 5xx |
| 2 out of 3 | Two of the last three minutes are bad | Most things: ignores a single blip, still quick |
| 3 out of 3 | Three minutes in a row are bad | Noisy metrics, like CPU or latency |
Asking for more datapoints makes the alarm slower, but calmer. When an alarm keeps switching between OK and ALARM, it’s flapping, and the fix is usually more datapoints or a better threshold.
Missing data is a decision too
Some metrics only report when something happens. The load balancer’s 5xx count, and a metric filter without a default value, publish nothing in a quiet minute. You choose what that silence means:
| Treat missing data as | Silence counts as | Use for |
|---|---|---|
| missing (the default) | Nothing — the alarm may go to INSUFFICIENT_DATA | Metrics that always report, like CPU |
| good (not breaching) | Fine | Error counts: no data means no errors |
| bad (breaching) | A problem | Heartbeats: a metric that must always report, so silence means something died |
| ignore | Keep whatever state it was already in | Rarely. It can keep a stale ALARM for a long time |
Getting it to a person: SNS
An alarm doesn’t send email itself. It publishes a message to an SNS topic, and the topic delivers it to everyone subscribed.
| Piece | What it is | QuickCart’s |
|---|---|---|
| Topic | A named channel that messages are sent to | quickcart-alerts |
| Subscription | One destination for that topic: an email address, a phone, a chat tool, another system | The on-call email address |
| Confirmation | Email subscriptions must be confirmed by clicking a link. Until then, nothing is delivered | Checked: Confirmed |
One topic can feed many alarms, and many subscribers. Change who gets alerts in one place.
Prove it fires
There are two ways to test an alarm, and they test different things:
| Test | How | What it proves |
|---|---|---|
| Force the state | aws cloudwatch set-alarm-state. The console has no button for this | Everything after the decision: the topic, the subscription, the email |
| Cause the real thing | Make the metric cross the threshold: send error logs, stop a server, remove a rule | The metric, the threshold and the datapoints too — the whole chain |
Do both. The forced test is quick and safe. The real one is the only proof that the alarm would fire during an actual incident. In QuickCart’s build, removing the database’s security group rule made the app return 500s, and the error alarm fired within two minutes: that’s the evidence it works.
Alarm fatigue
When alarms fire and nothing needs doing, people learn to ignore them. Then they ignore the real one.
| Rule | In practice |
|---|---|
| Every alarm needs an action | If the answer to “what would I do?” is “nothing”, it’s not an alarm. Put it on a dashboard |
| Page for customer pain, ticket for everything else | Checkout failing wakes someone. A disk at 70% can wait for the morning |
| Link every alarm to a runbook | Put the link in the alarm’s description, so the person woken up knows the first step |
| Review what fired | Every alarm that fired without needing action gets retuned or deleted |
Composite alarms
When one thing breaks, several alarms often fire together: target 5xx, app errors and latency, all for one database problem. A composite alarm combines other alarms with a rule, and notifies once:
ALARM("quickcart-target-5xx") AND ALARM("quickcart-app-errors")The individual alarms keep their states for the dashboard, but only the composite one sends the notification. Composite alarms cost a little more than a normal alarm. Start with a few well-tuned alarms and add composite ones when you see the same group firing together.
Part 3: See it
Here’s one metric — errors per minute — and an alarm on it. Pick what the metric does, then change the threshold, the datapoints and the missing-data setting. Watch the state row underneath, and count how many notifications would be sent.
- 02:00OK
- 02:01OK
- 02:02OK
- 02:03OK
- 02:04ALARM
- 02:05ALARM
- 02:06ALARM
- 02:07ALARM
- 02:08ALARM
- 02:09OK
- 02:10OK
- 02:11OK
What to notice: with 1 of 1, the one-minute blip wakes someone up. With 2 of 3 it doesn’t, and the real outage still fires, just a minute later. On the quiet night, the only thing that changes the result is what missing data means.
Part 4: Do it
Pennies — one alarm (about $0.10 a month, charged by the hour, and the free tier covers ten), an SNS topic whose first 1,000 emails a month are free, and a few kilobytes of logs.
You’ll build an error alarm on a log metric, send it to your email, force it to fire, then make it fire for real. About 30 minutes.
You need the AWS CLI with a working login, and the console’s region set to N. Virginia (us-east-1).
Create the log group, stream and metric filter
If you kept
/quickcart/ordersand its metric filter from Logs and metrics, skip to the next step.In the AWS console: CloudWatchLog groupsCreate log group
Field Enter Log group name /quickcart/ordersRetention setting 1 week Choose Create. Open the new group, and on Log streams choose Create log stream:
Field Enter Log stream name lab-1Then on Metric filters, choose Create metric filter:
Field Enter Filter pattern { $.level = "ERROR" }Filter name quickcart-app-errorsMetric namespace QuickCart/AppMetric name AppErrorCountMetric value 1Default value 0Choose Next between the pages, then Create metric filter.
You should seeThe log group with one stream,
lab-1, and one metric filter.Create the SNS topic
In the AWS console: Simple Notification ServiceTopicsCreate topic
Field Enter Type Standard Name quickcart-alertsLeave the rest and choose Create topic.
You should seeThe topic’s page, with its ARN:
arn:aws:sns:us-east-1:111122223333:quickcart-alerts, with your own account number.Subscribe your email, and confirm it
On the topic’s page, choose Create subscription.
Field Enter Topic ARN already filled in Protocol Email Endpoint your own email address Choose Create subscription. Then open your email, find the message from AWS Notifications — it may be in spam — and choose Confirm subscription.
You should seeBack in SNS → Subscriptions, refresh: the status is Confirmed. If it still says Pending confirmation, nothing will reach you. Don’t go on until it says Confirmed.
Send some normal traffic, so the metric exists
The console can only put an alarm on a metric that has data. Normal log lines make the metric filter publish its default value, 0.
The console has no way to send log lines by hand, so this uses the CLI. First get the current time:
Terminaldate +%sPart What it means dateprint the date and time +%sas seconds since 1 January 1970 You should see1790349700Yours will be larger. Add three zeros to turn it into milliseconds. Create
normal.json, putting your number in place of the highlighted one:normal.json[ {"timestamp": 1790349700000, "message": "{\"level\":\"INFO\",\"route\":\"/orders\",\"status\":200,\"latency_ms\":41,\"instance_id\":\"i-0aa11\"}"}, {"timestamp": 1790349700000, "message": "{\"level\":\"INFO\",\"route\":\"/orders\",\"status\":200,\"latency_ms\":38,\"instance_id\":\"i-0bb22\"}"} ]From the same folder, send it:
Terminalaws logs put-log-events --region us-east-1 --log-group-name /quickcart/orders --log-stream-name lab-1 --log-events file://normal.jsonPart What it means aws logs put-log-eventssend log events to CloudWatch Logs --region us-east-1the region the log group is in --log-group-name /quickcart/orderswhich log group --log-stream-name lab-1which stream --log-events file://normal.jsonread the events from this file You should seeA short JSON reply, without
rejectedLogEventsInfo. Wait two or three minutes before the next step, so the metric appears.Create the alarm
In the AWS console: CloudWatchAlarmsAll alarmsCreate alarm
Choose Select metric, then QuickCart/App → Metrics with no dimensions, tick AppErrorCount, and choose Select metric.
Field Enter Statistic Sum Period 1 minute Threshold type Static Whenever AppErrorCount is Greater than 5Open Additional configuration:
Field Enter Datapoints to alarm 1out of1Missing data treatment Treat missing data as good (not breaching threshold) Choose Next.
Field Enter Alarm state trigger In alarm Send a notification to the following SNS topic Select an existing SNS topic, then quickcart-alertsChoose Next.
Field Enter Alarm name quickcart-app-errorsAlarm description More than 5 app errors in a minute. Runbook: https://example.com/runbooks/checkout-5xxChoose Next, check the summary, and choose Create alarm.
You should seequickcart-app-errorsin the list. It starts as Insufficient data, and within a minute or two changes to OK.Force it to fire
The console has no way to set an alarm’s state, so this uses the CLI:
Terminalaws cloudwatch set-alarm-state --region us-east-1 --alarm-name quickcart-app-errors --state-value ALARM --state-reason "Testing the notification path"Part What it means aws cloudwatch set-alarm-statechange an alarm’s state by hand, for testing --region us-east-1where the alarm is --alarm-name quickcart-app-errorswhich alarm --state-value ALARMthe state to put it in --state-reason "Testing the notification path"a note that shows in the alarm’s history, so others know it was a test It prints nothing when it works.
You should seeWithin a minute, an email with the subject
ALARM: "quickcart-app-errors" in US East (N. Virginia). In the console the alarm shows In alarm, then goes back to OK at its next evaluation, about a minute later, because the metric itself is fine.Make it fire for real
Now make the metric itself cross the threshold: eight error lines in one minute is more than 5. Run
date +%sagain for a fresh time — the alarm only looks at the last minute or so, so the timestamp must be now. Createerrors.jsonwith your new number:errors.json[ {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0aa11\"}"}, {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0bb22\"}"}, {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0aa11\"}"}, {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0bb22\"}"}, {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0aa11\"}"}, {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0bb22\"}"}, {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0aa11\"}"}, {"timestamp": 1790350000000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0bb22\"}"} ]Send it straight away:
Terminalaws logs put-log-events --region us-east-1 --log-group-name /quickcart/orders --log-stream-name lab-1 --log-events file://errors.jsonPart What it means aws logs put-log-eventssend log events to CloudWatch Logs --region us-east-1the region the log group is in --log-group-name /quickcart/orderswhich log group --log-stream-name lab-1which stream --log-events file://errors.jsonread the eight error lines from this file You should seeWithin about two or three minutes, the alarm goes to In alarm and a second email arrives. This time the email’s reason names the real datapoint — something like
Threshold Crossed: 1 out of the last 1 datapoints [8.0 (25/09/26 15:26:00)] was greater than the threshold (5.0), with your own time. A few minutes later, with no more errors, the alarm returns to OK.If nothing happens after five minutes, the timestamp was probably too old by the time you sent it. Run
date +%s, update the file and send it again.Read the alarm's history
In the AWS console: CloudWatchAlarmsAll alarmsquickcart-app-errorsHistory
You should seeEach state change with its time and reason: the forced one says
Testing the notification path; the real one names the datapoint that crossed the threshold. Each Action row shows the notification being sent toquickcart-alerts. This history is your evidence the alarm works.Clean-up step: Clean up
Delete the alarm:
In the AWS console: CloudWatchAlarmsAll alarmsquickcart-app-errorsActionsDelete
Delete the topic, which removes its subscription too:
In the AWS console: Simple Notification ServiceTopicsquickcart-alertsDelete
Type
delete mewhen asked, and choose Delete. Then delete the log group:In the AWS console: CloudWatchLog groups/quickcart/ordersActionsDelete log group(s)
You should seeNo alarms, no
quickcart-alertstopic and no/quickcart/orderslog group left. The subscription may linger for a short time in the list as it’s removed.
Part 5: Check yourself
An alarm uses "3 out of 3" datapoints with a 1-minute period. Errors spike for one minute, then stop. What happens?
An alarm on an error-count metric sits in INSUFFICIENT_DATA every night. What is the most likely fix?
The alarm shows ALARM in the console, but no email ever arrives. What should you check first?
What does aws cloudwatch set-alarm-state prove?
The team gets 40 alarm emails a week and most need no action. What is the right response?
Part 6: Cheat sheet
| Term or setting | In one line |
|---|---|
OK / ALARM / INSUFFICIENT_DATA | Fine / crossed the threshold long enough / can’t tell |
| Threshold | Where “fine” ends. Write down why you chose it |
| M out of N | M bad datapoints in the last N periods fires it |
| Flapping | Switching between OK and ALARM. Needs more datapoints or a better threshold |
| Missing data: good | Error counts. No data means no errors |
| Missing data: bad | Heartbeats. Silence means something died |
| SNS topic | Where alarms send notifications. Subscriptions deliver them |
| Pending confirmation | Nothing is delivered until the email link is clicked |
set-alarm-state --state-value ALARM | Tests the notification path. CLI only |
| Real trigger | The only proof it fires in an incident |
| Composite alarm | Several alarms, one rule, one notification |
| Alarm fatigue | Every alarm must mean “do something now” |