AWS Lesson 74 of 123

A Structured Logging Pipeline on AWS: JSON Logs, CloudWatch Metric Filters, and Firehose to OpenSearch

In a nutshell

Picture a busy shipping warehouse. In the bad version, every parcel that arrives is a handwritten sticky note tossed onto one giant heap. To answer “how many parcels from Acme went to the wrong city today?” someone has to read the entire pile by hand. That heap is what free-text logs (printf strings) feel like the moment you have real traffic.

A structured logging pipeline turns that heap into a barcoded warehouse. Every log line becomes a small, labelled parcel — a JSON object with named, typed fields (service, status_code, latency_ms). Now the machinery can do the reading for you: scanners at the door count parcels automatically (CloudWatch metric filters → alarms), a librarian answers ad-hoc questions in seconds (CloudWatch Logs Insights), a conveyor belt carries copies to a searchable stockroom (Kinesis Data Firehose → OpenSearch), and anything mislabelled drops into a returns bin instead of being lost (S3 backup). The old copies age down to a cheap basement archive (S3 + Glacier).

That is the whole lesson in one sentence: emit JSON once, then let CloudWatch, Firehose, and OpenSearch each read the same well-labelled events for a different job — alerting, ad-hoc search, dashboards, and archive — instead of grepping a wall of text during an incident.

Level: Intermediate · Time: ~35 min

Prerequisites

After this lesson you can

Structured logging pipeline: CloudWatch → Firehose → OpenSearch

The diagram is the whole pipeline on one line: compute emits JSON into a CloudWatch log group, where metric filters raise alarms and Logs Insights answers questions; a subscription filter taps the same stream into Kinesis Data Firehose, which buffers and runs a Lambda transform that flattens each record into one document per event before delivering to OpenSearch — with any rejected document falling into S3 instead of vanishing.

Free-text logs are where observability goes to die. The moment you need to count errors by tenant, alarm on p99 latency, or correlate a request across three services, a wall of printf strings forces you into brittle regex and full-text scans. This article builds the alternative end to end on AWS: a logging contract that emits JSON, CloudWatch metric filters and Logs Insights for analysis, and a Kinesis Data Firehose path that streams those same logs into OpenSearch in real time — encrypted, least-privilege, and cost-aware.

1. Why structured JSON beats free-text

The core idea is that a log line is a structured event, not a sentence. Once every field is addressable, querying becomes selection instead of parsing, and CloudWatch can index discovered fields automatically.

Pick a logging contract and enforce it across every service. A minimal but production-grade shape:

{
  "timestamp": "2026-04-14T09:21:04.512Z",
  "level": "ERROR",
  "service": "checkout",
  "env": "prod",
  "trace_id": "1-66134a30-1f2c3d4e5f6a7b8c9d0e1f2a",
  "request_id": "8f3c1b2a-0d4e-4a6b-9c8d-7e6f5a4b3c2d",
  "tenant_id": "acme",
  "route": "POST /orders",
  "status_code": 502,
  "latency_ms": 1840,
  "message": "upstream payment gateway timed out"
}

Three rules make or break this contract:

CloudWatch automatically discovers fields in JSON logs and exposes them to Logs Insights as service, status_code, and so on. With free-text you would parse them out by hand on every query. That single difference is why the contract pays for itself within a week.

The mental model: a log line is a row, not a sentence

If you have ever written SQL, the payoff is easy to see. A structured log line is a row in a table you never had to declare: each JSON key is a column, each event is a row, and the query engines (Logs Insights, OpenSearch) infer the columns for you. Free-text logging throws that table away and forces every reader to reconstruct the columns with a regular expression, on every query, forever.

Question you ask during an incident Free-text logs Structured JSON
Count 5xx by route in the last hour grep plus a manual tally, or a bespoke regex filter status_code>=500 | stats count() by route
p99 latency per route impossible without first extracting the number stats pct(latency_ms,99) by route
Every line for one request grep <id> if you happened to log it filter request_id="…" — the ID is a first-class field
Alarm on error rate scrape logs into a metric with a Lambda a metric filter, zero extra infrastructure

Why types are load-bearing. OpenSearch (and to a lesser extent metric filters) infer a field’s type from the first document that carries it. If status_code arrives as the number 502 first, the field is mapped long; a later document that sends the string "502" is a mapping conflict and gets rejected. This is the single most common way a logging pipeline breaks in production, and it is why “stable field names and types” is rule one, not a nicety. We meet this exact failure in the Enterprise scenario below.

