Metrics, logs, traces, alarms - the same shape on both clouds, but with one painful surprise: KQL is gone. AWS replaces Log Analytics' rich query language with CloudWatch Logs Insights - a pipe-based syntax that looks neither like KQL nor SQL. The good news: once you re-learn the verbs, the rest of the stack maps cleanly.
Azure gives you one front door - Azure Monitor - that sits over Log Analytics, Application Insights, and Activity Log. AWS spreads the same surface across several focused services: CloudWatch (metrics, logs, alarms, dashboards), CloudTrail (audit trail), X-Ray (distributed tracing), and the various -Insights sub-products (Container, Lambda, Application). Every one of these has a direct Azure equivalent. The hard part is not the concepts; it's the query language switch and the cost surprises.
| Concept | Azure | AWS |
|---|---|---|
| Metrics platform | Azure Monitor Metrics | CloudWatch Metrics |
| Log platform | Azure Monitor Logs / Log Analytics workspaces | CloudWatch Logs (log groups + streams) |
| App-level telemetry | Application Insights | X-Ray (tracing) + CloudWatch Application Insights (auto-discovery) |
| Audit log (control plane) | Azure Activity Log | CloudTrail (every API call, every service) |
| Dashboards | Azure Workbooks / Azure Dashboards | CloudWatch Dashboards (JSON-defined widgets) |
| Alert pipeline | Alert rule -> Action Group (email/SMS/webhook) | CloudWatch Alarm -> SNS topic -> subscribers (email/SMS/Lambda/SQS) |
| Query language | KQL (Kusto Query Language) | CloudWatch Logs Insights query syntax (pipe-based, neither KQL nor SQL) |
| Distributed tracing | App Insights distributed traces / OpenTelemetry | X-Ray (or ADOT - AWS Distro for OpenTelemetry) |
| Container metrics | Container Insights (AKS) | CloudWatch Container Insights (ECS/EKS) |
| Function metrics | Functions monitoring (auto via App Insights) | CloudWatch Lambda Insights (opt-in extension) |
| Cost model | Per-GB ingestion + per-GB retention + per-query (Log Analytics) | Per-GB ingestion ($0.50/GB) + per-GB storage + per-GB scanned by Insights queries ($0.005/GB) |
| Long-term archive | Send Log Analytics to Storage Account | CloudWatch Logs export to S3 / subscription filter to S3 / Kinesis Firehose |
| Anomaly detection | Smart detection (App Insights), dynamic thresholds | CloudWatch Anomaly Detection (ML-based band) + composite alarms |
requests | where timestamp > ago(1h) | summarize count() by bin(timestamp, 5m), you will type that into the CloudWatch Logs Insights editor and stare at a red squiggle. The verbs (stats, filter, fields, parse, sort) are different. Plan 30 minutes of "translate my five most-used KQL snippets" before you trust yourself in an incident.
_aws key in the JSON, parses out the metrics, and bills you only once for the log ingestion. The hidden lever for chatty services.Sum, Average, Minimum, Maximum, SampleCount, or percentile (p99, p95, p50). Choosing the wrong one is the #1 source of misfired alarms - see the bug hunt later.RequestCount dimensioned by {user_id: "..."} will create one metric per user. People rediscover this on the bill, not before.aws cloudtrail lookup-events, and the lookup itself shows up in CloudTrail a few minutes later as a LookupEvents event. The recursive nature means a busy account routinely produces gigabytes of management events per day; a single noisy automation tool calling DescribeInstances every 10 seconds can dominate the log on its own. CloudTrail is the only AWS service whose volume grows when you observe it.
AWS/Lambda, AWS/EC2). A dimension is a label that says which thing the number is about (which Lambda function? which instance?). A statistic is how you fold many data points into one (average, sum, max, percentile). Get those four straight and the rest is dashboards.CloudWatch's data model has four nouns. Memorize them and the AWS console stops feeling random.
The container. AWS services emit to AWS/Lambda, AWS/EC2, AWS/SQS, etc. Your own apps emit to whatever you name - convention is Company/Service, e.g. Compass/Reports.
The named number itself: Invocations, Errors, Duration, Throttles. Same name can exist in multiple namespaces - Errors in AWS/Lambda is unrelated to Errors in AWS/ApiGateway.
A key-value tag attached to the metric. FunctionName=gfn-reports-worker turns the metric from "all Lambdas" into "just this one". Each unique combination is a separate billable metric.
How you collapse points. Statistic = Sum/Avg/Max/p99. Period = 1s, 10s, 60s, 5min, etc. A graph is always (metric, dimensions, statistic, period).
Most AWS services emit a set of metrics for free, at 1-minute resolution. Anything you emit yourself is a custom metric and costs $0.30 per metric per month (where a "metric" is one unique namespace + name + dimension combo).
| Origin | Examples | Cost | Resolution |
|---|---|---|---|
| AWS-emitted | EC2 CPUUtilization, Lambda Invocations, ALB RequestCount | Free (mostly) | 1-min standard, 1-sec for some (Detailed Monitoring extra) |
| Custom (PutMetricData) | Compass/Reports.QueueDepth, business metrics | $0.30/metric/month + API call costs | Standard (60s) or High-resolution (1s, costs more) |
| Embedded Metric Format (EMF) | Same as custom, but emitted via log lines | Only log ingestion charge - no separate metric charge | Same as custom |
CloudWatch metrics are kept forever, but resolution decays over time. This is similar to Azure Monitor's rollup behavior but with different cliffs.
# Custom metrics in Azure Monitor
# Usually emitted via App Insights SDK,
# or the Application Insights TrackMetric API:
from applicationinsights import TelemetryClient
tc = TelemetryClient("<ikey>")
tc.track_metric(
name="queue_depth",
value=42,
properties={"queue": "reports-intake"},
)
tc.flush()
# Or write the metric via Azure Monitor
# Metrics REST API (POST .../metrics).
# Option A: classic PutMetricData (counted)
aws cloudwatch put-metric-data \
--namespace "Compass/Reports" \
--metric-name QueueDepth \
--value 42 \
--unit Count \
--dimensions Queue=reports-intake
# Option B: EMF - emit via a log line, no separate metric call
# (this gets parsed by CloudWatch into a real metric):
echo '{
"_aws": {
"Timestamp": 1716331200000,
"CloudWatchMetrics": [{
"Namespace": "Compass/Reports",
"Dimensions": [["Queue"]],
"Metrics": [{"Name": "QueueDepth", "Unit": "Count"}]
}]
},
"Queue": "reports-intake",
"QueueDepth": 42
}'
$0.01 per 1,000 requests. A chatty service emitting 100 metrics/sec spends ~$26/month on the API calls alone, plus the per-metric storage fee. EMF folds the metric into a log line you were going to emit anyway, so you pay only the log ingestion. For high-cardinality services, EMF can cut metric costs by 80-95%.
If you spend most of your day with KQL, here's the shortest possible translation table. Internalize the verbs first, the syntax second.
| Operation | KQL (Log Analytics) | Logs Insights |
|---|---|---|
| Filter rows | | where Level == "ERROR" | | filter level = "ERROR" |
| Project columns | | project ts, msg, level | | fields @timestamp, @message, level |
| Time bin | | summarize count() by bin(ts, 5m) | | stats count(*) by bin(5m) |
| Sort | | order by ts desc | | sort @timestamp desc |
| Limit | | take 100 | | limit 100 |
| Parse | | parse msg with "user=" UserId " " | | parse @message "user=* " as user_id |
| Group with multiple stats | | summarize p99=percentile(dur, 99), n=count() by route | | stats pct(duration, 99) as p99, count(*) as n by route |
fields, filter, stats, sort, limit, parse, display. Special fields all start with @: @timestamp, @message, @logStream, @log, @duration. If you're parsing JSON-structured logs, the JSON keys become first-class field names automatically - no extend step needed.
AppTraces
| where TimeGenerated > ago(1h)
| where SeverityLevel >= 3
| where AppRoleName == "gfn-reports"
| summarize errors = count()
by bin(TimeGenerated, 5m)
| order by TimeGenerated asc
fields @timestamp, @message
| filter level = "ERROR"
| filter service = "gfn-reports"
| stats count(*) as errors by bin(5m)
| sort @timestamp asc
Logs Insights auto-parses any line that's valid JSON. Plain string lines work, but you'll spend your life writing parse rules. If you control the application, emit JSON. If you don't, write the parse rule once and save it as a query.
{
"timestamp": "2026-05-22T14:32:01Z",
"level": "ERROR",
"service": "gfn-reports",
"route": "/reports/aggregate",
"user_id": "u_42",
"duration_ms": 1820,
"error": "DynamoDB throttled"
}
With that shape, filter level = "ERROR" | stats avg(duration_ms) by route works out of the box. Every JSON key is a queryable field.
A microservice emits 100 KB of JSON-formatted logs per request at 10 requests/second, 24/7. The team enables CloudWatch Logs with default 90-day retention. Two engineers run a Logs Insights "show me last 24 hours of errors" query every hour during an incident week. The next month's CloudWatch bill is uncomfortably four figures. Where did the money go?
1. Ingestion. 100 KB x 10 req/s x 86,400 s/day x 30 days = ~2,592 GB/month. At $0.50/GB that's ~$1,296 just to land the logs.
2. Storage. 2.5 TB stored for 90 days at $0.03/GB-month = ~$75 ongoing.
3. Insights queries. Each "last 24 hours" query scans ~85 GB. At $0.005/GB that's $0.42 per query. Two engineers x once an hour x 168 hours = ~$140 for the incident week alone.
Fix bundle:
- Drop retention to 14 days (Lambda's default elsewhere). Long-term audit goes to S3 via subscription filter at $0.023/GB. Cuts steady-state storage by ~85%.
- Switch to EMF so the metrics you actually alarm on are extracted from the same log line - no separate PutMetricData charge.
- Sample debug-level logs at the application. 100 KB per request is almost always 80% noise. Aim for 5-10 KB structured JSON.
- Narrow Insights queries by log stream or @log so they scan only the relevant subset, not the entire group.
A realistic post-fix bill on the same traffic: ~$150/month, with sharper signal.
A backend team hit a tricky production bug. To trace it they bumped the log level on their entire Lambda fleet from INFO to DEBUG, redeployed, and reproduced the issue within an hour. They wrote up the fix, merged it, and went home for the weekend.
Nobody reverted the log level. The environment variable lived in a config file that wasn't part of the standard PR-review checklist. Every Lambda continued running at DEBUG - logging the full request body, every database query, and a trace of every retry. CloudWatch Logs ingestion went from ~50 GB/day to ~1.5 TB/day, silently. No alarm caught it, because nobody had an alarm on log ingestion volume.
Four weeks later, finance flagged a $40,000 CloudWatch bill. The fix took 30 seconds (revert the env var). The post-mortem took weeks.
Lessons: (1) Alarm on AWS/Logs.IncomingBytes per log group with a baseline threshold - this is one of the cheapest alarms you'll ever build. (2) Treat log levels as configuration that needs PR approval, not as a runtime knob. (3) Retention > 14 days for non-audit logs is almost always wrong; the cliff makes accidents auto-amortize. (4) Budgets and Cost Anomaly Detection should be on from day one, not bolted on after the first big bill.
An alarm has three states: OK, ALARM, and INSUFFICIENT_DATA. State transitions can each have separate actions - which is a feature and a foot-gun. There are three kinds of alarms:
The classic one. Watches a single metric statistic over a period. Trigger when threshold is crossed for N consecutive periods.
Example: Errors > 5 for 3 consecutive 1-minute periods.
A boolean expression over other alarms. Use to suppress noise (only page if error-rate-high AND traffic-is-normal) or correlate signals across components.
Cheaper than metric alarms ($0.50 vs $0.10/month). No data, just logic.
ML-trained band around the metric's normal pattern (daily/weekly seasonality). Fires when actual value falls outside the band, not a fixed threshold.
Closest Azure analog: Dynamic Thresholds in Azure Monitor.
# 1. Action Group (who gets paged)
az monitor action-group create \
--name oncall-ag \
--resource-group rg-obs \
--action email primary sre@example.com
# 2. Metric alert rule
az monitor metrics alert create \
--name "gfn-reports-errors-high" \
--resource-group rg-obs \
--scopes "<function-app-id>" \
--condition "avg exceptions/server > 10" \
--window-size 5m \
--evaluation-frequency 1m \
--action oncall-ag
# 1. SNS topic (the fan-out)
aws sns create-topic --name oncall-topic
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:123:oncall-topic \
--protocol email \
--notification-endpoint sre@example.com
# 2. CloudWatch alarm
aws cloudwatch put-metric-alarm \
--alarm-name gfn-reports-errors-high \
--namespace AWS/Lambda \
--metric-name Errors \
--dimensions Name=FunctionName,Value=gfn-reports-worker \
--statistic Sum \
--period 60 \
--evaluation-periods 3 \
--threshold 5 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:123:oncall-topic
An on-call engineer is paged six times in an hour by an alarm called api-latency-high. Latency on the dashboard looks fine - p99 hovers around 200 ms. They share the alarm config:
{
"AlarmName": "api-latency-high",
"Namespace": "AWS/ApiGateway",
"MetricName": "Latency",
"Dimensions": [
{ "Name": "ApiName", "Value": "reports-api" }
],
"Statistic": "Sum",
"Period": 60,
"EvaluationPeriods": 1,
"Threshold": 5000,
"ComparisonOperator": "GreaterThanThreshold",
"AlarmActions": ["arn:aws:sns:us-east-1:123:oncall-topic"]
}
"Statistic": "Sum" on a latency metric is almost always wrong.
Sum over a 60-second period adds together every request's latency in milliseconds. With 50 req/s averaging 200 ms each, Sum is 50 x 60 x 200 = 600,000 ms per period - 120x the threshold. The alarm fires constantly, not because latency is high, but because request volume is normal.
Correct choices for a latency alarm: Average for typical case, p99 / p95 (percentile statistic) for tail awareness, or a derived metric like "count of requests where latency > 1s". Sum only makes sense on counters (Invocations, Errors, BytesDownloaded), never on durations.
This is the single most common alarm mis-configuration in the wild. When in doubt, plot the candidate statistic on the dashboard before wiring it to an alarm.
CloudTrail logs two flavors of event:
Control-plane operations. Things that change configuration: RunInstances, PutBucketPolicy, CreateRole. Plus read APIs like DescribeInstances and ListBuckets.
Cost: free for the first copy. 90 days retention in the console event-history view.
Data-plane operations. Object-level S3 reads/writes (GetObject, PutObject), Lambda Invoke, DynamoDB item access. High-volume, off by default.
Cost: $0.10 per 100,000 events. On a busy S3 bucket, this is real money.
Anomaly detection over CloudTrail itself. Flags unusual spikes in API call rates - useful for catching runaway scripts or compromised credentials.
Cost: $0.35 per 100,000 management events analyzed.
Out of the box, AWS keeps a rolling 90-day window of management events you can browse in the console. To retain longer, search programmatically, or capture data events, you create a trail - a configured capture that lands events in S3 (and optionally CloudWatch Logs).
# A multi-region trail captures events from every region with one config.
# An org-wide trail captures all member accounts too (set up via Organizations).
aws cloudtrail create-trail \
--name compass-org-trail \
--s3-bucket-name compass-cloudtrail-archive-123 \
--is-multi-region-trail \
--is-organization-trail \
--enable-log-file-validation
aws cloudtrail start-logging --name compass-org-trail
# Look up a specific event in the rolling 90-day view (no trail needed)
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=DeleteBucket \
--max-results 10
X-Ray is AWS's distributed tracing service. It's similar in scope to Application Insights' distributed traces, with one big architectural difference: X-Ray is just the backend. In a modern setup you instrument with OpenTelemetry (via ADOT), which sends traces to X-Ray for storage and visualization. The X-Ray SDK still exists but is being de-emphasized.
At AWS-scale request rates, tracing 100% of requests is both expensive and noisy. X-Ray's default sampling rule keeps the first request per second per service, plus 5% of the rest:
{
"version": 2,
"default": {
"fixed_target": 1, // 1 trace per second per service
"rate": 0.05 // + 5% of all additional requests
},
"rules": [] // per-path overrides go here
}
The default is sensible. Override with custom rules to over-sample specific endpoints (the checkout flow, the new feature you just shipped) and under-sample others (health checks, the noisy /metrics Prometheus scrape).
The base CloudWatch metric set for ECS, EKS, and Lambda is functional but sparse. AWS sells two opt-in Insights bundles that emit richer schemas:
| Bundle | What it adds | Enabled by | Extra cost |
|---|---|---|---|
| Container Insights (ECS) | CPU/memory per task/container, network IO, per-service rollups, pre-built dashboards | --enable-container-insights on the cluster (ECS) or DaemonSet (EKS) | Per-metric storage charges; typically $0.30/metric/month x N containers |
| Container Insights (EKS) | Pod-level CPU/memory, kubelet metrics, control-plane metrics, ADOT collector option | Helm chart / ADOT collector / Fluent Bit DaemonSet | Same per-metric pricing; can be substantial at >100 nodes |
| Lambda Insights | Per-invocation CPU, memory, init-duration breakdown, runtime telemetry | Lambda Layer + IAM permission | ~$0.15 per million enhanced metric data points |
| Application Insights | Auto-discovery of an application stack (RDS + ALB + EC2 + ...) and pre-built dashboards/alarms | Console wizard or aws application-insights create-application | $0.0035 per metric/min monitored |
Note: AWS reuses the name "Application Insights" - it's a different product from Azure's App Insights despite the identical brand. AWS Application Insights is an opt-in auto-monitoring layer; Azure App Insights is the core APM service.
Goal: get hands on the four moving parts of CloudWatch (metrics, logs, alarms, trails) without writing or deleting anything. Every command here is a list/describe; nothing changes state.
Step 1. List the metric namespaces visible in your account.
aws cloudwatch list-metrics --max-items 20
# Filter to one namespace
aws cloudwatch list-metrics \
--namespace AWS/Lambda \
--max-items 10
# See which dimensions exist for one metric
aws cloudwatch list-metrics \
--namespace AWS/Lambda \
--metric-name Invocations
Step 2. Enumerate log groups and check retention.
aws logs describe-log-groups --max-items 20 \
--query "logGroups[*].[logGroupName, retentionInDays, storedBytes]" \
--output table
# Look for groups with no retention set (= forever, $$)
aws logs describe-log-groups \
--query "logGroups[?retentionInDays==null].logGroupName" \
--output table
Step 3. Run a Logs Insights query. Pick any log group from step 2.
# Start the query - returns a queryId
QID=$(aws logs start-query \
--log-group-name "/aws/lambda/some-existing-function" \
--start-time $(date -v-1H +%s) \
--end-time $(date +%s) \
--query-string 'fields @timestamp, @message | stats count(*) by bin(5m)' \
--query queryId --output text)
# Poll for completion (usually <5s for small windows)
aws logs get-query-results --query-id $QID
Step 4. See your existing alarms and trails.
# Alarms - filter to anything currently firing
aws cloudwatch describe-alarms \
--state-value ALARM \
--query "MetricAlarms[*].[AlarmName, StateValue, StateReason]" \
--output table
# Trails - how is auditing configured?
aws cloudtrail describe-trails \
--query "trailList[*].[Name, IsMultiRegionTrail, IsOrganizationTrail, S3BucketName]" \
--output table
# Recent root-user activity (security hygiene check)
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=root \
--max-results 5
What you learned: the CLI surface mirrors the console. Once you know list-metrics, describe-log-groups, describe-alarms, and describe-trails, you have a read-only inventory of every observability primitive in the account. This is also the muscle you'll use in incidents when the console is sluggish.
A few of these have answers that surprise people coming from Azure Monitor. Sit with each one.
1. What's the default retention for 1-minute resolution CloudWatch metrics?
2. CloudWatch Logs Insights query language is...
fields, filter, stats, sort) - neither KQL nor SQL.fields, filter, stats, sort, limit, parse, display. Auto-parses JSON log lines into queryable fields. Most KQL verbs have a 1:1 translation but the exact syntax differs - expect 30 minutes of friction the first time you sit down to write a non-trivial query.
3. You enable CloudTrail data events on every object operation in a 50-bucket S3 estate that handles 200 million GET/PUT operations per day. Roughly how much will this add to your monthly bill?
~$6,000/month. This is a real category of unexpected bill - several public horror stories have a startup turning on "CloudTrail data events for security" and watching their bill jump from $50/mo to five figures. Use data events selectively (specific buckets or prefixes), and prefer S3 Access Logs or Athena over S3 inventory for cheap broad audit.
4. What's the default X-Ray sampling rate, if you don't configure custom rules?
fixed_target) plus 5% of all additional requests (the rate). This guarantees you always see something for every service while keeping volume sane on high-traffic systems. Override per-route to over-sample your most-critical paths.
5. What's the difference between a metric alarm and a composite alarm?
ALARM("err-high") AND NOT ALARM("deploy-in-progress"). Composite alarms are how you silence noisy pages during deploys, correlate multiple signals before paging, and build hierarchical alarm trees. They cost $0.50/month vs $0.10/month for a metric alarm - but they pay for themselves the first time they suppress a false page at 3am.
INSUFFICIENT_DATA can still page you. An alarm has three states: OK, ALARM, INSUFFICIENT_DATA. By default the third one only fires a notification if you've configured --insufficient-data-actions. Many people copy/paste that field from an example without realizing it - so a metric that simply stops emitting (Lambda not being invoked, log group going quiet during off-hours) triggers a page indistinguishable from a real outage. Set --treat-missing-data notBreaching or missing explicitly to decide what "no data" means.
PutMetricData in the hot path.
aws_cloudwatch_dashboard Terraform resource takes the entire JSON as a string - paste-friendly, diff-friendly.
By chapter 8 the gfn-reports service has a Lambda worker, an SQS intake, a DynamoDB table, an S3 bucket, and KMS encryption. It does work. But if it breaks at 2am, nobody knows. This chapter's slice adds the visibility layer: an SNS topic for paging, three alarms on Lambda health, and a CloudWatch dashboard.
gfn-reports
Why now: every previous chapter added a moving part. Without alarms, an SQS DLQ filling up or a sudden error spike is invisible until somebody manually checks. This is the smallest set of signals that catches the most common Lambda failure modes.
# Create the SNS topic that all our alarms will publish to
TOPIC_ARN=$(aws sns create-topic \
--name compass-oncall \
--tags Key=Project,Value=Compass \
--profile compass \
--query TopicArn --output text)
# Subscribe your email (you'll get a confirmation link)
aws sns subscribe \
--topic-arn $TOPIC_ARN \
--protocol email \
--notification-endpoint you@example.com \
--profile compass
echo "Topic ARN: $TOPIC_ARN"
# Use a metric math expression: errors / invocations > 0.01
aws cloudwatch put-metric-alarm \
--alarm-name compass-error-rate-high \
--alarm-description "gfn-reports error rate exceeds 1%" \
--evaluation-periods 3 \
--datapoints-to-alarm 2 \
--threshold 0.01 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--metrics '[
{
"Id": "errorRate",
"Expression": "errors / invocations",
"Label": "ErrorRate",
"ReturnData": true
},
{
"Id": "errors",
"MetricStat": {
"Metric": {
"Namespace": "AWS/Lambda",
"MetricName": "Errors",
"Dimensions": [{"Name": "FunctionName", "Value": "gfn-reports-worker"}]
},
"Period": 60,
"Stat": "Sum"
},
"ReturnData": false
},
{
"Id": "invocations",
"MetricStat": {
"Metric": {
"Namespace": "AWS/Lambda",
"MetricName": "Invocations",
"Dimensions": [{"Name": "FunctionName", "Value": "gfn-reports-worker"}]
},
"Period": 60,
"Stat": "Sum"
},
"ReturnData": false
}
]' \
--alarm-actions $TOPIC_ARN \
--profile compass
aws cloudwatch put-metric-alarm \
--alarm-name compass-throttles-any \
--alarm-description "gfn-reports has any throttled invocations" \
--namespace AWS/Lambda \
--metric-name Throttles \
--dimensions Name=FunctionName,Value=gfn-reports-worker \
--statistic Sum \
--period 60 \
--evaluation-periods 1 \
--threshold 0 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions $TOPIC_ARN \
--profile compass
aws cloudwatch put-metric-alarm \
--alarm-name compass-dlq-depth-high \
--alarm-description "More than 10 messages stuck in the gfn-reports DLQ" \
--namespace AWS/SQS \
--metric-name ApproximateNumberOfMessagesVisible \
--dimensions Name=QueueName,Value=gfn-reports-dlq \
--statistic Maximum \
--period 300 \
--evaluation-periods 1 \
--threshold 10 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions $TOPIC_ARN \
--profile compass
cat > /tmp/compass-dashboard.json <<'EOF'
{
"widgets": [
{
"type": "metric", "x": 0, "y": 0, "width": 12, "height": 6,
"properties": {
"title": "Lambda invocations and errors",
"metrics": [
["AWS/Lambda", "Invocations", "FunctionName", "gfn-reports-worker", {"stat": "Sum"}],
[".", "Errors", ".", ".", {"stat": "Sum"}]
],
"period": 60, "region": "us-east-1", "view": "timeSeries", "stacked": false
}
},
{
"type": "metric", "x": 12, "y": 0, "width": 12, "height": 6,
"properties": {
"title": "Lambda duration p95",
"metrics": [
["AWS/Lambda", "Duration", "FunctionName", "gfn-reports-worker", {"stat": "p95"}]
],
"period": 60, "region": "us-east-1", "view": "timeSeries"
}
},
{
"type": "metric", "x": 0, "y": 6, "width": 12, "height": 6,
"properties": {
"title": "SQS intake + DLQ depth",
"metrics": [
["AWS/SQS", "ApproximateNumberOfMessagesVisible", "QueueName", "gfn-reports-intake", {"stat": "Maximum"}],
[".", ".", ".", "gfn-reports-dlq", {"stat": "Maximum"}]
],
"period": 60, "region": "us-east-1", "view": "timeSeries"
}
}
]
}
EOF
aws cloudwatch put-dashboard \
--dashboard-name Compass \
--dashboard-body file:///tmp/compass-dashboard.json \
--profile compass
# Confirm
aws cloudwatch describe-alarms \
--alarm-name-prefix compass- \
--query "MetricAlarms[*].[AlarmName, StateValue]" \
--output table \
--profile compass
Three alarms, one dashboard, one SNS topic. Total ongoing cost: ~$0.40/month (three metric alarms at $0.10 each, plus pennies for SNS). If anything in gfn-reports misbehaves now, you'll know within 1-5 minutes. Chapter 10 will reuse this SNS topic when we move to EKS.
notBreaching so that off-hours quiet (Lambda not being invoked) doesn't page you. If you actually want to be paged when telemetry vanishes - because vanishing telemetry could mean the function isn't running at all - swap that to breaching. Pick deliberately, not by accident.
fields, filter, stats, sort, parse. JSON-structured logs make queries painless.| Trap | Fix |
|---|---|
| "Why is my alarm constantly firing?" | You probably picked the wrong statistic. Sum on a latency or duration metric is almost always wrong - use Average, p95, or p99. |
| "Why is my CloudWatch bill four figures?" | Usually one of: forgotten DEBUG log level, unbounded log retention, CloudTrail data events on a busy bucket, or high-cardinality custom metrics. Run describe-log-groups with retention=null filter as a first check. |
| "Why doesn't my KQL query work?" | Because Logs Insights is not KQL. Translate the verbs (where->filter, summarize->stats, project->fields) and lean on JSON-structured logs. |
| "My alarm pages at night with no errors" | Probably INSUFFICIENT_DATA firing because the service is quiet off-hours. Add --treat-missing-data notBreaching unless you specifically want vanishing-signal alarming. |