Docs / CloudWatch Logs

CloudWatch Logs

LocalEmu CloudWatch Logs implements all 107 operations. Log groups, streams, events, sequence tokens, retention metadata, and KMS key associations are served by the moto-ext metadata backend, which holds everything in-process. On top of that, 11 operations have LocalEmu-custom code: PutLogEvents evaluates metric filters and forwards matches to CloudWatch in real time; the subscription-filter pipeline gzip+base64-wraps matching events and pushes them to Lambda, Kinesis, or Firehose synchronously; DescribeLogGroups, DescribeLogStreams, and ListLogGroups are reimplemented for prefix/pattern semantics and pagination; and the tag operations are LocalEmu-side with a dedicated store.

Operation-level coverage: see the CloudWatch Logs coverage matrix.

Quick start

Terminal
$ awsemu logs create-log-group --log-group-name app/api
$ awsemu logs create-log-stream --log-group-name app/api --log-stream-name prod

$ awsemu logs put-log-events --log-group-name app/api --log-stream-name prod \
    --log-events '[
      {"timestamp": '$(date +%s000)', "message": "INFO request_id=abc status=200"},
      {"timestamp": '$(date +%s000)', "message": "ERROR request_id=def status=500"}
    ]' --query 'nextSequenceToken' --output text
00000000000000000000000000000000000000000000000001

$ awsemu logs filter-log-events --log-group-name app/api \
    --filter-pattern 'ERROR' --query 'events[].message'
["ERROR request_id=def status=500"]

$ awsemu logs describe-log-groups \
    --query 'logGroups[].{Name:logGroupName,Class:logGroupClass,Bytes:storedBytes}'
[{"Name": "app/api", "Class": "STANDARD", "Bytes": 64}]

Events land in the moto backend immediately, in insertion order per stream. Sequence tokens are monotonic and returned in the response. storedBytes on the group is the sum of message lengths across its streams.

Architecture

Code lives at services/logs/. The provider class LogsProvider at provider.py:59 combines two integration styles:

Persistence: accept_state_visitor (provider.py:64-68) declares both logs_backends and logs_stores as state roots. With PERSISTENCE=1, log groups, streams, events, subscription filters, metric filters, retention settings, KMS associations, and tags survive restart. In-flight subscription-filter dispatches do not survive (the next matching PutLogEvents after restart resumes normally).

Features supported

FeatureNotes
Log group lifecycleCreateLogGroup, DeleteLogGroup, DescribeLogGroups (prefix and pattern filters, mutually exclusive), ListLogGroups (pattern filter with nextToken + limit pagination).
Log stream lifecycleCreateLogStream, DeleteLogStream, DescribeLogStreams (accepts either logGroupName or logGroupIdentifier: ARN or name, validated mutually exclusive).
EventsPutLogEvents (with metric-filter evaluation and subscription-filter dispatch), GetLogEvents, FilterLogEvents (full filter-pattern syntax).
Subscription filtersPutSubscriptionFilter, DeleteSubscriptionFilter, DescribeSubscriptionFilters. Real-time dispatch to Lambda, Kinesis, and Firehose; destinations validated at filter-create time.
Metric filtersPutMetricFilter, DeleteMetricFilter, DescribeMetricFilters. Matching events publish to CloudWatch via PutMetricData on every PutLogEvents.
Filter pattern syntaxSpace-separated AND, quoted phrases, -term exclude, ?term at-least-one-of, JSON selectors of the form { $.path = "value" } or { $.path = 42 }. Implemented at provider.py:301-399.
RetentionPutRetentionPolicy, DeleteRetentionPolicy. Value stored and returned by DescribeLogGroups; see Known limitations on eviction.
KMS encryptionAssociateKmsKey, DisassociateKmsKey. kmsKeyId stored on the group and surfaced by DescribeLogGroups.
DestinationsPutDestination, DeleteDestination, DescribeDestinations, PutDestinationPolicy. Cross-account subscription targets.
Export tasksCreateExportTask, CancelExportTask, DescribeExportTasks. Stored as moto-managed task records.
Queries (Insights)StartQuery, StopQuery, GetQueryResults, DescribeQueries, PutQueryDefinition. See Known limitations.
TagsTagResource, UntagResource, ListTagsForResource, plus the deprecated TagLogGroup/UntagLogGroup/ListTagsLogGroup trio.
Account policiesPutAccountPolicy, DeleteAccountPolicy, DescribeAccountPolicies for data-protection and subscription-filter scopes.
PersistenceFull state restored across PERSISTENCE=1 restart: groups, streams, events, subscription/metric filters, retention, KMS associations, tags.