Why cardinality is load-bearing. Every distinct value of a field you turn into a metric dimension is a separate CloudWatch custom metric with its own monthly cost, and every distinct field name in OpenSearch consumes mapping and field-data memory. tenant_id (hundreds of values) is fine; user_id (millions), a raw URL with a query string, or a full session token will blow up both your custom-metric bill and your OpenSearch cluster state. Keep high-cardinality identifiers as searchable fields in OpenSearch, never as metric dimensions.

There is a third way to get metrics out of logs that sidesteps the metric-filter cardinality limit entirely: the embedded metric format (EMF). You write a specially shaped JSON log and CloudWatch extracts the metrics asynchronously, so you can attach high-cardinality properties for later drill-down while only a few low-cardinality dimensions become billed metrics. We compare EMF with metric filters below.

2. Getting logs into CloudWatch

The destination is always a CloudWatch log group containing log streams. How you fill it depends on the compute.

Lambda. Anything you write to stdout/stderr lands in /aws/lambda/<function-name>. Just emit JSON — most runtimes’ structured loggers (Powertools for AWS Lambda, for example) already do. Set the format to JSON so platform fields are structured too:

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

EC2 / on-prem with the CloudWatch agent. The unified agent tails files and ships them. A minimal config:

{
  "logs": {
    "logs_collected": {
      "files": {
        "collect_list": [
          {
            "file_path": "/var/log/app/checkout.json",
            "log_group_name": "/app/checkout",
            "log_stream_name": "{instance_id}",
            "retention_in_days": 30
          }
        ]
      }
    }
  }
}

Start it with the config you just wrote:

sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
  -a fetch-config -m ec2 \
  -c file:/opt/aws/amazon-cloudwatch-agent/etc/config.json -s

ECS on Fargate. The simple path is the awslogs driver, which sends each container’s stdout straight to a log group:

{
  "logConfiguration": {
    "logDriver": "awslogs",
    "options": {
      "awslogs-group": "/ecs/checkout",
      "awslogs-region": "us-east-1",
      "awslogs-stream-prefix": "checkout"
    }
  }
}

When you need routing or parsing at the source — sending some streams to CloudWatch and others to S3, or attaching metadata — switch the log driver to awsfirelens, which runs a Fluent Bit sidecar. FireLens is the right tool when one log driver’s worth of configuration is not enough; for a single JSON stream to CloudWatch, awslogs is less to operate.

Whichever path you choose, the output is identical: JSON events in a log group, ready for the rest of the pipeline.

Log groups, streams, and who writes what

Two nouns do all the work in CloudWatch Logs, and beginners conflate them:

The rule of thumb: one log group per component, one stream per running instance of it. Sharding by stream is automatic; you reason about, and pay attention to, the group.

Compute How JSON gets in What to set
Lambda stdout/stderr → /aws/lambda/<fn>; enable JSON log format LogFormat=JSON, ApplicationLogLevel, optional custom LogGroup
ECS on Fargate awslogs driver → one group per service group, region, stream-prefix
ECS with routing awsfirelens (Fluent Bit sidecar) route/parse/enrich, then multiple destinations
EC2 / on-prem unified CloudWatch agent tails files collect_list, group, stream, retention
EKS Fluent Bit / Fluentd DaemonSet (Container Insights) node-level shipper, one group per namespace/app

Lambda’s advanced logging controls are underused. Setting ApplicationLogLevel=INFO and SystemLogLevel=WARN lets the platform drop debug noise before it is ingested — you pay CloudWatch ingestion only for what clears the level filter, which is often the single biggest lever on a chatty function’s bill. LogFormat=JSON also structures the platform’s own START/END/REPORT lines so they stop polluting your field discovery.

FireLens vs awslogs, decided in one line: if a single container’s stdout goes to exactly one CloudWatch group, use awslogs — it is one driver and nothing to operate. The moment you need to fan a stream out (some records to CloudWatch, some straight to S3 or a third party), parse or enrich at the source, or reduce volume before ingest, switch to awsfirelens and let Fluent Bit do it. Do not reach for FireLens just because it exists; it is a sidecar you now own.

3. Querying with CloudWatch Logs Insights

Logs Insights is the interactive query layer. Because the logs are JSON, fields are already parsed and you go straight to filter and stats. Error rate by route over the queried window:

fields @timestamp, route, status_code
| filter status_code >= 500
| stats count(*) as errors by route
| sort errors desc

Latency percentiles, which free-text logging cannot give you at all without first extracting the number:

fields latency_ms, route
| filter ispresent(latency_ms)
| stats
    pct(latency_ms, 50) as p50,
    pct(latency_ms, 90) as p90,
    pct(latency_ms, 99) as p99
  by route

Trace a single request across whatever streams it touched:

fields @timestamp, service, level, message
| filter request_id = "8f3c1b2a-0d4e-4a6b-9c8d-7e6f5a4b3c2d"
| sort @timestamp asc

Run a query from the CLI and poll for the result:

qid=$(aws logs start-query \
  --log-group-name /ecs/checkout \
  --start-time $(date -d '1 hour ago' +%s) \
  --end-time $(date +%s) \
  --query-string 'fields @timestamp, route, status_code | filter status_code >= 500 | stats count(*) by route' \
  --query 'queryId' --output text)

aws logs get-query-results --query-id "$qid"

Save the queries your team reruns (error rate, slow routes, a specific tenant’s traffic) so they are one click away during an incident instead of retyped from memory.

Cross-group searches matter at scale. You can pass multiple --log-group-name values, or query a log group field index. For fleet-wide investigations, organize related groups so a single query spans the whole service.

Logs Insights vs OpenSearch Dashboards — when to use which

Both let you search the same events, and part of the confusion of this architecture is that you end up with two query surfaces. They are for different jobs:

CloudWatch Logs Insights OpenSearch Dashboards
Best at ad-hoc questions during an incident, on data already in CloudWatch saved dashboards, long retention, full-text relevance, team-wide visualisations
Setup zero — it is just there a domain/collection, index templates, Firehose delivery
Query model purpose-built pipe language (also PPL and SQL now) Dashboards Query Language / Lucene / PPL
Cost model per GB scanned per query a cluster you run 24/7 (instances + storage)
Retention sweet spot hours to ~30 days (hot) 30–90 days warm, longer via UltraWarm/cold

The practical split most teams land on: Insights for “what is happening right now,” Dashboards for “what does normal look like over the last month.” You are not doing redundant work — the subscription filter deliberately sends only a subset (say ERROR/WARN) to OpenSearch, while CloudWatch keeps everything short-term.

Two newer CloudWatch features change the Insights cost/latency story and are worth knowing:

Whatever surface you use, the Insights cost rule is the same and it is worth repeating: bound the time range, filter before you stats, and never fields @message across a week of a chatty group. A query bills for every byte in the time window it has to open, not for the rows it returns.

4. Metric filters: alarms straight from log fields

Logs Insights is for humans asking questions. For machines watching continuously, use metric filters, which turn matching log events into CloudWatch metrics with no extra infrastructure — no Lambda, no scrape job.

Create a metric that counts 5xx responses, reading the value directly from the JSON field:

aws logs put-metric-filter \
  --log-group-name /ecs/checkout \
  --filter-name checkout-5xx \
  --filter-pattern '{ $.status_code >= 500 }' \
  --metric-transformations \
      metricName=Checkout5xx,metricNamespace=App/Checkout,metricValue=1,defaultValue=0

That { ... } syntax is the JSON metric-filter dialect: $.status_code addresses the field by JSON path. Setting defaultValue=0 is what makes the metric continuous — without it, the metric is sparse and alarms misbehave on the missing-data path.

You can also emit a field’s value as the metric, not just a count. Publish latency so you can alarm on it and graph it cheaply:

aws logs put-metric-filter \
  --log-group-name /ecs/checkout \
  --filter-name checkout-latency \
  --filter-pattern '{ $.latency_ms = * }' \
  --metric-transformations \
      metricName=CheckoutLatencyMs,metricNamespace=App/Checkout,metricValue='$.latency_ms'

Now wire an alarm. Alarm when 5xx count crosses a threshold over five minutes:

aws cloudwatch put-metric-alarm \
  --alarm-name checkout-5xx-high \
  --namespace App/Checkout --metric-name Checkout5xx \
  --statistic Sum --period 300 --evaluation-periods 1 \
  --threshold 10 --comparison-operator GreaterThanThreshold \
  --treat-missing-data notBreaching \
  --alarm-actions arn:aws:sns:us-east-1:111122223333:oncall

This is the cheapest reliable alerting you can build on AWS: the log you already emit becomes a metric, and the metric becomes a page. Reserve metric filters for the handful of signals you alarm on — every distinct dimension combination is a separate custom metric with its own cost — and leave exploratory slicing to Logs Insights.

Metric filters vs EMF — two ways to turn logs into metrics

A metric filter is the right first tool, but it has a hard edge: each dimension combination is a separate custom metric with its own cost, so it only scales for a handful of low-cardinality signals. When you need per-tenant or per-route metrics at high cardinality, reach for the embedded metric format (EMF) instead.

