aws-sandbox.orgOne AWS service and one hands-on lab a day. Independent; not affiliated with AWS.
Friday, October 30, 2026AssociateObservability

CloudWatch Logs, metric filters and alarms: from a log line to a red light

CloudWatch Logs stores what your apps print; metric filters and alarms turn it into signals. Today: when to use each piece, and a CloudShell lab that logs JSON, queries it with Logs Insights, trips an alarm and cleans up.

Amazon CloudWatch Logs with CloudWatch alarms, in plain words

Amazon CloudWatch Logs is where AWS services and your own code send their log lines. Lambda, API Gateway, ECS and many others write there by default, and anything else can send events with one API call. Logs live in log groups (one per app or function, usually) made of log streams (one per source). You choose how long each group keeps its data; the default is forever, which is the most common reason log bills creep up.

Reading raw logs only goes so far, so two tools sit on top. Logs Insights is a query language for asking questions after the fact: how many errors per route in the last hour, what was the average latency, show me the last ten failures. If your logs are JSON, it finds the fields by name with no setup. A metric filter works in the other direction: it watches events as they arrive and turns matches into a CloudWatch metric, such as "errors per minute".

Once something is a metric, a CloudWatch alarm can watch it and change state (OK, ALARM, INSUFFICIENT_DATA) when it crosses a threshold, and optionally notify an SNS topic, scale a group or run another action. Today's lab wires all three together: JSON logs in, a metric filter counting errors, an alarm that turns red at three errors in a minute, and two Logs Insights queries to see what happened.

Reach for it when

  • Your app or Lambda function already writes logs and you want a cheap error-rate alarm without adding a metrics library: a metric filter on ERROR lines gives you that in one command.
  • Something broke in the last hour and you need answers fast, such as which route failed, how often and with what latency. Logs Insights queries across streams without exporting anything.
  • You want one place for logs from several AWS services (Lambda, API Gateway, VPC Flow Logs, S3 server access logs) with per-group retention and encryption.

Skip it when

  • You need long-term, heavy analytics across months of logs. Exporting to S3 and querying with Athena, or a dedicated log analytics store, is usually cheaper than keeping everything in Standard log groups.
  • Your app can publish the number directly. Sending a custom metric (or using the embedded metric format) is more precise than parsing it back out of text, and avoids depending on the exact log wording.
  • You log very high volumes of debug output that nobody reads. Ingestion is billed per GB, so lower the log level or sample before shipping rather than paying to store noise.

The AI angle

AI apps are hard to debug without logs: which prompt timed out, how many tokens a request used, when a guardrail stepped in. Amazon Bedrock's model invocation logging (off by default, set up per Region) can send each call's model ID, token counts and request and response bodies to a CloudWatch Logs group. From there the same tools apply: a Logs Insights query for average output tokens per model, a metric filter that counts failed calls, an alarm when timeouts spike like today's made-up model timeout lines. Because those logs can contain full prompts and answers, give the log group short retention and tight access.

Hands-on lab: Log JSON events, count errors with a metric filter and trip an alarm

From AWS CloudShell, create a log group with 1-day retention, add a metric filter that counts JSON ERROR events, create an alarm on that metric, send eight log lines, query them with Logs Insights, watch the alarm go to ALARM, then delete everything and confirm it's gone.

TIMEAbout 15 minutes, including a few minutes of waiting for the metric
COSTClose to zero. Ingesting a couple of kilobytes of logs at the listed $0.50 per GB is far below a cent. The custom metric (listed at $0.30 per metric-month) and the standard-resolution alarm ($0.10 per alarm metric-month) are both prorated by the hour, so an hour of each is a small fraction of a cent. Prices are the US East examples on the CloudWatch pricing page; check your Region.
FREE TIER / CREDITSThe CloudWatch free tier includes 5 GB of logs per month (ingestion, storage and Logs Insights scanning), 10 custom metrics and 10 standard-resolution alarm metrics, which covers this lab if you aren't already using them up. Accounts created since July 15, 2025 get Free Tier credits that apply to eligible usage; check your plan's terms.

