Notes · Learn AWS · CHAPTER 9

Observability

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.

In this chapter
  1. The cheat table
  2. CloudWatch Metrics
  3. CloudWatch Logs & Logs Insights
  4. CloudWatch Alarms
  5. CloudTrail
  6. X-Ray distributed tracing
  7. Container & Lambda Insights
  8. Try it: poke the observability stack
  9. Quick check (quiz)
  10. Gotchas for Azure devs
  11. Project Compass: add alarms
  12. Recap & next

The cheat table: Azure Monitor → AWS observability

ConceptAzureAWS
Metrics platformAzure Monitor MetricsCloudWatch Metrics
Log platformAzure Monitor Logs / Log Analytics workspacesCloudWatch Logs (log groups + streams)
App-level telemetryApplication InsightsX-Ray (tracing) + CloudWatch Application Insights (auto-discovery)
Audit log (control plane)Azure Activity LogCloudTrail (every API call, every service)
DashboardsAzure Workbooks / Azure DashboardsCloudWatch Dashboards (JSON-defined widgets)
Alert pipelineAlert rule -> Action Group (email/SMS/webhook)CloudWatch Alarm -> SNS topic -> subscribers (email/SMS/Lambda/SQS)
Query languageKQL (Kusto Query Language)CloudWatch Logs Insights query syntax (pipe-based, neither KQL nor SQL)
Distributed tracingApp Insights distributed traces / OpenTelemetryX-Ray (or ADOT - AWS Distro for OpenTelemetry)
Container metricsContainer Insights (AKS)CloudWatch Container Insights (ECS/EKS)
Function metricsFunctions monitoring (auto via App Insights)CloudWatch Lambda Insights (opt-in extension)
Cost modelPer-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 archiveSend Log Analytics to Storage AccountCloudWatch Logs export to S3 / subscription filter to S3 / Kinesis Firehose
Anomaly detectionSmart detection (App Insights), dynamic thresholdsCloudWatch Anomaly Detection (ML-based band) + composite alarms
The row that hurts the most: the query language. If you have years of muscle memory writing 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.
What's in a name? - the observability glossary
CloudWatch
Launched May 2009 as plain "EC2 metric monitoring" - just CPU graphs for your VMs. The "Cloud" prefix was retroactive marketing once AWS realized the same plumbing could collect anything from anywhere. Today it's metrics + logs + alarms + dashboards + synthetics, all under one (slightly overloaded) brand.
CloudTrail
Launched November 2013. The internal codename was "AWS Audit Trail" - it was renamed to match the "Cloud*" family. Every API call to AWS (yes, even the calls to LIST other API calls) lands in CloudTrail. Free for the first 90 days of management events; data events cost real money.
X-Ray
Named for medical X-rays - you can "see through" your distributed system to spot what's slow or broken inside. Launched 2016 to compete with Zipkin/Jaeger. Pre-dates the OpenTelemetry consolidation; today AWS pushes ADOT (AWS Distro for OpenTelemetry) as the recommended SDK and X-Ray is mostly the backend.
Container Insights
The "Insights" family is AWS's brand for "we did extra schema work so you don't have to". Container Insights pre-builds ECS/EKS dashboards from existing CloudWatch primitives. Same for Lambda Insights, Application Insights, and Contributor Insights. Always cost extra; always opt-in.
EMF
Embedded Metric Format. A JSON convention where your application logs and emits metrics in a single line. CloudWatch sees the magic _aws key in the JSON, parses out the metrics, and bills you only once for the log ingestion. The hidden lever for chatty services.
Athena
Named for the Greek goddess of wisdom, weaving, and warfare. A serverless SQL query engine over S3 - frequently used to query CloudTrail logs (which land in S3) and Logs Insights exports. SQL feels familiar to Azure SQL devs; pay-per-byte-scanned feels familiar to nobody.
"Statistic"
In CloudWatch a statistic is the aggregation applied to a metric over the period: 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.
"Dimension"
A key-value pair attached to a metric. Each unique dimension combination creates a separate metric (a "custom metric") and is billed at $0.30/month. A loop that emits RequestCount dimensioned by {user_id: "..."} will create one metric per user. People rediscover this on the bill, not before.
Fun fact CloudTrail records every API call - including the ones to LIST API calls. Run 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.