Metric filter Embedded Metric Format (EMF)
How it works CloudWatch scans the group, a pattern matches, a metric is emitted your app writes a specially shaped JSON log; CloudWatch extracts metrics from it asynchronously
Where the logic lives in the log-group config (ops owns it) in the application logger (dev owns it)
Cardinality low — every dimension set is a billed metric high-cardinality properties for drill-down, only chosen dimensions billed
Best for a few alarm signals from logs you already ship rich application metrics emitted deliberately, e.g. via Powertools

The filter-pattern dialect matters. The { … } form is the JSON dialect: $.status_code >= 500 addresses a field by JSON path, and you combine terms with && / ||. There is also a space-delimited dialect for unstructured lines ([ip, user, ..., status>=500]) — you will only need it for logs you do not control. Two knobs decide whether the resulting metric behaves:

Cost intuition: a metric filter itself is free; the custom metrics it creates are billed per metric per month (plus alarm and API costs). Ten alarm signals is fine. A metric filter that fans Checkout5xx out by tenant_id across 500 tenants is 500 metrics — that is an EMF job, or a Logs Insights query, not a metric filter.

5. Subscription filters and Kinesis Data Firehose

Metric filters give you numbers. To get the events themselves out of CloudWatch in real time — into OpenSearch for search and dashboards, or S3 for archive — use a subscription filter, which pushes matching log events to a destination as they arrive. The destination here is a Kinesis Data Firehose delivery stream.

First, an IAM role CloudWatch Logs can assume to write to Firehose. Trust policy:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "logs.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}

Create the subscription filter pointing at the delivery stream. An empty filter pattern forwards every event; narrow it to cut volume and cost:

aws logs put-subscription-filter \
  --log-group-name /ecs/checkout \
  --filter-name to-opensearch \
  --filter-pattern '{ $.level = "ERROR" || $.level = "WARN" }' \
  --destination-arn arn:aws:firehose:us-east-1:111122223333:deliverystream/checkout-logs \
  --role-arn arn:aws:iam::111122223333:role/CWLtoFirehoseRole

Records arriving at Firehose from a subscription filter are gzip-compressed and base64-encoded, and a single record can contain multiple log events. That is exactly why you need a transform before indexing — covered next. Also note a log group allows a limited number of subscription filters, so plan one Firehose fan-out rather than many overlapping subscriptions.

Choosing the destination, and the limits that bite

A subscription filter can push to four kinds of destination: Kinesis Data Streams, Kinesis Data Firehose, Lambda, or a cross-account destination. This pipeline uses Firehose because we want managed delivery to OpenSearch and S3 with buffering and a transform, and nothing to shard or scale. The trade-off between the two Kinesis services is worth internalising:

Kinesis Data Streams (KDS) Kinesis Data Firehose
You manage shards (or on-demand), consumers, checkpoints nothing — fully managed delivery
Replay yes, 24 h – 365 d retention no — it is a delivery pipe, not a buffer you re-read
Fan-out many independent consumers one configured destination (+ S3 backup)
Use it when you need multiple real-time consumers or replay you just want logs to land in OpenSearch/S3/Splunk

For a logging pipeline you almost always want Firehose. Reach for KDS only if several systems must independently consume the same log stream in real time, or you need to replay.

The limit that surprises people: a log group supports only a small, fixed number of subscription filters (two by default; you can request an increase). That is deliberate — it forces the fan-out to happen downstream of one Firehose, not by stacking many overlapping subscriptions on the group. Design for one subscription filter per group → one Firehose → many destinations, not five subscriptions each to a different place.

DirectPut vs stream-sourced. The delivery stream here is --delivery-stream-type DirectPut because CloudWatch Logs puts records straight into it. If instead you fronted it with a Data Stream, the type would be KinesisStreamAsSource. And remember the wire format the subscription filter produces: records are gzip-compressed, base64-encoded, and may pack several log events into one record — which is exactly why a transform Lambda is mandatory before indexing, not optional.

6. Transform and deliver to OpenSearch

Firehose can deliver to an Amazon OpenSearch Service domain directly, but raw CloudWatch records are not index-ready. Attach a Lambda transform that decompresses and flattens each record into one JSON document per log event.

The transform must return each record with a recordId, a result of Ok/Dropped/ProcessingFailed, and base64 data. A Python sketch:

import base64, gzip, json

def handler(event, _ctx):
    out = []
    for rec in event["records"]:
        payload = json.loads(gzip.decompress(base64.b64decode(rec["data"])))
        if payload.get("messageType") == "CONTROL_MESSAGE":
            out.append({"recordId": rec["recordId"], "result": "Dropped"})
            continue
        lines = []
        for ev in payload.get("logEvents", []):
            try:
                doc = json.loads(ev["message"])
            except json.JSONDecodeError:
                doc = {"message": ev["message"]}
            doc["@timestamp"] = ev["timestamp"]   # epoch ms from CloudWatch
            doc["log_group"] = payload["logGroup"]
            lines.append(json.dumps(doc))
        data = ("\n".join(lines) + "\n").encode()
        out.append({
            "recordId": rec["recordId"],
            "result": "Ok",
            "data": base64.b64encode(data).decode(),
        })
    return {"records": out}

