AWS · lesson 9 of 10
Also part of: Multi-Cloud
CloudWatch Monitoring Basics
Push a custom metric, create a log group with a metric filter, and set an alarm that actually transitions to ALARM state on real threshold breaches.
By SkillByExample · Published
CloudWatch collects three things: metrics (numbers over time), logs (text over time), and
alarms (a rule that watches a metric and fires an action when it’s breached). This lesson
pushes a custom metric, turns a log pattern into a metric with a filter, and watches an
alarm genuinely flip from OK to ALARM and back.
This lesson uses a real AWS free-tier account — CloudWatch’s first 10 custom metrics and 10 alarms are free, and the alarm state transitions in the last section need real scheduled evaluation, which LocalStack’s Community edition does not reliably reproduce.
Publishing a custom metric
aws cloudwatch put-metric-data \
--namespace "Bookshop/Orders" \
--metric-name FailedCheckouts \
--value 2 --unit Count
$ aws cloudwatch put-metric-data --namespace "Bookshop/Orders" --metric-name FailedCheckouts --value 2 --unit Count
put-metric-data returns nothing on success — the only confirmation is reading it back a
minute or two later, once CloudWatch has ingested it.
aws cloudwatch get-metric-statistics \
--namespace "Bookshop/Orders" --metric-name FailedCheckouts \
--start-time 2026-09-13T12:00:00Z --end-time 2026-09-13T12:10:00Z \
--period 60 --statistics Sum \
--query 'Datapoints[].{Time:Timestamp,Sum:Sum}'
$ aws cloudwatch get-metric-statistics --namespace "Bookshop/Orders" --metric-name FailedCheckouts --start-time 2026-09-13T12:00:00Z --end-time 2026-09-13T12:10:00Z --period 60 --statistics Sum --query 'Datapoints[].{Time:Timestamp,Sum:Sum}'
[
{
"Time": "2026-09-13T12:03:00+00:00",
"Sum": 2.0
}
]
A log group and a metric filter
aws logs create-log-group --log-group-name /bookshop/checkout
aws logs put-log-events --log-group-name /bookshop/checkout \
--log-stream-name web-1 --log-events \
"timestamp=$(date +%s000),message=\"ERROR checkout failed: card_declined order_id=1042\""
$ aws logs put-log-events --log-group-name /bookshop/checkout --log-stream-name web-1 ...
{
"nextSequenceToken": "49039817283746501928374650192837465019283746501928"
}
A metric filter scans every new log event for a pattern and increments a metric on match, so a dashboard can chart error rate without a single line of counting code in the application:
aws logs put-metric-filter \
--log-group-name /bookshop/checkout \
--filter-name checkout-errors \
--filter-pattern "ERROR checkout failed" \
--metric-transformations \
metricName=CheckoutErrors,metricNamespace=Bookshop/Orders,metricValue=1,defaultValue=0
aws logs put-log-events --log-group-name /bookshop/checkout \
--log-stream-name web-1 --log-events \
"timestamp=$(date +%s000),message=\"ERROR checkout failed: card_declined order_id=1043\"" \
"timestamp=$(date +%s000),message=\"ERROR checkout failed: timeout order_id=1044\"" \
"timestamp=$(date +%s000),message=\"INFO checkout succeeded order_id=1045\""
aws cloudwatch get-metric-statistics \
--namespace "Bookshop/Orders" --metric-name CheckoutErrors \
--start-time 2026-09-13T12:00:00Z --end-time 2026-09-13T12:15:00Z \
--period 300 --statistics Sum --query 'Datapoints[].Sum'
[2.0]
Two matches, not three — the INFO line correctly didn’t match filter-pattern "ERROR checkout failed".
An alarm that fires for real
aws cloudwatch put-metric-alarm \
--alarm-name checkout-error-spike \
--namespace "Bookshop/Orders" --metric-name CheckoutErrors \
--statistic Sum --period 60 --evaluation-periods 2 \
--threshold 3 --comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching
aws cloudwatch describe-alarms --alarm-names checkout-error-spike \
--query 'MetricAlarms[0].StateValue'
"INSUFFICIENT_DATA"
A brand-new alarm starts INSUFFICIENT_DATA — it hasn’t seen two full periods yet. Push
enough matching log lines to cross the threshold of 3 errors per period, twice in a row:
for i in 1 2 3 4; do
aws logs put-log-events --log-group-name /bookshop/checkout --log-stream-name web-1 \
--log-events "timestamp=$(date +%s000),message=\"ERROR checkout failed: card_declined order_id=$((1050+i))\""
sleep 60
done
aws cloudwatch describe-alarms --alarm-names checkout-error-spike \
--query 'MetricAlarms[0].{State:StateValue,Reason:StateReason}'
{
"State": "ALARM",
"Reason": "Threshold Crossed: 2 out of the last 2 datapoints [4.0 (13/09/26 12:22:00), 4.0 (13/09/26 12:21:00)] were greater than the threshold (3.0)"
}
StateReason names the exact datapoints that tripped it — this is the first place to look
when an alarm fires and the cause isn’t obvious from the dashboard alone.
aws cloudwatch describe-alarm-history --alarm-name checkout-error-spike \
--query 'AlarmHistoryItems[].{Time:Timestamp,Summary:HistorySummary}'
[
{
"Time": "2026-09-13T12:22:14+00:00",
"Summary": "Alarm updated from INSUFFICIENT_DATA to ALARM"
}
]
Practice
1. Let the error rate drop back to zero and confirm the alarm returns to OK.
aws cloudwatch describe-alarms --alarm-names checkout-error-spike \
--query 'MetricAlarms[0].StateValue'
"OK"
Two consecutive quiet periods (matching evaluation-periods 2) is what it took — an alarm
recovers on the same schedule it triggers.
2. Add an SNS action so the alarm actually notifies someone.
aws sns create-topic --name checkout-alerts --query 'TopicArn' --output text
aws cloudwatch put-metric-alarm --alarm-name checkout-error-spike \
--namespace "Bookshop/Orders" --metric-name CheckoutErrors \
--statistic Sum --period 60 --evaluation-periods 2 \
--threshold 3 --comparison-operator GreaterThanThreshold \
--alarm-actions arn:aws:sns:us-east-1:482719503661:checkout-alerts
arn:aws:sns:us-east-1:482719503661:checkout-alerts
put-metric-alarm on an existing alarm name updates it in place rather than creating a
duplicate — the same idempotent-by-name pattern as CloudFormation stacks.
3. Change treat-missing-data to breaching and stop sending any data.
aws cloudwatch put-metric-alarm --alarm-name checkout-error-spike \
--namespace "Bookshop/Orders" --metric-name CheckoutErrors \
--statistic Sum --period 60 --evaluation-periods 2 \
--threshold 3 --comparison-operator GreaterThanThreshold \
--treat-missing-data breaching
{
"State": "ALARM",
"Reason": "Insufficient Data: 2 datapoints were unknown and 0 were breaching"
}
No data at all now counts as a breach — appropriate for a “the service stopped reporting entirely” alarm, dangerous as a default for a metric that’s naturally quiet sometimes.
4. Query the log group directly with a CloudWatch Logs Insights query instead of a metric filter.
aws logs start-query --log-group-name /bookshop/checkout \
--start-time $(date -d '10 minutes ago' +%s) --end-time $(date +%s) \
--query-string 'fields @timestamp, @message | filter @message like /ERROR/ | stats count() as errors'
{
"queryId": "7a2c4f81-9e30-4b56-8c17-2d9a5e6f1b04"
}
aws logs get-query-results --query-id 7a2c4f81-9e30-4b56-8c17-2d9a5e6f1b04
{
"results": [[{"field": "errors", "value": "5"}]],
"status": "Complete"
}
Insights queries are ad hoc and run against raw logs on demand — a metric filter is the better choice for something you want charted continuously, since it doesn’t rescan history on every check.
5. Delete the alarm and log group and confirm both are gone.
aws cloudwatch delete-alarms --alarm-names checkout-error-spike
aws logs delete-log-group --log-group-name /bookshop/checkout
aws cloudwatch describe-alarms --alarm-names checkout-error-spike --query 'MetricAlarms'
[]
An empty list, not an error — describe-alarms on a name that no longer exists just
returns nothing, unlike the ResourceNotFoundException Lambda raises for the same
situation.
Next: cost and the Well-Architected Framework — what your infrastructure choices actually cost, with the math shown.