CloudWatch Metrics

ELI5: metrics, namespaces, dimensions
A metric is a number that changes over time, like "CPU percent" or "request count". A namespace is the box it lives in (one box per service: 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.

Namespace

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.

Metric

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.

Dimension

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.

Statistic + Period

How you collapse points. Statistic = Sum/Avg/Max/p99. Period = 1s, 10s, 60s, 5min, etc. A graph is always (metric, dimensions, statistic, period).

Auto-emitted vs custom metrics

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).

OriginExamplesCostResolution
AWS-emittedEC2 CPUUtilization, Lambda Invocations, ALB RequestCountFree (mostly)1-min standard, 1-sec for some (Detailed Monitoring extra)
Custom (PutMetricData)Compass/Reports.QueueDepth, business metrics$0.30/metric/month + API call costsStandard (60s) or High-resolution (1s, costs more)
Embedded Metric Format (EMF)Same as custom, but emitted via log linesOnly log ingestion charge - no separate metric chargeSame as custom

Retention - the surprisingly long tail

CloudWatch metrics are kept forever, but resolution decays over time. This is similar to Azure Monitor's rollup behavior but with different cliffs.

CloudWatch metric retention & resolution decay 1-second points kept 3 hours 60-second points kept 15 days 5-minute points kept 63 days 1-hour points kept 15 months now 3h 15d 63d 15mo If you need year-over-year trends at fine granularity, export to S3 + Athena or to a long-term TSDB.
Retention is automatic but the cliffs are real: a metric chart spanning 6 months is auto-served from 1-hour rollups, not your original 1-second data.

Side-by-side: emit a custom metric

Azure (Monitor custom metric)
# 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).
AWS (CloudWatch PutMetricData)
# 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
}'
Why EMF matters: classic PutMetricData calls are billed at $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%.

CloudWatch Logs & Logs Insights

ELI5: log groups, streams, and Insights
A log group is a folder for related logs - one per service, function, or container. A log stream is a single file inside that folder - typically one per process or container instance. Retention is set on the group, not the stream. Logs Insights is the search-and-aggregate window you open on top, like a SQL prompt over your log files - but the syntax is its own thing.
CloudWatch Logs hierarchy Log group /aws/lambda/gfn-reports-worker retention: 14 days KMS encryption: optional metric filters: optional stream 2026/05/22/[$LATEST]a1b2 stream 2026/05/22/[$LATEST]c3d4 stream 2026/05/22/[$LATEST]e5f6 one stream per Lambda invocation cold-start / container instance Logs Insights query across many groups stats / filter / parse / sort $0.005 / GB scanned no KQL. no SQL. its own.
A log group has many streams. Streams hold line-by-line log events. Insights is a query layer that walks selected groups within a time window.

The KQL-to-Insights translation

If you spend most of your day with KQL, here's the shortest possible translation table. Internalize the verbs first, the syntax second.

OperationKQL (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
The verbs you must memorize: 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.

Side-by-side: count errors per 5 minutes

KQL (Log Analytics)
AppTraces
| where TimeGenerated > ago(1h)
| where SeverityLevel >= 3
| where AppRoleName == "gfn-reports"
| summarize errors = count()
    by bin(TimeGenerated, 5m)
| order by TimeGenerated asc
CloudWatch Logs Insights
fields @timestamp, @message
| filter level = "ERROR"
| filter service = "gfn-reports"
| stats count(*) as errors by bin(5m)
| sort @timestamp asc

Structured JSON is the unlock

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.

log line - JSON style (recommended)
{
  "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.

Cost trap: the chatty service that ate the bill $1,500-$3,000 / month

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?

Click to reveal the trap
Three layers, all charged separately.

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.

Real-world incident The DEBUG flag nobody turned off $40K from one forgotten log level

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.

CloudWatch Alarms

ELI5: how alarms work
An alarm watches one (or many) metric(s) and fires an action when a condition is met for a number of consecutive periods. The action is almost always "publish to an SNS topic" - and the SNS topic is what fans out to email, SMS, PagerDuty, a Lambda, or a Slack webhook. Same shape as Azure: alert rule -> Action Group, just with SNS in the middle.

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:

Metric alarm

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.

Composite alarm

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.

Anomaly-detection alarm

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.

Side-by-side: alert on error rate

Azure Monitor alert + Action Group
# 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
CloudWatch alarm + SNS topic
# 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
The middleman is SNS. Where Azure has Action Groups baked in, AWS exposes the fan-out as its own service - which feels like an extra step until you realize you can route alarms to anything SNS supports: Lambda, SQS, HTTP/S endpoints, mobile push, email, SMS. PagerDuty and Slack integrations are usually a one-line SNS subscription to their webhook.
Bug hunt: why is this latency alarm crying wolf?

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:

alarm.json
{
  "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"]
}
Click to reveal the bug
Wrong statistic: "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

ELI5: CloudTrail vs Azure Activity Log
Every time anyone (or anything) calls an AWS API - launching an instance, deleting a bucket, even just listing things - CloudTrail records who, what, when, from where. Same idea as Azure Activity Log, but covers every service uniformly, and includes data-plane reads if you ask (and pay) for them.

CloudTrail logs two flavors of event:

Management events

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 events

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.

Insight events

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.

Trails - the persistent capture

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).

