Laptop to Production

Logs and metrics

CloudWatch, and asking questions of your logs.

Time
45 minutes
Level
Beginner
Read first
Servers on EC2

Part 1: The problem

QuickCart, launch month

At 2am, the orders page started failing. By the time Leo woke up and opened a laptop, auto scaling had already replaced the two servers that were failing. Their logs had been on their own disks, so the logs were gone with them.

The next night it happened again, and this time Leo was quick. He opened a shell on a server and read the log file. Every error said the same thing: ERROR database_unavailable. Was it one problem or two? Which server? Since when? How many customers? The log couldn’t say.

QuickCart needs two things:

  • Logs that leave the server the moment they’re written, so they survive the server.
  • Logs that can answer questions: filter, count, group, average.

This lesson covers both, and metrics: the numbers you’ll hang alarms on in the next lesson.

Part 2: The idea

A running system tells you what it’s doing in two ways.

LogsMetrics
What it isOne line of text per event: “this request failed, here’s why”A number measured over time: “14 errors this minute”
Good forFinding out why something happenedSeeing that something is wrong, and how much
Example{"level":"ERROR","error_type":"OperationalError"}HTTPCode_Target_5XX_Count = 14
Cost grows withHow much you writeHow many different metrics you keep

In AWS, both live in CloudWatch.

Where logs go: CloudWatch Logs

PieceWhat it isQuickCart’s
Log eventOne line, with a timestampOne request to the orders API
Log streamThe events from one source, in orderOne stream per server, named after the server
Log groupA set of streams that belong together. Settings like retention live here/quickcart/orders — every server’s stream
RetentionHow long events are kept before CloudWatch deletes them30 days

Retention defaults to “Never expire”. Every log group you create keeps everything forever unless you choose otherwise. Choose a period every time.

Getting logs off the server

The app writes to a file, as it always has. Something has to copy each line to CloudWatch as it’s written.

Where the app runsWhat ships its logs
EC2 serverThe CloudWatch agent: a small program you install, which follows log files and sends new lines
ECS containerThe awslogs log driver: whatever the container prints goes to a log group. You set it in the task definition
Lambda functionNothing to set up. Lambda sends what the function prints automatically

The agent reads a JSON config file. This one follows the app’s log file and sends it to /quickcart/orders, one stream per server:

quickcart-agent.json
{
  "agent": {
    "metrics_collection_interval": 60
  },
  "logs": {
    "logs_collected": {
      "files": {
        "collect_list": [
          {
            "file_path": "/var/log/app/app.log",
            "log_group_name": "/quickcart/orders",
            "log_stream_name": "{instance_id}",
            "retention_in_days": 30,
            "timezone": "UTC"
          }
        ]
      }
    }
  },
  "metrics": {
    "metrics_collected": {
      "mem": {
        "measurement": ["mem_used_percent"]
      },
      "disk": {
        "measurement": ["used_percent"],
        "resources": ["/"]
      }
    }
  }
}
SettingWhat it doesIf you leave it outWhen you’d change it
metrics_collection_intervalHow often, in seconds, the agent measures the metrics belowDefaults to 60Lower only if you really need finer detail; it costs more
file_pathThe log file to followRequired — nothing to shipWhen the app writes somewhere else
log_group_nameWhich log group to send lines to. Created if it doesn’t existThe agent makes a name up from the file path. Always set itOne group per app or service
log_stream_nameThe stream name. {instance_id} is replaced by the server’s ID, so each server gets its own streamDefaults to the instance IDRarely
retention_in_daysRetention for the group, if the agent creates itThe group keeps logs foreverMatch what your team agreed, such as 30 days
timezoneWhich time zone the file’s timestamps are inLocal time on the serverKeep UTC everywhere, so times from different servers line up
mem, diskMeasure memory and disk use. EC2 can’t see inside the server, so it never reports these on its ownNo memory or disk metrics at allAdd more, such as swap, if you need them

The server also needs an IAM role that lets it write to CloudWatch; AWS’s managed policy CloudWatchAgentServerPolicy covers it. On the server, this starts the agent with that file:

Terminal
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s -c file:/opt/aws/amazon-cloudwatch-agent/etc/quickcart-agent.json
PartWhat it means
sudorun as the administrator; the agent is a system service
/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctlthe agent’s control program, where the package installs it
-a fetch-configthe action: load a config file
-m ec2the mode: running on an EC2 server
-sstart (or restart) the agent once the config is loaded
-c file:/opt/aws/amazon-cloudwatch-agent/etc/quickcart-agent.jsonwhere the config file is
You should see

