CloudWatch: metrics, logs, alarms, and the bill
CloudWatch is the default AWS telemetry plane: metrics, logs, alarms. The two traps are never-expire log retention (a silent bill) and alarming on average instead of p99. Spend ingest deliberately.
A finance team opens the monthly AWS bill and finds a five-figure line item they can’t place: CloudWatch Logs. Nobody changed anything. That’s the point — nobody had to. A chatty service had been logging every request at DEBUG into a log group whose retention was the default, Never Expire. For eighteen months every byte was both ingested (billed once) and stored forever (billed every month, compounding). The “fix” took thirty seconds — set the log group to 14-day retention — but the lesson is structural: CloudWatch defaults are tuned for don’t lose data, not for don’t surprise the bill. The same week, the same team had a real incident go undetected for forty minutes, because the latency alarm watched the average and the average looked fine while the p99 was on fire. CloudWatch gives you the telemetry plane for cheap in spirit and expensive in practice; using it well is mostly about choosing resolution, retention, and the right statistic on purpose.
Metrics: namespaces, dimensions, and the resolution you pay for
Before you publish your first custom metric, ask yourself: what will this cost at 10× today’s traffic, and am I alarming on the right number? The answers live in three decisions — namespace/dimensions, resolution, and which statistic you read.
A CloudWatch metric is a time-ordered set of data points — a single variable over time, like one EC2 instance’s CPU. Every metric is uniquely identified by a namespace (a container, e.g. AWS/EC2 or your own MyApp/Checkout), a metric name, and zero or more dimensions — name/value pairs like InstanceId=i-0abc. The subtlety that bites people: each unique combination of dimensions is a separate metric. Add a customerId dimension with 50,000 values and you’ve just created 50,000 metrics, each billed independently. That is the high-cardinality trap, and it is the fastest way to turn a metrics bill into a finance ticket.
Resolution is the other dial. A metric is either standard resolution (1-minute granularity) or high resolution (down to 1 second). AWS service metrics are standard by default; for EC2 you can opt into detailed monitoring for 1-minute data instead of the basic 5-minute data. For your own metrics you call PutMetricData and decide. Resolution costs: every PutMetricData call is charged, so publishing a high-resolution metric every second is dramatically more requests — and more money — than publishing once a minute. CloudWatch also ages metric data out automatically: 1-second data lives about 3 hours, 1-minute data about 15 days, 5-minute data about 63 days, and 1-hour rollups about 455 days (15 months), after which the metric expires if no new data arrives.
# Publish a standard-resolution custom metric (1-minute granularity, the default)
aws cloudwatch put-metric-data \
--namespace "MyApp/Checkout" \
--metric-name CheckoutLatencyMs \
--dimensions Service=checkout,Env=prod \
--value 412 --unit Milliseconds
# High resolution: --storage-resolution 1 stores at 1-second granularity.
# Every call is billed, so 1-sec publishing means ~60x the PutMetricData requests of 1-min.
aws cloudwatch put-metric-data \
--namespace "MyApp/Checkout" \
--metric-name CheckoutLatencyMs \
--storage-resolution 1 \
--value 412 --unit MillisecondsWhen you read a metric you choose a statistic — Average, Sum, Maximum, or a percentile like p99. This choice is not cosmetic. The average hides the tail: if 99 percent of requests are fast and 1 percent time out, the average barely moves while real users are suffering. For any latency SLO you alarm on p99 (or p95), never the average. (Note: percentiles need the raw data points; if you publish a pre-aggregated statistic set, p99 generally isn’t recoverable.) Prices below are illustrative and region-dependent — in US East (N. Virginia) a custom metric runs about $0.30/metric/month for the first 10,000; always confirm on the CloudWatch pricing page.
Logs: groups, streams, and the retention trap
CloudWatch Logs organizes data as log streams (one sequence of events per source — e.g. one container) grouped into log groups (everything sharing the same retention, access, and monitoring settings). Retention is set per log group, and here is the trap stated plainly in the docs: by default, log data is stored indefinitely — the console shows it as Never Expire. Ingest is billed once per GB (about $0.50/GB in us-east-1, illustrative); storage is billed every month (about $0.03/GB/month). Never-expire plus high-volume DEBUG logs means the storage line grows without bound. Set retention deliberately — 7, 14, 30, or 90 days for most app logs — and drop noisy debug logging in production.
To turn logs into signal you have two tools. Logs Insights is ad-hoc query for investigation: write a query, scan a time range, get results. A metric filter is the durable version: it matches a pattern in incoming log events and emits a CloudWatch metric you can graph and alarm on — for example, counting ERROR lines so a spike pages someone.
# Set retention deliberately — this one command is the whole fix for the never-expire trap
aws logs put-retention-policy --log-group-name /myapp/checkout --retention-in-days 14-- Logs Insights: top 20 slowest checkout requests in the last hour, for ad-hoc triage
fields @timestamp, requestId, durationMs
| filter service = "checkout" and durationMs > 1000
| sort durationMs desc
| limit 20// Metric filter: turn every ERROR line into a count metric you can alarm on
{
"filterName": "checkout-errors",
"filterPattern": "ERROR",
"logGroupName": "/myapp/checkout",
"metricTransformations": [
{ "metricNamespace": "MyApp/Checkout", "metricName": "ErrorCount", "metricValue": "1", "defaultValue": 0 }
]
}Alarms: states, evaluation windows, and what to watch
An alarm watches one metric (or a metric-math expression) and compares it to a threshold over a period, across a number of evaluation periods. It sits in one of three states: OK, ALARM, or INSUFFICIENT_DATA (not enough data points to decide — common right after creation, or when a metric stops reporting). Alarms only fire actions on a sustained state change, not merely for being in a state, which is why you tune “N of M datapoints breaching” to balance sensitivity against flapping. Actions go to an SNS topic (paging, ChatOps) or to an Auto Scaling policy.
Two refinements matter at senior level. Composite alarms combine other alarms with a boolean rule expression — for example (ALARM("CPUHigh") OR ALARM("DiskHigh")) AND OK("Deploying") — so you page once on a real condition instead of getting ten correlated pages from one root cause. Anomaly detection trains a band of expected values from history and alarms when the metric leaves the band, which beats a static threshold for metrics with daily or weekly seasonality. And the recurring mistake: alarming on CPU when your users feel latency and error rate. Alarm on p99 latency and on the error-count metric your metric filter produces — those are what an SLO is made of.
| Dial | Default | What it costs / risks | Senior move |
|---|---|---|---|
| Metric resolution | Standard (1-min); EC2 basic 5-min | 1-sec = ~60x PutMetricData calls billed | High-res only where sub-minute insight pays off |
| Dimensions (cardinality) | You choose | Each combo is a billed metric; userId explodes count | Keep dimensions low-cardinality; never per-user |
| Log retention | Never Expire | Storage billed monthly forever — silent bill | Set 7/14/30/90d per group; drop prod DEBUG |
| Alarm statistic | Often Average / CPU | Average hides the tail; misses real pain | Alarm on p99 latency + error rate |
| Missing data | treatMissingData | Wrong setting = silent blind spot or false page | Choose breaching/notBreaching/missing on purpose |
▸Why this works
Why does INSUFFICIENT_DATA matter so much? An alarm has three states, and only ALARM pages by default. If a metric stops reporting — the agent died, the function stopped being invoked, a deploy renamed the metric — the alarm slides into INSUFFICIENT_DATA, not ALARM. With the wrong treatMissingData setting, “no data” gets treated as “everything is fine,” and your most important alarm goes quiet exactly when the thing it watches has fallen over. The senior habit is to decide explicitly: for a heartbeat metric, treat missing data as breaching so silence pages you; for a sparse metric, treat it as notBreaching so you don’t get false alarms. The default is rarely the right answer for a critical alarm.
A high-traffic checkout API logs every request at DEBUG. The CloudWatch bill is dominated by Logs, and an SLO breach last week went unnoticed because the only latency alarm watched the average. Pick the highest-leverage first move.
You create a brand-new alarm on a metric a service publishes only occasionally, and it immediately shows INSUFFICIENT_DATA. What does that state mean?
Your latency alarm watches the Average statistic and stayed OK during an outage when many users saw timeouts. Why did it miss the pain?
- 01Why is 'Never Expire' log retention a silent cost trap, and what's the deliberate fix?
- 02Why alarm on p99 latency instead of average CPU, and how do INSUFFICIENT_DATA and composite alarms fit in?
CloudWatch is the default AWS telemetry plane and it has three parts. Metrics are time series identified by a namespace and dimensions, where each unique dimension combination is a separately billed metric — so high-cardinality dimensions like a per-user ID explode both your metric count and your bill. Resolution is a dial: standard 1-minute by default, high-resolution down to 1 second, and since every PutMetricData call is billed, 1-second publishing costs far more. When you read a metric you pick a statistic, and for latency that statistic must be p99 (or p95), never the average, because the average hides the slow tail that users actually feel. Logs are organized into streams grouped into log groups, and retention is set per group — its default is Never Expire, which combined with verbose logging is the silent cost trap, since ingest is billed once per GB and storage every month forever; the fix is deliberate retention plus cutting prod log volume, because ingest is the dominant cost lever. Logs Insights handles ad-hoc queries and metric filters turn a log pattern (like ERROR lines) into a metric you can alarm on. Alarms compare a metric to a threshold over a period and evaluation window, sit in OK / ALARM / INSUFFICIENT_DATA, and act via SNS or Auto Scaling; composite alarms combine conditions with boolean logic to suppress correlated noise, anomaly detection bands beat static thresholds for seasonal data, and treatMissingData must be chosen on purpose so a stopped metric pages you instead of going silent. Prices quoted here are illustrative and region-dependent — confirm on the CloudWatch pricing page. Now when you see a surprise on an AWS bill or a latency alarm that stayed green during an outage, your first two questions are: what is the retention on that log group, and which statistic is the alarm watching?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.