Logit.io
AWS ECS application log dashboards from CloudWatch Logs
← Back to blog
11/10/2026 · 19 min read

AWS ECS application log dashboards from CloudWatch Logs

Logit.io Team
Logit.io Team
Technical Content Team

Last updated 11/10/2026

Log ManagementHow To GuidesOpensearch

The AWS ECS application logs pack in the Logit.io Dashboard Library gives you six OpenSearch Dashboards for ECS task logs that reach your Logs stack through CloudWatch Logs, covering log volume, application errors, requests, HTTP errors and slow requests, log groups and tasks, plus a log investigation timeline. Every panel reads logGroupName and logStreamName on logstash-* documents, so your forwarder has to keep both fields. The error and request boards only fill when your services write JSON lines with levelname, status and request_time and your Logstash filter decodes them as numbers.

This guide is for teams who already run services on Amazon ECS (EC2 or Fargate) and want those logs searchable and on a dashboard without building one by hand. It walks the whole path once: the awslogs settings in the task definition, the CloudWatch subscription that forwards each log group to your stack, the Logstash filter that turns JSON lines into fields, the checks that prove the fields are right before you install and what to do when a board stays empty.

Contents

Six dashboards and the question each answers

The pack is called AWS ECS application logs in the Dashboard Library, filed under Cloud. It installs six OpenSearch Dashboards that all read the stack's logstash-* index pattern with @timestamp as the time field. Every dashboard has the same shape: four headline tiles, a full-width trend, two breakdowns and a detail table you can filter from. The default time range is the last 24 hours with a 60-second refresh, and no panel overrides the range you pick.

DashboardWhat it answersFields it needs
Application logsAre ECS tasks still shipping? Lines, error lines, log groups, last event, levelslogGroupName, logStreamName, message, optional levelname
Application errorsWhich application and logger are writing ERROR, CRITICAL or WARNING lineslevelname, application_name, name
Application requestsRequest volume by status code and path, with mean response timemethod, path, status (number), request_time (number, seconds)
HTTP errors and slow requestsWhere 5xx, 4xx and requests of one second or longer are coming fromstatus, path, request_time
Log groups and tasksWhich log groups and task streams produce the volumelogGroupName, logStreamName
Log investigationOne timeline for a log group, application, logger or request IDAll of the above, plus requestId

Every panel starts from the same base filter, written in Lucene:

_exists_:logGroupName AND _exists_:logStreamName AND NOT _exists_:eventSource

That one line explains most of the behaviour you'll see. A document counts as an ECS application line when it carries both CloudWatch fields. The eventSource exclusion keeps CloudTrail events out, because CloudTrail also travels through CloudWatch Logs and would otherwise swamp the application boards. The error tiles add levelname:ERROR OR levelname:CRITICAL, the request boards add _exists_:status AND _exists_:path and the slow-request tiles add request_time:[1 TO 3600]. Error, 4xx, 5xx and slow-request tiles stay neutral at zero and turn red from one.

The split matters when you plan the work. Two of the six boards (Application logs and Log groups and tasks) fill from the CloudWatch fields alone, so they work for any service, including ones that log plain text. The other four get richer the more structure your services write.

From ECS task to logstash-* in three hops

The path the pack is built for is the one most ECS teams already have. Containers write to stdout and stderr, the awslogs log driver sends each line to a CloudWatch log group and a subscription on that log group forwards batches to your Logit.io stack, where Logstash splits them into one document per line.

ECS task stdout goes through the awslogs driver into a CloudWatch log group then a subscription forwarder sends batches to Logstash on your Logit.io stack which writes logstash indices for the six ECS dashboards

Hop one: awslogs in the task definition

This is the logConfiguration block for one container. It works on both Fargate and the EC2 launch type:

"logConfiguration": {
  "logDriver": "awslogs",
  "options": {
    "awslogs-group": "/ecs/orders-api",
    "awslogs-region": "eu-west-2",
    "awslogs-stream-prefix": "ecs",
    "awslogs-create-group": "true",
    "mode": "non-blocking",
    "max-buffer-size": "25m"
  }
}

Three settings shape what the dashboards show:

  • awslogs-group becomes logGroupName. One log group per service (/ecs/orders-api, /ecs/checkout-api) gives you a readable Log groups breakdown and a clean way to derive the application name later.
  • awslogs-stream-prefix is required on Fargate. ECS names each stream prefix/container-name/task-id, so logStreamName looks like ecs/orders-api/5a44bf52ae040c03. That's what the Task log streams panel counts, one stream per running task.
  • mode decides what happens when CloudWatch can't keep up. non-blocking buffers lines in memory and drops them if the buffer fills, which protects the application. blocking never drops a line but can stall writes to stdout. Set it explicitly so nobody has to guess which one a service uses.

awslogs-create-group needs logs:CreateLogGroup on the task execution role, on top of logs:CreateLogStream and logs:PutLogEvents. If you'd rather create log groups in Terraform or CloudFormation, drop the option and create them there.

Hop two: subscribe the log group to your stack

The AWS Lambda CloudWatch integration guide has a forwarder function that unzips each CloudWatch batch and posts it to your stack's API with your stack API key. Its parser is the important part: every event goes out with message, logGroupName, logStreamName and timestamp, which are exactly the fields the pack keys on.

The guide's code is written for an older Node.js runtime. The ES module below is a runtime update of that documented forwarder for Node.js 22, not the docs sample, and it hasn't been run as a Lambda against a stack. It keeps the same payload and the same fields, uses the built-in fetch, decodes the batch as UTF-8 so non-ASCII log lines survive and throws on a failed post so Lambda retries the batch:

// index.mjs on the Node.js 22.x runtime
import { gunzipSync } from 'node:zlib';

const host = process.env.LOGIT_API_HOST;  // API hostname from the integration guide
const apiKey = (process.env.logitApiKey || '').trim();

export const handler = async (event) => {
  if (!host || !apiKey) throw new Error('Set LOGIT_API_HOST and logitApiKey');

  const batch = JSON.parse(
    gunzipSync(Buffer.from(event.awslogs.data, 'base64')).toString('utf8')
  );
  if (batch.messageType !== 'DATA_MESSAGE') return;

  const cloudWatchLogEvents = batch.logEvents.map((e) => ({
    message: e.message.trim(),
    logGroupName: batch.logGroup,
    logStreamName: batch.logStream,
    timestamp: new Date(e.timestamp).toISOString(),
  }));

  const res = await fetch(`https://${host}/v2`, {
    method: 'POST',
    headers: { apikey: apiKey, 'content-type': 'application/json' },
    body: JSON.stringify({ cloudWatchLogEvents }),
  });
  if (res.status !== 202) {
    throw new Error(`Logit.io returned ${res.status}: ${await res.text()}`);
  }
};

Set logitApiKey as the guide describes. Set LOGIT_API_HOST to the API hostname the guide puts in place of @logitApiEndpoint, hostname only with no https://. The guide doesn't document that env var, because its Node 16 sample inlines the hostname.

Then point each service's log group at the function. In the console that's Add trigger, CloudWatch Logs, pick the log group. From the CLI it's two commands per log group, one to let CloudWatch Logs invoke the function and one to create the subscription filter. Replace <ACCOUNT_ID> and the names with your own:

aws lambda add-permission \
  --function-name logit-cloudwatch-forwarder \
  --statement-id cwlogs-orders-api \
  --principal logs.amazonaws.com \
  --action lambda:InvokeFunction \
  --source-arn "arn:aws:logs:eu-west-2:<ACCOUNT_ID>:log-group:/ecs/orders-api:*"

aws logs put-subscription-filter \
  --log-group-name /ecs/orders-api \
  --filter-name logit \
  --filter-pattern "" \
  --destination-arn "arn:aws:lambda:eu-west-2:<ACCOUNT_ID>:function:logit-cloudwatch-forwarder"

An empty filter pattern forwards every line. You can narrow it later, but start with everything so the volume tiles mean something. Never subscribe the forwarder's own log group (/aws/lambda/logit-cloudwatch-forwarder): each invocation writes a line, which triggers another invocation, and you've built a loop.

Hop three: split the batch in Logstash

The forwarder sends one request per batch, so the whole batch arrives at Logstash as a single JSON string in message. The same integration guide gives the filter that splits it back into lines. Add it under Stack, Settings, Logstash Pipelines if your stack doesn't already have it (the guide on configuring Logstash filters shows where):

if ([message] =~ "cloudWatchLogEvents") {
  json {
    source => "message"
    remove_field => [ "message" ]
  }
  split {
    field => "cloudWatchLogEvents"
  }
  mutate {
    rename => [
      "[cloudWatchLogEvents][logGroupName]", "logGroupName",
      "[cloudWatchLogEvents][logStreamName]", "logStreamName",
      "[cloudWatchLogEvents][message]", "message",
      "[cloudWatchLogEvents][timestamp]", "timestamp"
    ]
    remove_field => "cloudWatchLogEvents"
  }
  date {
    match => ["timestamp", "ISO8601"]
    target => "@timestamp"
    remove_field => [ "timestamp" ]
  }
}

Without this block the pack sees nothing. The fields sit nested under cloudWatchLogEvents, the base filter doesn't match, and every document is one giant batch instead of one line. With it, you have one document per log line in a daily logstash- index, @timestamp set from the CloudWatch event time and the two fields at the top level. That's enough for the Application logs and Log groups and tasks boards straight away.

Write app logs the dashboards can count

The other four boards read keys from the log line itself, so they depend on how your services log. The pack uses Python logging names because that's what the field shape was modelled on: levelname for the level, name for the logger, plus application_name and requestId. Request lines add method, path, status and request_time.

Here's a Python service with python-json-logger 3.1 or later that writes exactly that shape to stdout:

import logging
import sys
import time
import uuid

from pythonjsonlogger.json import JsonFormatter

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter(
    "%(asctime)s %(levelname)s %(name)s %(message)s",
    static_fields={"application_name": "orders-api"},
))
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(logging.INFO)

request_log = logging.getLogger("app.request")

def log_request(method, path, status, started, request_id):
    request_log.info(
        f"{method} {path} {status}",
        extra={
            "requestId": request_id,
            "method": method,
            "path": path,
            "status": int(status),  # a number, not "200"
            "request_time": round(time.monotonic() - started, 3),  # seconds
        },
    )

started = time.monotonic()
log_request("GET", "/api/orders", 200, started, str(uuid.uuid4()))

Each request produces one line like this:

{"asctime": "2026-11-10 09:14:03,512", "levelname": "INFO", "name": "app.request", "message": "GET /api/orders 200", "application_name": "orders-api", "requestId": "4f6c1d9e-8a0b-4c55-9d1e-2b7f3a61c0aa", "method": "GET", "path": "/api/orders", "status": 200, "request_time": 0.004}

Use logger.exception() inside except blocks. The traceback lands in an exc_info key on the same JSON line, at level ERROR, so it shows up on Application errors as one event instead of thirty unlabelled lines.

Other languages need the same keys, and most loggers get close with a setting or a rename:

LoggerWhat it writesWhat to change
Python logging with a JSON formatterlevelname, nameNothing; add request keys with extra
Node.js pinoNumeric level (30, 50), msgSet formatters: { level: (label) => ({ level: label }) }, then map level in Logstash
Node.js winston (JSON format)level: "error"Map level in Logstash
Go log/slog JSON handlerlevel: "ERROR", msgMap level; the Logstash block below also turns WARN into WARNING

If your request logs carry duration in milliseconds, keep that key and derive request_time in seconds in Logstash, shown in the next section. The slow-request threshold is one second, so a value of 1200 that is really milliseconds would make every request look slow.

Decode the JSON and fix the types in Logstash

Add this block below the CloudWatch block in the same pipeline. It only touches lines that came through the forwarder and look like JSON, so other sources on the stack are left alone:

if [logGroupName] and [logStreamName] {
  if [message] =~ /^\s*\{/ {
    json {
      source => "message"
      skip_on_invalid_json => true
    }
  }

  # Level names from loggers that write "level" instead of "levelname"
  if [level] and ![levelname] {
    mutate { copy => { "level" => "levelname" } }
    mutate { uppercase => [ "levelname" ] }
  }
  if [levelname] == "WARN"  { mutate { replace => { "levelname" => "WARNING" } } }
  if [levelname] == "FATAL" { mutate { replace => { "levelname" => "CRITICAL" } } }

  # application_name from the log group when the app doesn't set it
  if ![application_name] {
    grok {
      match => { "logGroupName" => "^/ecs/%{GREEDYDATA:application_name}$" }
      tag_on_failure => []
    }
  }

  # Milliseconds to seconds, if that's what your app logs
  if [duration_ms] and ![request_time] {
    ruby { code => "event.set('request_time', event.get('duration_ms').to_f / 1000)" }
  }

  # The request boards need numbers
  mutate {
    convert => {
      "status" => "integer"
      "request_time" => "float"
    }
  }
}

A few details are worth knowing before you save it:

  • The json filter has no target, so keys land at the top level and the app's own message replaces the raw line. That keeps the Recent application log lines table readable.
  • Lines that aren't JSON (a startup banner, a library printing to stderr) pass through untouched. They still count as log lines, and the Log levels donut shows them as Unknown.
  • convert does nothing when a field is missing, so plain log lines don't pick up empty status values.
  • The default template is applied when an index is created, so convert the types in Logstash rather than editing the live mapping. Existing daily indices keep their first type until they age out. A string "200" that reaches a new daily index first gets mapped as text for that whole day.

If your Grok or JSON parsing isn't doing what you expect, the post on fixing Grok parse failures with the Grok Debugger covers testing a pattern before it goes live.

Check the fields before you install

Five minutes of checks now saves you staring at an empty donut later. Start in Discover on logstash-* with the query language set to Lucene and paste the base filter:

_exists_:logGroupName AND _exists_:logStreamName AND NOT _exists_:eventSource

If you prefer DQL, the same filter is logGroupName:* and logStreamName:* and not eventSource:*. Open one document and confirm logGroupName reads /ecs/..., message is the app's message rather than a JSON string and levelname is present. Then add AND _exists_:status AND _exists_:path to see only request lines.

Next, check the types with the OpenSearch API on your stack. The endpoint and stack API key are under Settings, Endpoints, and the guide to connecting to your stack's OpenSearch API covers both authentication modes. In API key mode:

curl -s "https://<stack-id>-es.logit.io/logstash-*/_field_caps?fields=logGroupName*,logStreamName*,levelname*,status,request_time&apikey=<stack-api-key>"

You want status as long and request_time as float. The string fields should be text, each with a .keyword sub-field (the trailing * in the request returns those too), because the breakdowns and distinct counts group by logGroupName.keyword, logStreamName.keyword, levelname.keyword, name.keyword, application_name.keyword, path.keyword and requestId.keyword. If status comes back with two types, some daily indices mapped it as text. New indices will be right once the convert is in place, and the old ones age out with your retention.

Finally, count what the pack will see in the last 24 hours:

curl -s -H 'Content-Type: application/json' \
  "https://<stack-id>-es.logit.io/logstash-*/_count?apikey=<stack-api-key>" -d '{
  "query": { "bool": {
    "filter": [
      { "range":  { "@timestamp": { "gte": "now-24h" } } },
      { "exists": { "field": "logGroupName" } },
      { "exists": { "field": "logStreamName" } }
    ],
    "must_not": [ { "exists": { "field": "eventSource" } } ]
  } }
}'

A non-zero count means the Library will flag the pack. Add { "exists": { "field": "status" } } to the filter list to check the request boards separately.

Install the pack and use it on a bad deploy

Open your Logs stack's card in your Logit.io account and choose Dashboard Library. The Library checks the stack's field mappings and event counts over the last 24 hours, so once ECS lines have arrived the pack shows a Data detected badge. If you've only just started forwarding, press Rescan stack. The post on how Dashboard Library detection works explains why some dashboards arrive ticked and others don't. Select all six (or the ones you want), choose Install dashboards and open them in OpenSearch Dashboards a minute or two later. The Dashboard Library guide covers permissions and what gets installed.

Where the pack earns its keep is the first ten minutes after a deploy goes wrong. A typical path:

Bad deploy triage path from the HTTP errors and slow requests board to paths with 5xx then failed requests to investigate then log investigation filtered by request ID
  1. HTTP errors and slow requests. The 5xx tile has gone red and the trend shows when it started. Line that up with the deploy time.
  2. Paths with 5xx. The donut shows whether it's one endpoint or everything. One path usually means a code change; every path usually means a dependency, a missing secret or a bad config value.
  3. Failed requests to investigate. Filter to the bad path and copy a requestId from the table.
  4. Log investigation. Filter on requestId and you get every line that request wrote, across loggers, in order. The ERROR line with its exc_info traceback is usually two or three lines above the request log.

Then check Log groups and tasks. If one task stream has gone quiet while the others carry on, you're looking at a task that's crash looping or stuck, not an application bug. The Last event received tile on every board tells you whether quiet means healthy or means logs have stopped arriving.

When a board stays empty

  • The pack isn't detected at all. The two CloudWatch fields aren't at the top level. Look at a document in Discover: if you see cloudWatchLogEvents.logGroupName or one huge document per batch, the split filter is missing or sits after a filter that already consumed message. If you forward with something other than the Lambda function, rename its log group and stream fields to logGroupName and logStreamName.
  • Detected, installed, still empty. The lines are going to an index outside logstash-*, usually because a custom Logstash output names its own index. The pack reads logstash-* only, and index names containing cloudwatch only act as a hint that data is on its way.
  • Every line shows level Unknown. The JSON isn't being decoded (the message field still holds the raw JSON) or your logger writes level and the mapping block isn't there. Error tiles stay at zero in both cases, which looks like good news but isn't.
  • Request counts are fine but the status donut and mean response time are empty. status or request_time arrived as strings. Add the convert and wait for the next daily index.
  • Every request is slow. request_time is in milliseconds. Use the duration_ms conversion, or change the app to log seconds.
  • One Python traceback becomes thirty separate lines. The app prints tracebacks as plain text, and awslogs sends every line as its own event. Switch to JSON logging, or set awslogs-multiline-pattern in the task definition.
  • Lines are missing during traffic spikes. The container is in non-blocking mode and the buffer filled. Raise max-buffer-size or accept blocking for that service.
  • Nothing arrives and the forwarder has errors. Read the function's own log group in CloudWatch. A 401 or 403 means the stack API key is wrong or belongs to a different stack. Because the function throws on anything other than 202, Lambda retries the batch, which covers brief failures. A wrong key fails every retry, so fix it before you rely on the dashboards.
  • CloudTrail lines don't appear. That's deliberate. Documents with eventSource are CloudTrail and every panel excludes them. EKS container logs that carry kubernetes.pod.name aren't counted when the Library checks for this pack either. If you ship EKS logs with Filebeat, the Kubernetes container logs and cluster events dashboards are the better fit.

Trade-offs and limits

  • You still pay AWS for CloudWatch. The awslogs driver writes to CloudWatch Logs first, so ingestion and storage there still apply. Once your stack holds the searchable copy, a short retention on the ECS log groups keeps that cost down.
  • Each log group allows two subscription filters. If another tool already uses both slots on a log group, you'll need to free one or share a destination.
  • It's logs only. There's no Grafana variant and no CPU or memory panels. For ECS and Fargate container metrics, the AWS ECS Fargate metrics guide covers Telegraf in the task.
  • Mean response time is a mean. It isn't a percentile or an SLO, and one very slow request moves it. The slow-request threshold is fixed at one second. If you need p95 or a different threshold, save a copy of the dashboard and change the copy.
  • Top-level field names are shared. status, path and name are generic. If another source on the same stack writes status as text into logstash-*, it can win the mapping for the day. Keep other sources' generic keys nested, or convert them the same way.
  • FireLens skips CloudWatch entirely. That saves the CloudWatch hop, but the pack keys on logGroupName and logStreamName, so you'd have to add both fields yourself for these dashboards to match.

Start Free Trial

Unlock complete visibility with hosted ELK, Grafana, and Prometheus-backed Observability

Start Free Trial

FAQ

Does the ECS pack work on Fargate as well as EC2?

Yes. Both launch types support the awslogs driver, and the pack only cares about what lands in CloudWatch. On Fargate, awslogs-stream-prefix is required, which also gives you the prefix/container/task-id stream names the Log groups and tasks board counts.

Our services log plain text. Is the pack still worth installing?

Partly. Application logs and Log groups and tasks fill from the CloudWatch fields alone, so you get volume, freshness and per-task streams straight away, and Log investigation still searches every line. The error and request boards need a level and request keys, so switch the noisiest service to JSON logging first and the rest as you touch them.

Why does the pack read logstash-* rather than a cloudwatch index?

Lines that come through the forwarder and your stack's Logstash pipeline land in daily logstash- indices by default, with string fields mapped as text plus a .keyword sub-field. The dashboards group on those .keyword fields, so the default mapping is what they're built for.

Will CloudTrail events show up on these dashboards?

No. Every panel excludes documents that have eventSource, which CloudTrail records always carry. That's what keeps a busy AWS account's API activity from drowning out your application errors.

How soon after I start forwarding will the Library detect the pack?

As soon as matching lines are in the stack. The Library looks at the last 24 hours of data, so press Rescan stack once the Discover check above returns results and the pack moves to the top with a Data detected badge.

Can I try it on the free trial?

Yes. The 14-day free trial includes every feature, including the Dashboard Library and Hosted OpenSearch MCP. Subscribe one ECS log group to a trial Logs stack, run the checks and install the pack.

Next steps

If your ECS services already log to CloudWatch, subscribe one log group, add the two Logstash blocks, run the _count check and install the pack today. Then move each service to JSON logs as you next deploy it, so the error and request boards fill in one service at a time. If you're running your own OpenSearch cluster for these logs, a Logit.io Logs stack gives you managed Logstash, OpenSearch and OpenSearch Dashboards with the Dashboard Library built in. For a dedicated cluster, see Hosted OpenSearch and OpenSearch pricing. To ask questions of the same ECS logs from any MCP-compatible AI assistant, see Hosted OpenSearch MCP.

Get the latest Elastic Stack & logging resources when you subscribe

Want to see this in action?
Start a free trial and connect logs to your alert workflows.