A few lines saying the config was read and checked, then that the agent service was started. The exact wording changes between agent versions. You don’t need a server for this lesson’s lab; A self-healing environment runs this in a server’s boot script.

Why the format of each line matters

Here is the same failure, written two ways:

Plain text
2026-09-25T02:06:15Z ERROR GET /orders 500 database_unavailable
Structured JSON
{"level":"ERROR","route":"/orders","status":500,"latency_ms":3004,"error_type":"OperationalError","instance_id":"i-0aa11","detail":"Can't connect to MySQL server on 'quickcart-db' (timed out)"}

The plain line is easy to read and impossible to query. To count errors by type, you’d have to guess at words inside the text. The JSON line is a set of named fields. CloudWatch finds them automatically, so you can ask “how many errors of each error_type?” or “what was the average latency_ms?” This is called structured logging.

Key point: Write every log line as JSON, so every detail is a field you can filter, count and average.

A few habits make structured logs useful:

HabitWhy
Always include levelSo you can find every ERROR in one filter
Include the thing that varies: error_type, status, latency_msThese are what you’ll group and average by
Include where it happened: instance_id, zoneSo you can tell one bad server from all of them
Include a request ID if you have oneSo you can follow one customer’s request through several services
Never log passwords, tokens or card numbersLogs are read by many people and kept for weeks

Metrics: numbers over time

A metric is one number, reported again and again. AWS services publish many for free. Five words describe any metric:

WordWhat it meansExample
NamespaceThe group the metric belongs to. AWS services use AWS/…AWS/ApplicationELB
Metric nameWhat is being measuredHTTPCode_Target_5XX_Count
DimensionWhich one: a name and value that pick out one resourceLoadBalancer = app/quickcart-alb/50dc6c495c0c9188
StatisticHow reports in one period are combinedSum, Average, Maximum, Minimum, SampleCount, or a percentile like p95
PeriodThe length of each time slice1 minute, 5 minutes

The statistic matters. Counts are added up with Sum: errors per minute. Levels are averaged: CPU with Average. Response times use a percentile: p95 is the time 95% of requests beat, which shows the slow minority an average hides.

A few metrics QuickCart will use constantly:

NamespaceMetricStatisticTells you
AWS/ApplicationELBHTTPCode_Target_5XX_CountSumHow many requests the app failed
AWS/ApplicationELBTargetResponseTimep95How slow the slowest requests are
AWS/ApplicationELBUnHealthyHostCount, per target groupMaximumHow many servers the load balancer has given up on
AWS/EC2CPUUtilizationAverageHow busy the servers are
AWS/RDSDatabaseConnectionsMaximumHow close the database is to its connection limit
CWAgentmem_used_percentAverageMemory use, reported by the agent

EC2 reports every 5 minutes unless you turn on detailed monitoring, which reports every minute. The difference matters for alarms: a problem can be 5 minutes old before a 5-minute metric even shows it.

Metric filters: turning log lines into a metric

A metric filter watches a log group and adds to a metric whenever a line matches a pattern. It’s how “an ERROR line was written” becomes a number you can alarm on.

FieldQuickCart’sWhat it does
Filter pattern{ $.level = "ERROR" }Match JSON lines whose level field is ERROR. $ is the whole line; $.level is its level field
Metric namespaceQuickCart/AppYour own namespace, so it doesn’t mix with AWS’s
Metric nameAppErrorCountThe name you’ll alarm on
Metric value1Each matching line adds 1
Default value0Report 0 in periods when logs arrived but none matched. Without it, a healthy minute reports nothing at all, and alarms can’t tell “no errors” from “no data”

The { $.field = value } pattern only works on JSON lines — one more reason to log structured. Metric filters only see lines that arrive after they’re created. They never go back over old logs.

Logs Insights: asking questions of your logs

Logs Insights is a query tool in the CloudWatch console. You pick log groups and a time range, write a query, and it scans the logs. Each query is a series of commands joined by |, like a pipe in the terminal: each step works on what the step before it produced.

CommandWhat it doesExample
fieldsChoose which fields to showfields @timestamp, level, error_type
filterKeep only lines that matchfilter level = "ERROR"
statsCount, sum or average, optionally grouped with bystats count() by error_type
sortOrder the resultssort @timestamp desc
limitShow at most this manylimit 20