Before you start

  • An AWS account that you own and are allowed to experiment in. Don't use a work account without permission.
  • Sign-in as an IAM user or role that can manage CloudWatch Logs log groups, metric filters and queries, and CloudWatch alarms (an administrator role in a personal sandbox account covers this).
  • The console set to the Region you want to use. CloudShell runs commands in the console's current Region, and the metric and alarm live in that Region too.
  1. Open CloudShell and create a log group with short retention

    Choose the CloudShell icon in the console's top bar. A log group holds related logs, and a log stream is one source writing into it (one app instance, one Lambda container). Setting retention to 1 day means CloudWatch Logs expires the events on its own even if you forget cleanup; the default is to keep them forever. If CloudShell restarts during the lab, run the variable lines again.

    LG=/sandbox/lab-app
    STREAM=web-1
    FILTER=sandbox-error-count
    NS=SandboxLab
    METRIC=ErrorCount
    ALARM=sandbox-error-alarm
    echo "Region: $AWS_REGION"
    aws logs create-log-group --log-group-name "$LG"
    aws logs put-retention-policy --log-group-name "$LG" --retention-in-days 1
    aws logs create-log-stream --log-group-name "$LG" --log-stream-name "$STREAM"
    aws logs describe-log-groups --log-group-name-prefix "$LG" \
      --query 'logGroups[].[logGroupName,retentionInDays]' --output text
  2. Turn ERROR lines into a metric with a metric filter

    A metric filter watches events as they arrive and publishes a CloudWatch metric. The pattern { $.level = "ERROR" } matches JSON events whose level field is ERROR; each match publishes 1, and defaultValue=0 publishes 0 for events that don't match, so the metric shows a real zero instead of a gap. Create the filter before sending logs: filters never look back at events that already arrived.

    aws logs put-metric-filter --log-group-name "$LG" --filter-name "$FILTER" \
      --filter-pattern '{ $.level = "ERROR" }' \
      --metric-transformations metricName=$METRIC,metricNamespace=$NS,metricValue=1,defaultValue=0,unit=Count
    aws logs describe-metric-filters --log-group-name "$LG" \
      --query 'metricFilters[].[filterName,filterPattern]' --output text
  3. Create an alarm on the new metric

    The alarm adds up (Sum) the error metric over one 60-second period and goes to ALARM at 3 or more errors. notBreaching tells it to treat minutes with no data as healthy, which suits an error counter. There is no --alarm-actions, so nothing gets emailed or triggered; in real use you would point it at an SNS topic or another action. A brand-new alarm starts in INSUFFICIENT_DATA.

    aws cloudwatch put-metric-alarm --alarm-name "$ALARM" \
      --namespace "$NS" --metric-name "$METRIC" \
      --statistic Sum --period 60 --evaluation-periods 1 \
      --threshold 3 --comparison-operator GreaterThanOrEqualToThreshold \
      --treat-missing-data notBreaching
    aws cloudwatch describe-alarms --alarm-names "$ALARM" \
      --query 'MetricAlarms[0].[AlarmName,StateValue]' --output text
  4. Send eight structured log lines

    Each event needs a timestamp in milliseconds and a message. The messages are JSON, the way many apps log, so the filter and Logs Insights can read fields by name. Events in one call must be in time order and no older than 14 days. The batch below is a made-up chat app: five INFO lines and three ERROR lines, sent a millisecond apart so they land in the same minute. A response with no rejectedLogEventsInfo means all eight were accepted.

    NOW=$(date +%s%3N)
    cat > events.json <<EOF
    [
     {"timestamp": $((NOW+1)), "message": "{\"level\":\"INFO\",\"route\":\"/chat\",\"latency\":120}"},
     {"timestamp": $((NOW+2)), "message": "{\"level\":\"INFO\",\"route\":\"/chat\",\"latency\":140}"},
     {"timestamp": $((NOW+3)), "message": "{\"level\":\"ERROR\",\"route\":\"/chat\",\"latency\":2300,\"error\":\"model timeout\"}"},
     {"timestamp": $((NOW+4)), "message": "{\"level\":\"INFO\",\"route\":\"/search\",\"latency\":80}"},
     {"timestamp": $((NOW+5)), "message": "{\"level\":\"ERROR\",\"route\":\"/chat\",\"latency\":1900,\"error\":\"model timeout\"}"},
     {"timestamp": $((NOW+6)), "message": "{\"level\":\"INFO\",\"route\":\"/search\",\"latency\":95}"},
     {"timestamp": $((NOW+7)), "message": "{\"level\":\"ERROR\",\"route\":\"/upload\",\"latency\":450,\"error\":\"file too large\"}"},
     {"timestamp": $((NOW+8)), "message": "{\"level\":\"INFO\",\"route\":\"/chat\",\"latency\":130}"}
    ]
    EOF
    aws logs put-log-events --log-group-name "$LG" --log-stream-name "$STREAM" \
      --log-events file://events.json
  5. Ask questions with Logs Insights

    Logs Insights discovers the JSON fields (level, route, latency, error) on its own. start-query takes its time range in seconds, not milliseconds, and returns a query ID; get-query-results returns rows once the status is Complete. Wait about 30 seconds after step 4 so the events are searchable. The first query counts events and averages latency per level; the second lists only the errors, newest first. If a query returns nothing, wait a little and run it again.

    run_query() {
      QID=$(aws logs start-query --log-group-name "$LG" \
        --start-time $(( $(date +%s) - 900 )) --end-time $(( $(date +%s) + 60 )) \
        --query-string "$1" --query queryId --output text)
      for i in 1 2 3 4 5 6 7 8 9 10; do
        [ "$(aws logs get-query-results --query-id "$QID" --query status --output text)" = Complete ] && break
        sleep 2
      done
      aws logs get-query-results --query-id "$QID" --query 'results[*][*].value' --output text
    }
    run_query 'stats count(*) as events, avg(latency) as avg_ms by level'
    run_query 'filter level = "ERROR" | fields @timestamp, route, error | sort @timestamp desc'
  6. Watch the metric and the alarm react

    Give CloudWatch two or three minutes to publish the filter's data points and evaluate the alarm. The metric should show a Sum of 3 for the minute your logs arrived, and the alarm should move to ALARM with a reason that mentions the threshold. A few minutes later, with no new data and notBreaching, it drifts back to OK. If the metric is still empty, wait a minute and run the commands again.

    aws cloudwatch get-metric-statistics --namespace "$NS" --metric-name "$METRIC" \
      --start-time $(date -u -d '-15 minutes' +%FT%TZ) --end-time $(date -u -d '+1 minute' +%FT%TZ) \
      --period 60 --statistics Sum \
      --query 'sort_by(Datapoints,&Timestamp)[].[Timestamp,Sum]' --output text
    aws cloudwatch describe-alarms --alarm-names "$ALARM" \
      --query 'MetricAlarms[0].[StateValue,StateReason]' --output text