Two correctness details that bite people: drop CONTROL_MESSAGE records (Firehose sends them to validate connectivity), and keep the output of every input record — a missing recordId fails the whole batch.

Now create the delivery stream wired to OpenSearch with the transform and buffering. The key knobs are buffering (deliver when the buffer hits a size in MB or an interval in seconds, whichever first) and an index rotation period so indices stay a manageable size:

aws firehose create-delivery-stream \
  --delivery-stream-name checkout-logs \
  --delivery-stream-type DirectPut \
  --amazon-open-search-service-destination-configuration '{
    "RoleARN": "arn:aws:iam::111122223333:role/FirehoseToOpenSearch",
    "DomainARN": "arn:aws:es:us-east-1:111122223333:domain/logs",
    "IndexName": "checkout-logs",
    "IndexRotationPeriod": "OneDay",
    "BufferingHints": { "SizeInMBs": 5, "IntervalInSeconds": 60 },
    "S3BackupMode": "FailedDocumentsOnly",
    "ProcessingConfiguration": {
      "Enabled": true,
      "Processors": [{
        "Type": "Lambda",
        "Parameters": [{
          "ParameterName": "LambdaArn",
          "ParameterValue": "arn:aws:lambda:us-east-1:111122223333:function:cwl-transform"
        }]
      }]
    },
    "S3Configuration": {
      "RoleARN": "arn:aws:iam::111122223333:role/FirehoseToOpenSearch",
      "BucketARN": "arn:aws:s3:::checkout-logs-backup"
    }
  }'

S3BackupMode set to FailedDocumentsOnly is non-negotiable for a log pipeline: any document OpenSearch rejects (almost always a mapping conflict) lands in S3 instead of vanishing, so you can inspect and replay it. Smaller buffers mean fresher data and more, smaller PUTs; larger buffers mean cheaper delivery and more latency. For a logging pipeline, 60s / 5 MB is a sane default — tune toward larger if cost dominates, smaller if you live in the dashboards during incidents.

OpenSearch: domains vs Serverless, and keeping indices healthy

Firehose can deliver to two flavours of the destination, and the choice changes your cost and operating model:

OpenSearch Service domain OpenSearch Serverless collection
Capacity you size data/master nodes, EBS, UltraWarm auto-scaling OCUs, no nodes to size
Cost shape instance-hours 24/7 (predictable floor cost) pay for compute units used (spiky-friendly, minimum OCUs)
Tiering hot → UltraWarmcold, built in managed; no manual tiering
Firehose config AmazonopensearchserviceDestination… AmazonOpenSearchServerlessDestination…
Reach for it when steady high volume, need UltraWarm/fine control bursty or unpredictable log volume, less ops

Index rotation and ISM are how you stop one index from eating the cluster. IndexRotationPeriod: OneDay makes Firehose write to checkout-logs-2026-04-14, -04-15, and so on. On the OpenSearch side an Index State Management (ISM) policy then ages those indices automatically: hot for a few days, roll to UltraWarm (S3-backed, cheaper, slower) for weeks, cold for months, then delete. Without rotation + ISM you get one ever-growing index, slow queries, and an eventual red cluster.

Pin the mapping; do not trust dynamic mapping. As the Enterprise scenario shows, dynamic mapping means the first document defines every field’s type and a later type mismatch is silently rejected into S3. An index template with dynamic: strict and explicit properties turns that into a loud failure at onboarding — in a test deploy, not a month later in prod.

Operational facts worth pre-loading:

7. Retention and cost

Logs are cheap to write and expensive to forget about. Three levers control the bill.

Log class. CloudWatch offers a Standard class and an Infrequent Access class. Infrequent Access costs less to ingest but supports a reduced feature set (notably it does not support metric filters or subscription filters). Keep alarm-driving and OpenSearch-bound groups on Standard; consider Infrequent Access for high-volume, rarely-queried debug logs you keep only for occasional Insights queries.

Retention. The default is never expire, which is a quiet, unbounded cost. Set it explicitly on every group:

aws logs put-retention-policy \
  --log-group-name /ecs/checkout \
  --retention-in-days 30

Archive tier. CloudWatch is not a cheap long-term store. Keep 14-30 days hot in CloudWatch and OpenSearch for incident response, and let the Firehose-to-S3 path (or a dedicated S3 destination) hold the cold copy. Apply S3 lifecycle rules to transition to Glacier-class storage for multi-year retention at a fraction of the cost, and query it with Athena when audit time comes.