Fields that start with @ are added by CloudWatch: @timestamp is the event’s time, and @message is the whole raw line. Fields from your JSON appear under their own names.

Logs Insights query
filter level = "ERROR"
| stats count() as errors by bin(1m)
| sort @timestamp asc
PartWhat it means
filter level = "ERROR"keep only lines whose level field is ERROR
|pass what’s left to the next command
stats count() as errorscount the lines, and call the column errors
by bin(1m)one count for each one-minute slice of time
sort @timestamp ascearliest minute first

That one query shows when the trouble started, when it stopped and how bad it got. It’s usually the first thing to run in an incident.

Part 3: See it

Below are twelve log lines from QuickCart’s orders API: a few normal requests and six errors. Switch between plain text and structured JSON, then try to find out what went wrong. Filter by level and error_type, and count by type or by server.

The app writes
level
error_type
Show
The query
fields @timestamp, level, error_type, latency_ms, instance_id
| sort @timestamp asc
Results from the log group /quickcart/orders
  1. 14:01:12{"level":"INFO","route":"/orders","status":200,"latency_ms":41,"instance_id":"i-0aa11"}
  2. 14:01:40{"level":"INFO","route":"/orders","status":200,"latency_ms":38,"instance_id":"i-0bb22"}
  3. 14:02:05{"level":"ERROR","route":"/orders","status":500,"latency_ms":6,"error_type":"ProgrammingError","instance_id":"i-0aa11","detail":"Table 'quickcart.orders' doesn't exist"}
  4. 14:02:31{"level":"ERROR","route":"/orders","status":500,"latency_ms":5,"error_type":"ProgrammingError","instance_id":"i-0bb22","detail":"Table 'quickcart.orders' doesn't exist"}
  5. 14:03:10{"level":"INFO","route":"/orders","status":200,"latency_ms":44,"instance_id":"i-0aa11"}
  6. 14:04:02{"level":"INFO","route":"/orders","status":200,"latency_ms":39,"instance_id":"i-0bb22"}
  7. 14:06:15{"level":"ERROR","route":"/orders","status":500,"latency_ms":3004,"error_type":"OperationalError","instance_id":"i-0aa11","detail":"Can't connect to MySQL server on 'quickcart-db' (timed out)"}
  8. 14:06:20{"level":"ERROR","route":"/orders","status":500,"latency_ms":3003,"error_type":"OperationalError","instance_id":"i-0bb22","detail":"Can't connect to MySQL server on 'quickcart-db' (timed out)"}
  9. 14:06:48{"level":"ERROR","route":"/orders","status":500,"latency_ms":3005,"error_type":"OperationalError","instance_id":"i-0aa11","detail":"Can't connect to MySQL server on 'quickcart-db' (timed out)"}
  10. 14:07:30{"level":"ERROR","route":"/orders","status":500,"latency_ms":3004,"error_type":"OperationalError","instance_id":"i-0bb22","detail":"Can't connect to MySQL server on 'quickcart-db' (timed out)"}
  11. 14:08:05{"level":"INFO","route":"/orders","status":200,"latency_ms":40,"instance_id":"i-0aa11"}
  12. 14:08:44{"level":"INFO","route":"/orders","status":200,"latency_ms":36,"instance_id":"i-0bb22"}
12 eventsThe same 12 events, each written as JSON. Every piece of information is a named field that Logs Insights can filter on.

What to notice: in plain text, all six errors look identical, and no query can split them. In JSON, one stats line reveals two different problems: one failing in 5 ms and one timing out at 3,000 ms. The customer saw the same message for both.

Part 4: Do it

Pennies — a few kilobytes of logs, a few Logs Insights queries and one custom metric. Well under a cent, and the free tier covers most of it.

You’ll create a log group, send it structured log lines, turn the errors into a metric, ask the logs questions with Logs Insights, and set retention. About 30 minutes.