Check that it worked

Step 1 prints /sandbox/lab-app and 1. Step 2 prints the filter name and its pattern. Step 3 prints the alarm name with INSUFFICIENT_DATA. Step 4 returns without a rejectedLogEventsInfo field. In step 5 the first query shows ERROR 3 1550 and INFO 5 113 (row order and decimals may vary) and the second lists three errors. In step 6 one minute shows a Sum of 3.0 and the alarm reports ALARM (or OK if it has already recovered).

Clean up

Delete in reverse order: the alarm, the metric filter, then the log group, which also removes its stream and events. Then remove the local events.json file. The last two commands should print nothing (empty lists). The ErrorCount metric may still appear in the metric list for a while; CloudWatch doesn't let you delete metric data, but a metric that receives no new data stops being billed.

aws cloudwatch delete-alarms --alarm-names "$ALARM"
aws logs delete-metric-filter --log-group-name "$LG" --filter-name "$FILTER"
aws logs delete-log-group --log-group-name "$LG"
rm -f events.json
aws cloudwatch describe-alarms --alarm-names "$ALARM" --query 'MetricAlarms[].AlarmName' --output text
aws logs describe-log-groups --log-group-name-prefix "$LG" --query 'logGroups[].logGroupName' --output text

Gotchas

  • Metric filters only count events that arrive after the filter exists. Adding one today won't tell you how many errors you had yesterday; use a Logs Insights query for that.
  • Time units differ between calls: put-log-events timestamps are milliseconds, while start-query takes start and end times in seconds. Mixing them up gives rejected events or empty results.
  • Log groups keep data forever unless you set retention. Put a retention policy on every group you create, including the ones Lambda creates for you on first run.
  • Don't add high-cardinality dimensions, such as request IDs or IP addresses, to a metric filter. Each distinct value becomes a separately billed custom metric, and CloudWatch Logs may disable a filter that creates too many.
  • News: since October 21, 2026, Amazon S3 server access logs can be delivered to CloudWatch Logs in every AWS Region, including GovCloud (US), the European Sovereign Cloud and China, so the query-and-alarm pattern from today's lab works for bucket access logs everywhere.