Subscription filters: Lambda, Kinesis, Firehose

PutSubscriptionFilter (provider.py:413-485) validates the destination at create time. It dispatches by ARN service segment:

Once attached, every PutLogEvents evaluates the filter pattern against each event (provider.py:497-511). Matching events are wrapped in the AWS-standard envelope, gzip-compressed, base64-encoded, and forwarded synchronously:

{
  "messageType": "DATA_MESSAGE",
  "owner":       "<account-id>",
  "logGroup":    "app/api",
  "logStream":   "prod",
  "subscriptionFilters": ["errors-only"],
  "logEvents":   [{"id": "...", "timestamp": ..., "message": "..."}, ...]
}

For Lambda, the envelope is wrapped one more level as {"awslogs": {"data": "<base64>"}} and delivered via Invoke. For Kinesis and Firehose, the raw gzip payload is sent as the record body (provider.py:553-578). If a roleArn was supplied on the filter, the destination client is built via connect_to.with_assumed_role.

Terminal
# 1. Create a Lambda that consumes the awslogs envelope
$ awsemu lambda create-function --function-name log-shipper \
    --runtime python3.12 --handler index.handler \
    --role arn:aws:iam::000000000000:role/lambda-role \
    --zip-file fileb://shipper.zip

# 2. Wire it as a subscription on the log group
$ awsemu logs put-subscription-filter \
    --log-group-name app/api --filter-name errors-only \
    --filter-pattern 'ERROR' \
    --destination-arn arn:aws:lambda:us-east-1:000000000000:function:log-shipper

# 3. Any matching event triggers the Lambda in real time.
#    The function receives the standard gzip+base64 envelope:
#    { "awslogs": { "data": "<base64(gzip(json))>" } }
$ awsemu logs put-log-events --log-group-name app/api --log-stream-name prod \
    --log-events '[{"timestamp": '$(date +%s000)', "message": "ERROR boom"}]' >/dev/null

$ awsemu logs filter-log-events \
    --log-group-name /aws/lambda/log-shipper --limit 1 --query 'events[0].message'
"Got: {\"messageType\":\"DATA_MESSAGE\",\"logGroup\":\"app/api\",\"logEvents\":[{...}]}"

Metric filters: cross-publish to CloudWatch

Every PutLogEvents call walks the group's metric filters (provider.py:70-105). For each event matching a filter pattern, every transformation in metricTransformations emits one PutMetricData call against cloudwatch.PutMetricData with the configured metricNamespace, metricName, and value. The value is parsed as a float when numeric; the special $size placeholder is recognised but emits a warning (it currently falls back to value 1). Metric filters are skipped silently when the cloudwatch service is disabled.

Terminal
$ awsemu logs put-metric-filter --log-group-name app/api \
    --filter-name error-count \
    --filter-pattern 'ERROR' \
    --metric-transformations '[{
      "metricName":      "ApiErrorCount",
      "metricNamespace": "App/Api",
      "metricValue":     "1"
    }]'

$ awsemu logs put-log-events --log-group-name app/api --log-stream-name prod \
    --log-events '[
      {"timestamp": '$(date +%s000)', "message": "ERROR a"},
      {"timestamp": '$(date +%s000)', "message": "INFO  b"},
      {"timestamp": '$(date +%s000)', "message": "ERROR c"}
    ]' >/dev/null