A note on Insights cost: Logs Insights bills by data scanned per query. Always bound the time range, filter early before you stats, and prefer saved narrow queries over fields @message across a week of a chatty service.

Tier Lives in Typical window Use
Hot CloudWatch + OpenSearch 14-30 days Alarms, dashboards, incident response
Warm OpenSearch UltraWarm (optional) 30-90 days Slower interactive search
Cold S3 (+ Glacier classes) months to years Compliance, audit, replay

A worked cost example (representative us-east-1 list prices)

Numbers make the levers concrete. Take a service emitting 100 GB of logs per day. Prices below are representative on-demand list prices and vary by region and over time — always check the current pricing page — but the ratios are what teach the lesson.

Lever Cuts How
Drop log level before ingest ingestion (the big one) Lambda ApplicationLogLevel, app-side filtering
Narrow the subscription filter Firehose + OpenSearch forward only ERROR/WARN, not everything
Infrequent Access class ingestion high-volume, rarely-queried debug groups
Retention + ISM storage expire hot data; UltraWarm/cold the rest
S3 + Glacier for cold long-term storage lifecycle transition; query with Athena on demand
Bounded Insights queries per-query scan time-box, filter early, saved narrow queries

The headline: you control cost mostly at ingestion and at the subscription filter, not at storage. A team that obsesses over retention days while forwarding every DEBUG line to OpenSearch is optimising the wrong end.

8. Locking it down

Logs contain your most sensitive runtime data. Three controls are mandatory.

KMS encryption at rest. Encrypt the log group with a customer-managed key. The key policy must allow the CloudWatch Logs service principal to use it:

aws logs associate-kms-key \
  --log-group-name /ecs/checkout \
  --kms-key-id arn:aws:kms:us-east-1:111122223333:key/abcd-1234

Encrypt the OpenSearch domain, the S3 backup bucket, and the Firehose stream with KMS as well so the data is covered along the entire path.

Least-privilege IAM. The Firehose delivery role should grant only what delivery needs — es:ESHttpPost/es:ESHttpPut scoped to the one domain, s3:PutObject scoped to the backup bucket, lambda:InvokeFunction scoped to the transform, and the kms actions for the specific keys. No wildcards on resources. The CloudWatch-to-Firehose role should allow only firehose:PutRecord/firehose:PutRecordBatch on the single delivery stream.

Redact before it leaves the app. The cheapest place to handle PII is the logger. Never log raw tokens, full PANs, or passwords. For defense in depth, CloudWatch Logs data protection policies can detect and mask sensitive data identifiers (emails, credentials, and similar) at ingest, and the Firehose transform Lambda is a second chokepoint where you can drop or hash a field before it reaches OpenSearch. Layer all three rather than trusting any one.

Cross-account aggregation and the security details that matter

At org scale you do not want every account running its own OpenSearch. The pattern is a central logging account that owns the Firehose, OpenSearch, and S3, with member accounts shipping to it.

The KMS key policy is where people get stuck. Associating a customer-managed key is one CLI call, but the key policy must let the CloudWatch Logs service principal use the key, scoped to your log-group ARNs:

{
  "Effect": "Allow",
  "Principal": { "Service": "logs.us-east-1.amazonaws.com" },
  "Action": [
    "kms:Encrypt*", "kms:Decrypt*", "kms:ReEncrypt*",
    "kms:GenerateDataKey*", "kms:Describe*"
  ],
  "Resource": "*",
  "Condition": {
    "ArnLike": {
      "kms:EncryptionContext:aws:logs:arn":
        "arn:aws:logs:us-east-1:111122223333:log-group:/ecs/checkout"
    }
  }
}

Encrypt every hop with its own scoped key policy: the log group, the Firehose stream, the OpenSearch domain, and the S3 backup bucket. A gap anywhere means plaintext at rest on that hop.

Fine-grained access control (FGAC) on OpenSearch is the other must-have: run the domain in a VPC, enable FGAC, and map the Firehose delivery role to an OpenSearch role whose permissions are write to the index pattern and nothing else. Human analysts get a separate read role. Combined with the data protection policy at ingest (managed identifiers for emails, credentials, and the like, masked before anyone reads them) and redaction in the logger itself, you have the three layers the section above demands — app, log group, and transform — each catching what the others miss.

Enterprise scenario

