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_ACCESSgroup. 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
defaultValueon 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,userIdor 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-filterwith an existing--filter-namereplaces 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
Unitand the filter published a different unit (or none), the alarm sees no data. RemoveUnitfrom the alarm unless you need it. - Use the
Sumstatistic for counters.Averageof 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-filterin 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.