Step 5 — Observability ✅ (2026-10-19)
Verified by: the CloudWatch agent running on the live host, structured JSON nginx access logs arriving in
/gamehub/prod/nginx-access, container stdout arriving in/gamehub/prod/app,CWAgenthost metrics with the dimensions read back fromlist-metrics, all eight alarms reporting a real state (including the memory and disk alarms built from those dimensions), the synthetic canary invoked by hand and by schedule, and one dashboard (gamehub-prod) whose log widgets return rows.
What was built
A deployment you cannot see is a deployment you cannot run. This step makes the live system observable from the outside in and the inside out.
| Piece | Implementation | Why not the alternative |
|---|---|---|
Structured access logs (frontend/nginx.conf) | log_format … escape=json with status, uri, request_time, upstream_time | Parsing the default combined log with regexes in a metric filter → every rule becomes a cubicle joke; JSON metric filters are a first-class CloudWatch feature |
Shipping logs + host metrics (infra/backend.yaml) | CloudWatch agent installed by user-data, config file written inline, logs → CloudWatch Logs, mem/disk/swap → the CWAgent namespace | Container Insights / an APM vendor → an agent you must pay per host for, when four metrics and two log files answer every question this project has |
Retention (observability.yaml) | Log groups created by template with 7-day retention | Letting the agent create them → groups that never expire and quietly bill forever is the classic first-week-of-AWS mistake |
Metrics from logs (AWS::Logs::MetricFilter) | Requests, ServerErrors, RequestTimeSeconds, ApiRequestTimeSeconds derived from the JSON log lines | Instrumenting the app with an EMF library → more code in the request path for numbers nginx already knows |
| External availability check | EventBridge rate(5 minutes) → a 20-line Lambda that fetches / and /api/health and prints one JSON line per check; a metric filter turns that line into SyntheticSuccess | CloudWatch Synthetics → the managed canary costs about $10/month at a 5-minute interval, more than the whole project; printing a line and filtering it is free |
Alarms (observability.yaml) | 8 alarms: availability (synthetic), 5xx count, API p99 latency, instance status, CPU, CPU credits, memory, disk — all publishing to one SNS topic | A single "something is wrong" alarm → an alert nobody can act on is an alert everybody mutes |
| One dashboard | Availability, requests vs errors, latency percentiles, host pressure, and two live log tables (recent 5xx, backend log tail) | Per-service dashboards → this service has one host; the dashboard fits on a screen |
Written response (docs/slo.md, docs/runbook.md) | Objectives with error budgets, measurement, and one runbook section per alarm containing the exact commands | "Investigate and fix" → the runbook's value is that it was written before the incident, by someone who was not panicking |
Things that had to be got right
TreatMissingDatais a decision, not a default. For the host metrics, missing data isnotBreaching— a stopped agent should not page anyone. For the synthetic check it isbreaching: no data means the checker itself is broken, and an availability monitor that fails silently is worse than none.- The agent must not create the log groups. If the agent starts first, the groups exist without retention and CloudFormation's
AWS::Logs::LogGroupfails withAlreadyExistson the next deploy. The template owns the groups; the agent only writes into them. - A metric filter is not a log parser you can debug in place. Pattern syntax errors produce an alarm that never fires — no warning, no log entry. The check that matters is a manual canary invocation followed by
get-metric-statistics, proving a data point exists, not that the template was accepted. - *Metric dimensions must match exactly.*
CWAgentattachesInstanceId, and the disk plugin addspath,deviceandfstypeon top. An alarm whose dimension set is one key short sits inINSUFFICIENT_DATAforever. The dimensions in this stack were read back fromlist-metricson the running host, then written into the template — verified, not guessed. - The synthetic check measures the path a user takes. It calls the CloudFront URL, not localhost — otherwise it proves the host is alive while the CDN is broken, which is exactly the failure it is meant to catch.
- Alarms without a subscriber are half a feature. The topic is created and exported; subscribing is one
aws sns subscribebecause the account's own contact address cannot be read by an alarm template. Documented indocs/runbook.md§8 rather than pretended away. - Latency is measured where the user waits.
$request_timeincludes the upstream Express call;$upstream_timealone would hide the time nginx spends. Having both on the log line is what lets the runbook split "slow app" from "slow edge" with one query. - A metric filter publishes its default value for every event that does not match. The canary alarm was built on
Averageof a metric emitted from a Lambda log group, and it sat inALARMwith an average of0.4while every check was passing: the group also containsSTART,ENDandREPORTlines, each of which contributed a zero. The fix is not a different threshold, it is a different statistic — one JSON line per run, summed, so the zeros add nothing. The same trap is why numeric-extraction filters ($.request_time) must only be pointed at log groups where every event matches; the nginx group qualifies because only/api/*traffic reaches the origin, and the S3-served page requests never enter it. - The agent has to be root to read Docker's container logs.
/var/lib/docker/containers/*/*-json.logis0640 root:rootinside a0710directory. Running the agent as its owncwagentuser collected host metrics perfectly and shipped nothing from the containers — a silent half-failure, because the metrics half looked healthy. The log group stayed empty untilrun_as_userwas set toroot, with the reason written next to it. - A policy that is only in the template does not exist on a running host. The instance role gained
CloudWatchAgentServerPolicyin the template, but the live host was created before that, so the agent started and was immediately refused withAccessDeniedException: ... not authorized to perform: logs:PutLogEvents. IaC is a description of the desired state; the running instance remains the old state until it is redeployed or the change is applied. (Attaching the policy by hand made reality match the template again — the template was already correct.)
Concepts to understand cold
- The four golden signals — latency, traffic, errors, saturation — and why this dashboard is organised as exactly those four plus availability
- Metric filters and JSON patterns — how a log line becomes a time series, including extracting a numeric value from a field (
$.request_time) - Extended statistics — why the
p99alarm exists next to the average, and why an average latency is the metric that hides the outage - Error budgets — the shift from "uptime is good" to "the budget decides what we work on next", and what burning 3 h 39 m of a 30-day window means
- Pull vs push monitoring — why the synthetic check lives outside the system it watches, in a different service, in a different failure domain
- Retention and cost of observability — log ingestion is the subscription trap of cloud monitoring; retention is an architectural choice
Interview questions this unlocks
- "How do you know your service is up right now?" (external synthetic check on the public URL, every 5 minutes, with
TreatMissingData: breaching— and what that costs, which is nothing) - "Walk me through an incident: the p99 latency alarm fired. What do you do?" (runbook §3: split
request_timevsupstream_time, then CPU credits, then MySQL swap) - "How do you get metrics out of a system you did not instrument?" (structured JSON logs → metric filters, versus adding an SDK or an EMF library)
- "Your alarm fired at 03:00. Was it a false positive, and how would you know?" (dimension sets,
TreatMissingData, evaluation periods — and the manual canary invocation that proves the wiring) - "What does your error budget buy you, and who decided the target?" (99.5 % on a single-AZ t3.micro is the honest ceiling; the budget decides whether the next sprint is features or reliability)