A fintech platform team ran this exact pipeline across ~140 ECS services. Three weeks after launch, OpenSearch ingestion fell off a cliff for one domain while Firehose still reported ACTIVE. The FailedDocumentsOnly prefix in S3 was filling up: a newly onboarded service emitted status_code as the string "502" instead of a number, and OpenSearch had already inferred long for that field from the first write. Every document from that service after the rotation was a mapping conflict and got dropped. The contract said “numbers stay numbers” — but nothing enforced it, so a single team’s logger shipped strings.

The fix had two parts. Short term, they made the Firehose transform Lambda coerce the known-numeric fields instead of trusting upstream, so one bad emitter could not poison the index:

for k in ("status_code", "latency_ms"):
    v = doc.get(k)
    if isinstance(v, str) and v.lstrip("-").isdigit():
        doc[k] = int(v)

Long term, they stopped relying on dynamic mapping. They created an index template that pins types and rejects surprises, applied before any data lands:

curl -XPUT "https://<domain>/_index_template/checkout-logs" -H 'Content-Type: application/json' -d '{
  "index_patterns": ["checkout-logs-*"],
  "template": { "mappings": {
    "dynamic": "strict",
    "properties": {
      "status_code": { "type": "integer" },
      "latency_ms":  { "type": "integer" },
      "@timestamp":  { "type": "date" }
    }
  }}
}'

With dynamic: strict, a stray new field now fails loudly into S3 backup at onboarding time — in a test deploy, not silently in prod a month later.

Verify

Confirm each stage end to end:

# Logs are arriving and are valid JSON
aws logs tail /ecs/checkout --since 5m --format short

# Metric filter is producing data points
aws cloudwatch get-metric-statistics \
  --namespace App/Checkout --metric-name Checkout5xx \
  --start-time $(date -d '15 min ago' -u +%FT%TZ) \
  --end-time $(date -u +%FT%TZ) \
  --period 60 --statistics Sum

# Firehose is delivering, not erroring
aws firehose describe-delivery-stream \
  --delivery-stream-name checkout-logs \
  --query 'DeliveryStreamDescription.DeliveryStreamStatus'

# Documents are landing in OpenSearch
curl -s "https://<domain-endpoint>/checkout-logs-*/_count" | jq .

If _count is flat but Firehose is ACTIVE, check the S3 FailedDocumentsOnly prefix — mapping conflicts are the usual culprit, and the rejected document there tells you exactly which field changed type.

Checklist

Pitfalls

Going deeper

The core pipeline works; production is where the edges live. This section is the material an experienced engineer wants before running this at scale.

What actually happens between subscription filter and OpenSearch

Trace one error line end to end, because every failure mode hides in a seam:

  1. Your app writes one JSON line to stdout. The compute platform (Lambda/ECS/agent) delivers it to a log stream in the group. Ingestion latency is typically sub-second but is not ordered across streams.
  2. The subscription filter evaluates its pattern against each event and, on a match, hands the event to CloudWatch Logs’ delivery machinery, which assumes the CWLtoFirehoseRole and calls firehose:PutRecord. Events are batched and gzip-compressed; one Firehose record can carry many events, wrapped in a CloudWatch envelope (logGroup, logStream, messageType, logEvents[]).
  3. Firehose buffers by size or interval, whichever trips first, then invokes the transform Lambda with a batch. The Lambda has a hard 6 MB invocation-payload ceiling; keep it fast and idempotent, because Firehose retries a failing transform for a bounded window before parking the records in the processing-failed/ S3 prefix.
  4. Transformed records are buffered again and bulk-indexed into OpenSearch. A document whose type conflicts with the mapping is rejected by OpenSearch, and Firehose routes it to the failed-documents S3 prefix — Firehose still reports ACTIVE, which is why “green Firehose, flat _count” is the signature of a mapping problem.

The lesson of the seams: nothing is lost, but rejects go sideways into S3, not backwards into an error you would notice. Your monitoring must watch the failed prefixes, not just the Firehose status.

Ordering, duplicates, and idempotency

Scale and quota edges

Failure modes to pre-wire alarms for

Symptom Likely cause Where to look / fix
Firehose ACTIVE, OpenSearch _count flat mapping conflict failed-documents S3 prefix; pin types with a strict template
DeliveryToOpenSearch errors climbing domain throttling / red cluster / auth Firehose CloudWatch metrics; OpenSearch cluster health, FGAC role mapping
Whole batches missing transform dropped records or omitted recordId processing-failed/ prefix; return all records
Ingestion cost spike a new DEBUG-chatty deploy log level at source; narrow the subscription filter
Alarm flaps on no data sparse metric filter defaultValue=0 plus a deliberate treat-missing-data

Version and API caveats

Practice challenges

Do these in a throwaway account or region. Placeholders (111122223333, ARNs, <domain>) are illustrative — substitute your own. They escalate beginner → advanced.