# The two ERROR matches landed in CloudWatch as a metric data point.
$ awsemu cloudwatch get-metric-statistics --namespace App/Api \
    --metric-name ApiErrorCount --statistics Sum \
    --start-time $(date -u -v-5M +%FT%TZ) --end-time $(date -u +%FT%TZ) \
    --period 60 --query 'Datapoints[].Sum'
[2.0]

Filter pattern syntax

The matcher at provider.py:301-399 implements the term-based syntax from the AWS docs. The same matcher is shared between FilterLogEvents, subscription-filter dispatch, and metric-filter evaluation, so behaviour is consistent across all three.

Terminal
# Substring / AND across space-separated terms
$ awsemu logs filter-log-events --log-group-name app/api \
    --filter-pattern 'ERROR request_id' --query 'events[].message'
["ERROR request_id=def status=500"]

# Exclude with -term  +  at-least-one-of with ?term
$ awsemu logs filter-log-events --log-group-name app/api \
    --filter-pattern '?ERROR ?WARN -healthcheck'

# JSON selector: only events whose JSON message has status >= 500
$ awsemu logs filter-log-events --log-group-name app/api \
    --filter-pattern '{ $.status = 500 }' --query 'events[].message'
["{\"status\":500,\"path\":\"/users\"}"]

Lambda invocation logs

Every Lambda invocation streams its stdout and stderr into a CloudWatch log group named /aws/lambda/<function-name>. The plumbing lives at services/lambda_/invocation/logs.py:

Terminal
$ awsemu lambda create-function --function-name greet \
    --runtime python3.12 --handler index.handler \
    --role arn:aws:iam::000000000000:role/lambda-role \
    --zip-file fileb://greet.zip

$ awsemu lambda invoke --function-name greet --payload '{"name":"Tarek"}' /tmp/out.json

# Stdout + stderr from the function were streamed into the log group
# /aws/lambda/<function-name> with one stream per invocation cohort.
$ awsemu logs filter-log-events \
    --log-group-name /aws/lambda/greet --limit 3 \
    --query 'events[].message'
[
  "START RequestId: 1234... Version: $LATEST",
  "Hello, Tarek",
  "END RequestId: 1234..."
]

Because the Lambda log path is just a CloudWatch Logs client, subscription filters and metric filters attached to /aws/lambda/<function-name> work the same way as on any user group.

VPC flow logs

When VPC flow logging is enabled on the Docker-backed network stack, the recorder at services/ec2/docker/flow_log_recorder.py buffers per-flow lines in-process and flushes every 60 seconds to the log group /localemu/vpc-flow-logs (stream name flow-YYYY-MM-DD). The group and stream are created on first flush; events use the AWS flow-log line format. From there, all normal CloudWatch Logs features apply: filter, subscribe to a Lambda, or wire a metric filter against the line shape.

Configuration

CloudWatch Logs has no service-specific environment variables in LocalEmu. Disable it globally with SERVICES=...,-logs (which also disables Lambda log shipping and VPC flow log forwarding). Disable just the cross-publish to CloudWatch metrics by disabling the cloudwatch service: PutLogEvents still runs, but the metric-filter loop is skipped (provider.py:81).

Integration points

Other LocalEmu services produce or consume from CloudWatch Logs.

ServiceHow it touches Logs
Lambdaservices/lambda_/invocation/logs.py: per-function worker thread streams stdout/stderr into /aws/lambda/<name>.
VPC (Docker backend)services/ec2/docker/flow_log_recorder.py: 60 s buffered flush to /localemu/vpc-flow-logs.
CloudWatch metricsPutLogEvents evaluates metric filters and calls cloudwatch.PutMetricData per match.
Lambda (subscription dest.)Subscription filters invoke target functions synchronously with the gzip+base64 awslogs envelope.
Kinesis (subscription dest.)Subscription filters call put_record with the gzip payload; partition key = log group name.
Firehose (subscription dest.)Subscription filters call put_record with the gzip payload as the record body.

Known limitations