terminal - create an organization-wide trail
# 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
The "free first copy" rule. Management events are free for the first trail in an account. Spin up a second trail capturing the same events and you pay $2 per 100,000 events for the duplicate. Stick to one org-wide multi-region trail and you cover essentially everything for $0 on the management-event side.

X-Ray distributed tracing

ELI5: traces, segments, subsegments
A trace is the story of a single request as it travels through your services. Each service that handles it adds a segment - its piece of the timeline. Within a segment, you can add subsegments for individual outgoing calls (a DynamoDB query, an HTTP fetch). Plotted together, you get a flame graph that shows where the time went.

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.

X-Ray service map: a single trace across 4 services Client API Gateway segment: 20ms Lambda segment: 180ms DynamoDB subsegment: 30ms S3 subsegment: 70ms External HTTPS subsegment: 980ms ⚠ Red = high error rate or latency outlier. One look and you know the external API is the bottleneck.
The service map auto-builds from segment data. No manual topology configuration; X-Ray learns the shape from the traces themselves.

Sampling - why "trace everything" is a bad idea

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:

default sampling rule
{
  "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).

OpenTelemetry parity: if you're already running OTel SDKs in Azure / on-prem, the migration path is to swap the OTel exporter to the AWS Distro for OpenTelemetry (ADOT) and point at the X-Ray endpoint (or X-Ray-as-OTLP). The application code doesn't change. Spans become X-Ray segments automatically.

Container Insights & Lambda Insights

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:

BundleWhat it addsEnabled byExtra 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 optionHelm chart / ADOT collector / Fluent Bit DaemonSetSame per-metric pricing; can be substantial at >100 nodes
Lambda InsightsPer-invocation CPU, memory, init-duration breakdown, runtime telemetryLambda Layer + IAM permission~$0.15 per million enhanced metric data points
Application InsightsAuto-discovery of an application stack (RDS + ALB + EC2 + ...) and pre-built dashboards/alarmsConsole 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.

Don't enable Container Insights blindly. On a 200-node EKS cluster with 30 pods/node, the default "every container, every metric" capture can add hundreds of dollars/month. Start with cluster + node level only; opt into pod-level for specific namespaces where you need it.

Try it: poke the observability stack

Lab: list, query, inspect - all read-only $0

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.

terminal
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.

terminal
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.

terminal
# 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.

terminal
# 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.

Quick check

Test yourself - 5 questions

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?

  • 15 days
  • 63 days
  • 15 months
  • Forever (until you delete the metric)
Show answer
Answer: a. 1-minute resolution points are kept for 15 days. After that they're rolled up to 5-minute (kept 63 days) and then to 1-hour (kept 15 months). The chart never says "data missing"; it just silently auto-resamples to a coarser resolution. If you need 1-minute granularity for compliance or year-over-year comparisons, export to S3 before the 15-day window closes.

2. CloudWatch Logs Insights query language is...

  • The same as KQL (Kusto Query Language) for Azure compatibility.
  • Standard SQL with a few CloudWatch-specific functions.
  • Its own pipe-based syntax (fields, filter, stats, sort) - neither KQL nor SQL.
  • JSON-only - you submit a query AST as JSON, not text.
Show answer
Answer: c. Logs Insights uses its own pipe-delimited DSL. Verbs are 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?

  • $0 - data events are free.
  • ~$20/month
  • ~$200/month
  • ~$6,000/month
Show answer
Answer: d. Data events are priced at $0.10 per 100,000 events. 200M events/day x 30 days = 6 billion events = 60,000 hundred-thousand-event units = ~$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?

  • 100% - X-Ray traces every request.
  • 1 request per second per service, plus 5% of additional requests.
  • 10% of all requests, uniformly.
  • Off by default - no traces unless you enable.
Show answer
Answer: b. The default sampling rule (called "Default") keeps the first request per second per service (the 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?

  • Composite alarms support more statistics (like p99) than metric alarms.
  • Metric alarms watch one metric; composite alarms combine the states of other alarms with boolean logic and don't watch metrics directly.
  • Composite alarms are limited to the same region; metric alarms are global.
  • There's no real difference - it's just two console workflows for the same underlying object.
Show answer
Answer: b. A metric alarm watches one metric (with its statistic, period, threshold, comparison operator). A composite alarm watches other alarms and combines their states via a boolean expression like 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.

Gotchas for Azure devs

1. CloudWatch Logs ingestion is $0.50/GB - and there's no free tier above 5 GB. Azure Monitor's Log Analytics has a daily-cap knob and tiers down. CloudWatch Logs doesn't tier; every byte after the free tier is the same flat $0.50. A chatty service emitting 100 KB/request at modest QPS easily lands a $1,000+ monthly bill. The mitigations - shorter retention, EMF, structured logs, sampling - are not optional at scale.
2. CloudTrail data events on busy S3 buckets or Lambdas explode the bill. A startup once flipped on data events "for security" across every S3 bucket and watched their CloudTrail line item go from $50 to $14K/month overnight. Data events are billed per event. Default to management events only; enable data events on a narrow, named list of sensitive buckets/functions.
3. Logs Insights queries also cost money - $0.005 per GB scanned. An "open the editor, hit enter on yesterday's query" workflow during an incident can rack up double-digit dollars across a team. Two mitigations: narrow the time window aggressively, and pin time range / log group to specific values rather than the "last 7 days, all groups" default. Saved queries help; "scratchpad explorations on terabyte log groups" hurts.
4. 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.
5. Metric filters lag the underlying log events by 1-2 minutes. A metric filter is the standard way to turn a log line ("ERROR: payment failed") into a metric you can alarm on. But the filter runs on ingestion in batches, not in real-time. Alarms built on metric-filter-derived metrics will trail real events by ~60-90 seconds. Fine for most alerting; not fine if you need sub-minute paging. Use EMF for sub-minute, or Lambda + custom PutMetricData in the hot path.
6. CloudWatch dashboards are JSON, not a UI-first object. The console dashboard editor is fine for exploration, but production dashboards belong in Terraform or CloudFormation as JSON. Coming from Azure Workbooks (which are also JSON-defined, but lean visual), expect a slightly more spartan widget set. The aws_cloudwatch_dashboard Terraform resource takes the entire JSON as a string - paste-friendly, diff-friendly.

Project Compass: add alarms & a dashboard

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.

Project Compass · Step 9 of 12 Add alarms and a dashboard for 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.

This chapter's slice - SNS topic + email subscription
terminal · create the paging fan-out
# 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"
Three alarms - error rate, throttles, DLQ depth
terminal · alarm 1 - error rate > 1%
# 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
terminal · alarm 2 - any throttles
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
terminal · alarm 3 - DLQ depth > 10
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
Dashboard - one screen for everything
terminal · create the dashboard
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.

Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
One thing worth checking now. Open the alarm in the console and look at the "Treat missing data as" setting. We picked 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.

Recap & next

What stuck?

The mental model in one sentence

Azure Monitor is one product with sub-features; AWS observability is a half-dozen services that you compose. The flexibility costs you a glue paragraph in your runbook (where does each signal land, which topic do they alert through, who pages whom). Once that glue is in Terraform, it stays solved.

Common pitfalls so far

TrapFix
"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.
NEXT CHAPTER
10. Kubernetes on AWS (EKS)
EKS control plane vs AKS, managed node groups, IRSA in depth, the AWS Load Balancer Controller, EBS CSI, and Karpenter.