1. Emit and verify a JSON contract (beginner). Make a Lambda emit the lesson’s JSON shape and confirm CloudWatch discovered the fields.

<details><summary>Solution</summary>

Set the function to JSON logs, then tail:

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

aws logs tail /aws/lambda/checkout --since 5m --format short

In Logs Insights, fields service, status_code | filter ispresent(status_code) returns rows with the fields already parsed — no parse needed. Why: if ispresent(status_code) is empty, your logger is emitting a string blob, not JSON, and nothing downstream can type the field. </details>

2. Alarm on 5xx with a metric filter (beginner). Turn status_code >= 500 into a paging alarm.

<details><summary>Solution</summary>

aws logs put-metric-filter \
  --log-group-name /ecs/checkout --filter-name checkout-5xx \
  --filter-pattern '{ $.status_code >= 500 }' \
  --metric-transformations metricName=Checkout5xx,metricNamespace=App/Checkout,metricValue=1,defaultValue=0

aws cloudwatch put-metric-alarm \
  --alarm-name checkout-5xx-high --namespace App/Checkout --metric-name Checkout5xx \
  --statistic Sum --period 300 --evaluation-periods 1 --threshold 10 \
  --comparison-operator GreaterThanThreshold --treat-missing-data notBreaching \
  --alarm-actions arn:aws:sns:us-east-1:111122223333:oncall

Why: defaultValue=0 makes the metric continuous so the alarm evaluates every period instead of sitting in INSUFFICIENT_DATA between errors. </details>

3. p99 latency by route in Logs Insights (intermediate). Write the query and make it cheap.

<details><summary>Solution</summary>

fields latency_ms, route
| filter ispresent(latency_ms)
| stats pct(latency_ms,50) as p50, pct(latency_ms,90) as p90, pct(latency_ms,99) as p99 by route
| sort p99 desc

Run it over a 15-minute window, not a week. Why: Insights bills per GB scanned in the time range; filter before stats and a tight window are the difference between cents and dollars per run. </details>

4. Narrow the subscription filter and reason about volume (intermediate). Forward only ERROR/WARN to Firehose and explain the cost effect.

<details><summary>Solution</summary>

aws logs put-subscription-filter \
  --log-group-name /ecs/checkout --filter-name to-opensearch \
  --filter-pattern '{ $.level = "ERROR" || $.level = "WARN" }' \
  --destination-arn arn:aws:firehose:us-east-1:111122223333:deliverystream/checkout-logs \
  --role-arn arn:aws:iam::111122223333:role/CWLtoFirehoseRole

Why: the pattern runs before Firehose, so INFO/DEBUG never leave CloudWatch — cutting Firehose ingest, OpenSearch storage, and the OpenSearch bill in one line, while everything stays queryable short-term in CloudWatch. </details>

5. Make the transform poison-proof (advanced). Harden the transform Lambda so one emitter sending status_code as a string cannot break the index.

<details><summary>Solution</summary>

Coerce known-numeric fields and always return every record:

NUMERIC = ("status_code", "latency_ms")

def coerce(doc):
    for k in NUMERIC:
        v = doc.get(k)
        if isinstance(v, str) and v.lstrip("-").isdigit():
            doc[k] = int(v)
    return doc

Apply coerce(doc) before json.dumps, keep the CONTROL_MESSAGE drop, and return the same recordId for every input. Why: OpenSearch types a field from the first write; coercing at the transform means one team’s bad logger fails its line, not the whole index — and no recordId is dropped, so batches never fail wholesale. </details>

6. Prevent silent drops with a strict template plus monitoring (advanced). Stop relying on dynamic mapping and detect rejects.

<details><summary>Solution</summary>

Pin types and reject surprises before data lands:

curl -XPUT "https://<domain>/_index_template/checkout-logs" -H 'Content-Type: application/json' -d '{
  "index_patterns": ["checkout-logs-*"],
  "template": { "mappings": { "dynamic": "strict",
    "properties": {
      "status_code": { "type": "integer" },
      "latency_ms":  { "type": "integer" },
      "@timestamp":  { "type": "date" } } } }
}'

Then alarm on the Firehose DeliveryToOpenSearch.Success metric dropping below 1, and watch the failed-documents S3 prefix for objects. Why: dynamic: strict turns a new or mistyped field into a loud failure at onboarding, and the Firehose metric plus the S3 prefix are how you notice rejects while Firehose still shows ACTIVE. </details>

Common beginner mistakes

These are misconceptions, not just symptoms — the wrong mental model that leads to the trap, and the right one.

Glossary

CloudWatchAWSStructuredLoggingFirehoseOpenSearch
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments