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
- From ECS task to logstash-* in three hops
- Write app logs the dashboards can count
- Decode the JSON and fix the types in Logstash
- Check the fields before you install
- Install the pack and use it on a bad deploy
- When a board stays empty
- Trade-offs and limits
- FAQ
- Does the ECS pack work on Fargate as well as EC2?
- Our services log plain text. Is the pack still worth installing?
- Why does the pack read logstash-* rather than a cloudwatch index?
- Will CloudTrail events show up on these dashboards?
- How soon after I start forwarding will the Library detect the pack?
- Can I try it on the free trial?
- Next steps
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.
| Dashboard | What it answers | Fields it needs |
|---|---|---|
| Application logs | Are ECS tasks still shipping? Lines, error lines, log groups, last event, levels | logGroupName, logStreamName, message, optional levelname |
| Application errors | Which application and logger are writing ERROR, CRITICAL or WARNING lines | levelname, application_name, name |
| Application requests | Request volume by status code and path, with mean response time | method, path, status (number), request_time (number, seconds) |
| HTTP errors and slow requests | Where 5xx, 4xx and requests of one second or longer are coming from | status, path, request_time |
| Log groups and tasks | Which log groups and task streams produce the volume | logGroupName, logStreamName |
| Log investigation | One timeline for a log group, application, logger or request ID | All 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.
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-groupbecomeslogGroupName. 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-prefixis required on Fargate. ECS names each streamprefix/container-name/task-id, sologStreamNamelooks likeecs/orders-api/5a44bf52ae040c03. That's what the Task log streams panel counts, one stream per running task.modedecides what happens when CloudWatch can't keep up.non-blockingbuffers lines in memory and drops them if the buffer fills, which protects the application.blockingnever 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:
| Logger | What it writes | What to change |
|---|---|---|
Python logging with a JSON formatter | levelname, name | Nothing; add request keys with extra |
| Node.js pino | Numeric level (30, 50), msg | Set 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 handler | level: "ERROR", msg | Map 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
jsonfilter has notarget, so keys land at the top level and the app's ownmessagereplaces 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.
convertdoes nothing when a field is missing, so plain log lines don't pick up emptystatusvalues.- 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:
- 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.
- 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.
- Failed requests to investigate. Filter to the bad path and copy a
requestIdfrom the table. - Log investigation. Filter on
requestIdand you get every line that request wrote, across loggers, in order. The ERROR line with itsexc_infotraceback 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.logGroupNameor one huge document per batch, the split filter is missing or sits after a filter that already consumedmessage. If you forward with something other than the Lambda function, rename its log group and stream fields tologGroupNameandlogStreamName. - 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 readslogstash-*only, and index names containingcloudwatchonly act as a hint that data is on its way. - Every line shows level Unknown. The JSON isn't being decoded (the
messagefield still holds the raw JSON) or your logger writesleveland 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.
statusorrequest_timearrived as strings. Add theconvertand wait for the next daily index. - Every request is slow.
request_timeis in milliseconds. Use theduration_msconversion, or change the app to log seconds. - One Python traceback becomes thirty separate lines. The app prints tracebacks as plain text, and
awslogssends every line as its own event. Switch to JSON logging, or setawslogs-multiline-patternin the task definition. - Lines are missing during traffic spikes. The container is in
non-blockingmode and the buffer filled. Raisemax-buffer-sizeor acceptblockingfor 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
eventSourceare CloudTrail and every panel excludes them. EKS container logs that carrykubernetes.pod.namearen'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,pathandnameare generic. If another source on the same stack writesstatusas text intologstash-*, 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
logGroupNameandlogStreamName, so you'd have to add both fields yourself for these dashboards to match.
Unlock complete visibility with hosted ELK, Grafana, and Prometheus-backed Observability
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.