You need the AWS CLI with a working login in your terminal. Set up your computer covers it. Check the region selector in the console says N. Virginia (us-east-1).

  1. Create the log group

    In the AWS console: CloudWatchLog groupsCreate log group

    In newer versions of the console, Log groups sits under Logs, and may be called Log Management.

    FieldEnter
    Log group name/quickcart/orders
    Retention settingNever expire — leave the default for now. You’ll change it in the last step
    Log classStandard

    Leave everything else empty and choose Create.

    You should see

    /quickcart/orders in the list, with Retention: Never expire and Stored bytes: 0.

  2. Create a log stream

    Normally the agent creates streams for you. Here you’re sending lines by hand, so you make one.

    In the AWS console: CloudWatchLog groups/quickcart/ordersLog streamsCreate log stream

    FieldEnter
    Log stream namelab-1

    Choose Create.

    You should see

    lab-1 in the list of log streams, with no events yet.

  3. Create the metric filter before any logs arrive

    A metric filter only counts lines that arrive after it exists, so create it first.

    In the AWS console: CloudWatchLog groups/quickcart/ordersMetric filtersCreate metric filter

    FieldEnter
    Filter pattern{ $.level = "ERROR" }

    Choose Next.

    FieldEnter
    Filter namequickcart-app-errors
    Metric namespaceQuickCart/App
    Metric nameAppErrorCount
    Metric value1
    Default value0
    Unitleave empty

    Choose Next, then Create metric filter.

    You should see

    The filter listed on the Metric filters tab, showing the pattern and QuickCart/App / AppErrorCount.

  4. Write the log lines to a file

    The console has no way to send log lines by hand, so this step and the next use the CLI.

    First, get the current time. CloudWatch refuses log events more than 14 days old or more than 2 hours in the future, so the timestamps must be close to now.

    Terminal
    date +%s
    PartWhat it means
    dateprint the date and time
    +%sin this format: seconds since 1 January 1970, which is how computers count time
    You should see
    1790349400

    Yours will be a different, larger number. CloudWatch wants milliseconds, so add three zeros to the end of yours: 1790349400 becomes 1790349400000.

    In a new folder, create events.json. Replace every highlighted 1790349400000 with your own number. Most editors can do this with find and replace.

    events.json
    [
      {"timestamp": 1790349400000, "message": "{\"level\":\"INFO\",\"route\":\"/orders\",\"status\":200,\"latency_ms\":41,\"instance_id\":\"i-0aa11\"}"},
      {"timestamp": 1790349400000, "message": "{\"level\":\"INFO\",\"route\":\"/orders\",\"status\":200,\"latency_ms\":38,\"instance_id\":\"i-0bb22\"}"},
      {"timestamp": 1790349400000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":6,\"error_type\":\"ProgrammingError\",\"instance_id\":\"i-0aa11\",\"detail\":\"Table 'quickcart.orders' doesn't exist\"}"},
      {"timestamp": 1790349400000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":5,\"error_type\":\"ProgrammingError\",\"instance_id\":\"i-0bb22\",\"detail\":\"Table 'quickcart.orders' doesn't exist\"}"},
      {"timestamp": 1790349400000, "message": "{\"level\":\"INFO\",\"route\":\"/orders\",\"status\":200,\"latency_ms\":44,\"instance_id\":\"i-0aa11\"}"},
      {"timestamp": 1790349400000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3004,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0aa11\",\"detail\":\"Can't connect to MySQL server on 'quickcart-db' (timed out)\"}"},
      {"timestamp": 1790349400000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3003,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0bb22\",\"detail\":\"Can't connect to MySQL server on 'quickcart-db' (timed out)\"}"},
      {"timestamp": 1790349400000, "message": "{\"level\":\"ERROR\",\"route\":\"/orders\",\"status\":500,\"latency_ms\":3005,\"error_type\":\"OperationalError\",\"instance_id\":\"i-0aa11\",\"detail\":\"Can't connect to MySQL server on 'quickcart-db' (timed out)\"}"}
    ]
    PartWhat it means
    [ and ]a list of log events
    timestampwhen the event happened, in milliseconds
    messagethe log line itself. Here each line is JSON, written inside a string, so its own quotes are escaped as \"

    Eight events: three normal requests, two failures from a missing table, and three timeouts to the database. They’re the kind of lines QuickCart’s app writes.

  5. Send them to CloudWatch

    From the folder that holds events.json:

    Terminal
    aws logs put-log-events --region us-east-1 --log-group-name /quickcart/orders --log-stream-name lab-1 --log-events file://events.json
    PartWhat it means
    aws logs put-log-eventssend log events to CloudWatch Logs
    --region us-east-1the region the log group is in
    --log-group-name /quickcart/orderswhich log group
    --log-stream-name lab-1which stream in that group
    --log-events file://events.jsonread the events from this file. file:// tells the CLI it’s a file, not the events themselves
    You should see

    A short JSON reply. It may include a nextSequenceToken, which you can ignore — CloudWatch no longer needs it. If the reply includes rejectedLogEventsInfo, some events were refused: see the mistake below.

    Open CloudWatch → Log groups → /quickcart/orders → lab-1 to see the eight lines. Choose one to expand it: CloudWatch shows the JSON fields laid out.

  6. Ask the logs questions

    In the AWS console: CloudWatchLogs Insights

    If the page offers a choice of query language, choose Logs Insights QL. In Select log groups, choose /quickcart/orders. Set the time range to 3h, so it includes your timestamps.

    Paste this query and choose Run query:

    Logs Insights query
    fields @timestamp, level, error_type, latency_ms, instance_id
    | sort @timestamp desc
    | limit 20
    PartWhat it means
    fields @timestamp, level, error_type, latency_ms, instance_idshow these fields as columns. All but @timestamp come from your JSON
    | sort @timestamp descnewest first
    | limit 20at most 20 rows
    You should see

    Eight rows with the fields in their own columns. error_type is empty for the three normal requests.

    Now count the errors by type:

    Logs Insights query
    filter level = "ERROR"
    | stats count() as errors, avg(latency_ms) as avg_ms by error_type
    PartWhat it means
    filter level = "ERROR"keep only the error lines
    | stats count() as errorscount them, in a column called errors
    avg(latency_ms) as avg_msand average their latency_ms, in a column called avg_ms
    by error_typeone row for each different error_type
    Logs Insights shows
    error_type          errors   avg_ms
    OperationalError    3        3004
    ProgrammingError    2        5.5

    The rows may come in the other order. This is the question plain-text logs couldn’t answer. Two different problems: one fails instantly with a clear message, the other waits 3 seconds for a database that never answers — the sign of a network block, not a code bug.

  7. See the metric the filter made

    In the AWS console: CloudWatchMetricsAll metricsBrowseQuickCart/AppMetrics with no dimensions

    Tick AppErrorCount. On the Graphed metrics tab, set Statistic to Sum and Period to 1 minute.

    You should see

    One point at 5 — the five error lines — at the minute of your timestamp. The namespace can take a few minutes to appear the first time. This metric is what the next lesson puts an alarm on.

  8. Set retention

    In the AWS console: CloudWatchLog groups/quickcart/ordersActionsEdit retention setting(s)

    FieldEnter
    Expire events after1 week

    Choose Save.

    You should see

    Retention: 1 week in the log group list. For a real app, 30 days is a common choice for application logs; audit logs are often kept much longer, by law.

  9. Clean-up step: Clean up

    In the AWS console: CloudWatchLog groups/quickcart/ordersActionsDelete log group(s)

    Choose Delete. This deletes the stream, its events and the metric filter with it.

    You should see

    The log group is gone from the list. The AppErrorCount metric may stay visible for a while, but it receives no new data. You can’t delete a metric by hand; CloudWatch removes it after it has had no data for 15 months, and it costs nothing while nothing is sent to it.

    If you’re going straight on to Alarms that actually fire, you can keep the log group instead — that lab starts by building the same thing.

Part 5: Check yourself

QuickCart's servers are replaced by auto scaling several times a day. Where should their logs live?

Every error line in the logs reads "ERROR database_unavailable". What would help most to tell different causes apart?

A new log group is created with the default settings. How long are its logs kept?

You create a metric filter for { $.level = "ERROR" } at 10:30. The errors happened at 10:00. What does the metric show for 10:00?

In the metric HTTPCode_Target_5XX_Count, you want the total number of 5xx responses each minute. Which statistic and period?

Part 6: Cheat sheet

On one card
Term or queryIn one line
Log groupA set of log streams for one app, where retention is set
Log streamThe lines from one source, such as one server
RetentionDefaults to Never expire. Always set it
CloudWatch agentShips log files and memory and disk metrics from a server
Structured logOne JSON object per line, so every detail is a field
MetricNamespace, name, dimensions; read with a statistic over a period
Sum / Average / p95Counts / levels like CPU / response times
Metric filter{ $.level = "ERROR" } → a metric. Only counts lines after it exists
Default value 0Lets “no errors” look different from “no data”
filter level = "ERROR"Only error lines
stats count() by error_typeCount per type
stats count() by bin(1m)Count per minute: when did it start and stop?
@timestamp, @messageCloudWatch’s own fields: the time, and the whole raw line

Finished the build and the quiz? Mark it done to fill in its stop on the route map.