Home / AWS

AWSTroubleshootingIntermediate

CloudWatch Logs metric filter shows no data: pattern syntax, JSON logs, dimensions and alarm mismatches

Your log group clearly contains the errors, but the metric filter graph is empty and the alarm never fires. Test the pattern, fix JSON and dimension mistakes, and line the alarm up with the metric.

Deepak Bhardwaj8 min read

Applies toAmazon CloudWatch Logs metric filters, CloudWatch metric alarms, AWS CLI v2

You can see ERROR lines in the log group, yet the custom metric your metric filter is supposed to publish has no data points, or the alarm built on it sits in INSUFFICIENT_DATA. Metric filters fail silently: there is no error log, no failed-invocation counter. The filter either matches incoming events or it doesn't.

Work through the checks in order. Set these first:

export AWS_REGION=eu-west-1
LG="/aws/lambda/orders-api"

Symptom to cause

Symptom Likely cause Fix
Metric never appears in list-metrics No event has matched since the filter was created Filters are not retroactive; generate a matching event or wait
Pattern works in the console search but not in the filter Testing against old data, or different quoting Run test-metric-filter with real messages
JSON pattern { $.level = "ERROR" } never matches Log line has a text prefix before the JSON Switch the app or Lambda to JSON log format, or use a text pattern
Metric has data but alarm is INSUFFICIENT_DATA Alarm namespace, dimensions or unit differ from the metric Copy them from list-metrics
Alarm stays OK between bursts, never resolves cleanly No data points when nothing matches Set defaultValue=0 (not allowed with dimensions) or treat missing data as not breaching
Filter stopped publishing after adding a dimension High-cardinality dimension values Use a low-cardinality field, or move to embedded metric format
Nothing works on one log group only Log group uses the Infrequent Access class Check the log class feature list; recreate as Standard if needed

Check 1: does the filter exist where you think it does?

Metric filters belong to one log group in one region. A filter created on /aws/lambda/orders-api in us-east-1 does nothing for the same function in eu-west-1.

aws logs describe-metric-filters --log-group-name "$LG" \
  --query 'metricFilters[].{name:filterName,pattern:filterPattern,metric:metricTransformations[0].metricName,ns:metricTransformations[0].metricNamespace,value:metricTransformations[0].metricValue,default:metricTransformations[0].defaultValue}' \
  --output table

If you are not sure which log group has the filter, search by metric name across the region:

aws logs describe-metric-filters \
  --metric-name OrdersErrors --metric-namespace "App/Orders" \
  --query 'metricFilters[].[logGroupName,filterName]' --output text

Then confirm the log class of the group:

aws logs describe-log-groups --log-group-name-prefix "$LG" \
  --query 'logGroups[].[logGroupName,logGroupClass]' --output text

Warning

The Infrequent Access log class drops several features to save cost, and metric filters have historically been one of them. Check the current log class comparison in the CloudWatch Logs documentation before relying on a filter in an INFREQUENT_ACCESS group. The log class cannot be changed after creation.

Check 2: test the pattern against real messages

The most common cause is a pattern that doesn't match what the application actually writes. Copy two or three real lines from the log group and test them directly:

aws logs filter-log-events --log-group-name "$LG" \
  --start-time $(( ($(date +%s) - 3600) * 1000 )) --limit 3 \
  --query 'events[].message' --output json > /tmp/sample.json

aws logs test-metric-filter \
  --filter-pattern '?ERROR ?FATAL' \
  --log-event-messages file:///tmp/sample.json

The response lists matches with the event number and extractedValues. An empty matches array means the filter would not count those lines.

Pattern rules that catch people out

You wrote What it actually means
error Case-sensitive: does not match ERROR
ERROR timeout Both terms must appear (AND)
?ERROR ?timeout Either term (OR)
ERROR -healthcheck ERROR but not healthcheck
Connection refused Two separate terms; use "Connection refused" for the exact phrase
%ERROR|FATAL% Regular expression (supported in newer pattern syntax; check limits on regex length)
{ $.status = 500 } JSON field, numeric comparison
{ $.level = "ERROR" } JSON field, string comparison; quotes required
[ip, id, user, ts, req, status=5*, size] Space-delimited fields; status must start with 5

Terms that contain characters other than letters and digits (:, -, /) must be in double quotes.

Tip

Test the pattern, not just the search. The Logs Insights query box and the log group search bar use different syntax from metric filters. A line found by Logs Insights tells you nothing about whether a metric filter pattern matches it.

Check 3: JSON patterns need pure JSON

{ $.level = "ERROR" } only matches if the whole log event is a JSON object. Lambda's default text format writes lines like:

2026-11-13T09:12:44.120Z  5f0c...  ERROR  {"level":"ERROR","msg":"payment failed"}

That message starts with a timestamp, so the JSON selector never matches. Either use a text pattern ("\"level\":\"ERROR\"" or simply ERROR), or switch the function to JSON log format so each event is JSON:

aws lambda update-function-configuration --function-name orders-api \
  --logging-config LogFormat=JSON,ApplicationLogLevel=INFO,SystemLogLevel=WARN

In JSON log format, Lambda's own level field is level and your message lands under message; check one event after the change and adjust the selector. The same applies to containers: if the log driver or agent wraps the line in another structure, test against the wrapped form.

Check 4: filters are not retroactive

A metric filter only evaluates events ingested after it was created. If you created it at 10:00 and the errors happened at 09:50, the metric will be empty. Generate a known matching event to prove the pipeline end to end:

aws logs create-log-stream --log-group-name "$LG" --log-stream-name filter-test
aws logs put-log-events --log-group-name "$LG" --log-stream-name filter-test \
  --log-events timestamp=$(( $(date +%s) * 1000 )),message="ERROR synthetic test for metric filter"

After a minute or two the metric should appear:

aws cloudwatch list-metrics --namespace "App/Orders" --metric-name OrdersErrors

The data point is stamped with the log event's timestamp, not the ingestion time. If your shipper delivers logs late, data points land in past periods that the alarm has already evaluated.

Check 5: dimensions and default values

A metric filter can add up to three dimensions, taken from fields in JSON or space-delimited patterns:

aws logs put-metric-filter --log-group-name "$LG" \
  --filter-name orders-5xx-by-route \
  --filter-pattern '{ $.status >= 500 }' \
  --metric-transformations \
    'metricName=Orders5xx,metricNamespace=App/Orders,metricValue=1,unit=Count,dimensions={Route=$.route}'

Two rules matter here:

  • You can't set defaultValue on a transformation that has dimensions. Without a default, the metric has no data at all during quiet periods.
  • Every unique dimension value creates a separate metric. A field like requestId, userId or a full URL path makes thousands of metrics, costs money, and CloudWatch can stop publishing for a filter whose dimensions are too high-cardinality. Check the current limits; use a field with a handful of values, such as a route template or status class.

For a simple counter you alarm on, skip dimensions and set a default so the metric reports zero whenever logs arrive without a match:

aws logs put-metric-filter --log-group-name "$LG" \
  --filter-name orders-errors \
  --filter-pattern '?ERROR ?FATAL' \
  --metric-transformations \
    metricName=OrdersErrors,metricNamespace=App/Orders,metricValue=1,defaultValue=0,unit=Count

defaultValue is only emitted when events are ingested. If the application stops logging entirely, there is still no data.

Caution

put-metric-filter with an existing --filter-name replaces that filter's pattern and transformation. If an alarm or dashboard depends on the old metric name, changing the name here breaks it without any warning.

Check 6: line the alarm up with the metric

Once list-metrics shows the metric, compare it with the alarm:

aws cloudwatch describe-alarms --alarm-names orders-errors \
  --query 'MetricAlarms[].{ns:Namespace,metric:MetricName,dims:Dimensions,stat:Statistic,unit:Unit,period:Period,missing:TreatMissingData}'
  • Namespace and metric name are case-sensitive and must match exactly.
  • If the filter has dimensions, the alarm must specify all of them with exact values. An alarm with no dimensions does not aggregate across dimensioned metrics.
  • If the alarm sets Unit and the filter published a different unit (or none), the alarm sees no data. Remove Unit from the alarm unless you need it.
  • Use the Sum statistic for counters. Average of a filter that emits 1 per match is always 1.

For error counters, --treat-missing-data notBreaching keeps quiet periods from flipping the alarm to INSUFFICIENT_DATA:

aws cloudwatch put-metric-alarm --alarm-name orders-errors \
  --namespace App/Orders --metric-name OrdersErrors \
  --statistic Sum --period 60 --evaluation-periods 5 --datapoints-to-alarm 3 \
  --threshold 5 --comparison-operator GreaterThanOrEqualToThreshold \
  --treat-missing-data notBreaching \
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:oncall

Prevent it next time

  • Keep a sample of real log lines next to each filter definition and run test-metric-filter in CI before deploying the filter.
  • Prefer structured JSON logs end to end, and test JSON selectors against the exact format your log shipper writes.
  • Keep metric filter dimensions low-cardinality; for per-request metrics, publish embedded metric format from the application instead.
  • After creating a filter, push one synthetic matching event and confirm the metric and the alarm both see it.
  • Manage filters and alarms together in Terraform or CloudFormation so a renamed metric can't orphan its alarm.