Photo by Jeferson Tomaz on Unsplash
CloudWatch Logs ingestion cost by log group isn’t on your bill, which shows one total per Region. Rebuild it from the AWS/Logs metric IncomingBytes with the LogGroupName dimension: sum 30 days per group, convert to GB and multiply by the class price, $0.50 per GB for Standard and $0.25 for Infrequent Access in us-east-1 (September 2026).
CloudWatch is often the surprise line on an AWS bill, and ingestion is usually the biggest part of it. Cost Explorer tells you how many GB you ingested, not which service sent them. This example is for engineers and FinOps reviewers who want the CloudWatch Logs ingestion cost by log group, ranked, so they can talk to the three teams that matter instead of all thirty.
The script is report only. Every fix it points to (log levels, sampling, a different destination) is a change in the application or its logging setup, not in CloudWatch.
What does CloudWatch Logs ingestion cost?
As of September 2026, the AWS Price List for Amazon CloudWatch in US East (N. Virginia), published 22 September 2026, gives these ingestion prices. The Amazon CloudWatch pricing page has the other Regions.
| Log type and class | Price per GB ingested |
|---|---|
| Custom logs, Standard class | $0.50 |
| Custom logs, Infrequent Access class | $0.25 |
| Vended logs, Standard class | $0.50 for the first 10 TB a month, then $0.25, $0.10 and $0.05 over 50 TB |
| Vended logs, Infrequent Access class | $0.25 for the first 10 TB a month, then $0.15, $0.075 and $0.05 over 50 TB |
Vended logs are logs AWS services publish on your behalf. Since May 2025 that includes Lambda function logs, which moved to the tiered vended prices. Below 10 TB a month the tiers make no difference, so the script uses the first-tier price for every group. The price list also shows the first 5 GB of ingestion each month at $0.
Worked example: a Lambda function that logs 11 GB a day, with one 41 GB day during an incident, ingests about 360 GB a month. At $0.50 per GB that’s $180.00 a month, or $2,160 a year, for one function. A container service writing 6.2 GB a day in Standard costs $93.00. The same volume in an Infrequent Access log group would cost $46.50.
Why rebuild ingestion cost from IncomingBytes?
IncomingBytes is “the volume of log events in uncompressed bytes uploaded to CloudWatch Logs”, per the CloudWatch Logs metrics reference, and with the LogGroupName dimension it’s per group. CloudWatch publishes it without any setup and GetMetricData can read 500 groups per call, so a whole Region takes a few seconds.
It’s a close proxy, not an invoice. Treat the total as an estimate and compare it with the DataProcessing-Bytes usage in Cost Explorer; if the two are far apart, look for log groups in other Regions or accounts. Storage is a separate charge that depends on retention, which the example to set CloudWatch Logs retention for all log groups handles.
What does the script do?
- Lists log groups
paginateDescribeLogGroupsreturns each group’s name,logGroupClassandretentionInDays. - Reads ingestionOne
IncomingBytesquery per group with a dailySum, in batches of 500 queries perGetMetricDatacall. - Prices each groupTotal bytes ÷ 1024³, scaled to 30 days, times $0.50 (Standard) or $0.25 (Infrequent Access). Delivery-class groups are shown without a price.
- Ranks and reportsThe top N groups with GB a month, the busiest single day, cost, share of the total and retention.
Prerequisites
- Node.js 18 or later with
tsx,@aws-sdk/client-cloudwatch-logsand@aws-sdk/client-cloudwatch. - A read-only profile; see connecting to AWS with profiles, SSO or roles.
- Run it in every Region you log in. Log groups and their metrics are regional.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListLogGroups",
"Effect": "Allow",
"Action": "logs:DescribeLogGroups",
"Resource": "*"
},
{
"Sid": "ReadIngestionMetrics",
"Effect": "Allow",
"Action": "cloudwatch:GetMetricData",
"Resource": "*"
}
]
}
Neither action reads log content. If you extend the script, the IAM policy generator for TypeScript code drafts the new actions for review.
The script to rank CloudWatch Logs ingestion cost by log group
// top-log-groups-by-ingestion.ts
// Ranks CloudWatch Logs log groups by the data they ingested over the last 30 days (AWS/Logs IncomingBytes),
// estimates the monthly ingestion cost of each from its log class, and prints the top N. Report only.
// Usage: npx tsx top-log-groups-by-ingestion.ts [--regions us-east-1,eu-west-1] [--days 30] [--top 20]
import { CloudWatchLogsClient, paginateDescribeLogGroups, type LogGroup } from "@aws-sdk/client-cloudwatch-logs";
import { CloudWatchClient, paginateGetMetricData, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Math.min(Math.max(Number(flag("--days") ?? 30), 1), 90);
const top = Math.max(Number(flag("--top") ?? 20), 1);
const GB = 1024 ** 3;
// First-tier ingestion price per GB, us-east-1, AWS Price List (published 22 September 2026).
// Custom logs are flat. Vended logs (Lambda and other AWS service logs) drop to lower tiers after 10 TB a month.
const PRICE_PER_GB: Record<string, number> = { STANDARD: 0.5, INFREQUENT_ACCESS: 0.25 };
interface Row {
LogGroup: string;
Class: string;
GBPerMonth: number;
BusiestDayGB: number;
CostPerMonth: string;
Share: string;
Retention: string;
cost: number;
}
const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
/** Daily IncomingBytes sums per log group, in batches of 500 metric queries per GetMetricData call. */
async function ingestion(cw: CloudWatchClient, names: string[]): Promise<Map<string, number[]>> {
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
const queries: MetricDataQuery[] = names.map((name, i) => ({
Id: `g${i}`,
MetricStat: {
Metric: { Namespace: "AWS/Logs", MetricName: "IncomingBytes", Dimensions: [{ Name: "LogGroupName", Value: name }] },
Period: 86_400,
Stat: "Sum",
},
}));
const out = new Map<string, number[]>();
for (let i = 0; i < queries.length; i += 500) {
const pages = paginateGetMetricData({ client: cw }, { MetricDataQueries: queries.slice(i, i + 500), StartTime: start, EndTime: end });
for await (const page of pages) {
for (const r of page.MetricDataResults ?? []) {
const name = names[Number((r.Id ?? "").slice(1))];
if (name && r.Values?.length) out.set(name, [...(out.get(name) ?? []), ...r.Values]);
}
}
}
return out;
}
async function scanRegion(region: string): Promise<void> {
const logs = new CloudWatchLogsClient({ region });
const groups: LogGroup[] = [];
for await (const page of paginateDescribeLogGroups({ client: logs }, {})) groups.push(...(page.logGroups ?? []));
if (groups.length === 0) {
console.log(`${region}: no log groups`);
return;
}
const byName = new Map(groups.filter((g) => g.logGroupName).map((g) => [g.logGroupName as string, g]));
const daily = await ingestion(new CloudWatchClient({ region }), [...byName.keys()]);
const rows: Row[] = [];
let totalGb = 0;
let totalCost = 0;
for (const [name, values] of daily) {
const g = byName.get(name);
const logClass = g?.logGroupClass ?? "STANDARD";
const gbPerMonth = (values.reduce((a, b) => a + b, 0) / GB) * (30 / days);
const price = PRICE_PER_GB[logClass];
const cost = price === undefined ? 0 : gbPerMonth * price;
totalGb += gbPerMonth;
totalCost += cost;
rows.push({
LogGroup: name,
Class: logClass,
GBPerMonth: Number(gbPerMonth.toFixed(2)),
BusiestDayGB: Number((Math.max(...values) / GB).toFixed(2)),
CostPerMonth: price === undefined ? "see pricing" : `$${cost.toFixed(2)}`,
Share: "",
Retention: g?.retentionInDays ? `${g.retentionInDays} days` : "never expire",
cost,
});
}
rows.sort((a, b) => b.cost - a.cost || b.GBPerMonth - a.GBPerMonth);
for (const r of rows) r.Share = totalCost > 0 ? `${((r.cost / totalCost) * 100).toFixed(1)}%` : "-";
console.log(`\n${region}: ${groups.length} log groups, ${daily.size} ingested data in the last ${days} days (scaled to 30 days)`);
console.table(rows.slice(0, top).map(({ cost: _cost, ...visible }) => visible));
console.log(`Total: ${totalGb.toFixed(1)} GB a month, about $${totalCost.toFixed(2)} in ingestion at first-tier us-east-1 prices.`);
const quiet = groups.length - daily.size;
if (quiet > 0) console.log(`Log groups with no ingestion in this window: ${quiet}.`);
}
async function main(): Promise<void> {
for (const region of regions) {
try {
await scanRegion(region);
} catch (err) {
console.error(`${region}: ${errorText(err)}`);
}
}
}
main().catch((err) => {
console.error(errorText(err));
process.exit(1);
});
Both calls use SDK paginators, described in the AWS SDK v3 paginators guide. BusiestDayGB is there to separate steady noise from one-off incidents.
How do you run it?
npm install @aws-sdk/client-cloudwatch-logs @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node
AWS_PROFILE=readonly npx tsx top-log-groups-by-ingestion.ts --regions us-east-1,eu-west-1 --days 30 --top 20
Sample output
us-east-1: 6 log groups, 5 ingested data in the last 30 days (scaled to 30 days)
┌─────────┬─────────────────────────────┬─────────────────────┬────────────┬──────────────┬──────────────┬─────────┬────────────────┐
│ (index) │ LogGroup │ Class │ GBPerMonth │ BusiestDayGB │ CostPerMonth │ Share │ Retention │
├─────────┼─────────────────────────────┼─────────────────────┼────────────┼──────────────┼──────────────┼─────────┼────────────────┤
│ 0 │ '/aws/lambda/orders-api' │ 'STANDARD' │ 360 │ 41 │ '$180.00' │ '47.7%' │ '30 days' │
│ 1 │ '/ecs/checkout-service' │ 'STANDARD' │ 186 │ 6.2 │ '$93.00' │ '24.7%' │ 'never expire' │
│ 2 │ '/aws/vpc/flow-logs-prod' │ 'STANDARD' │ 123 │ 4.1 │ '$61.50' │ '16.3%' │ '90 days' │
│ 3 │ '/app/audit-archive' │ 'INFREQUENT_ACCESS' │ 150 │ 5 │ '$37.50' │ '9.9%' │ '365 days' │
│ 4 │ '/aws/lambda/image-resizer' │ 'STANDARD' │ 10.5 │ 0.35 │ '$5.25' │ '1.4%' │ '14 days' │
└─────────┴─────────────────────────────┴─────────────────────┴────────────┴──────────────┴──────────────┴─────────┴────────────────┘
Total: 829.5 GB a month, about $377.25 in ingestion at first-tier us-east-1 prices.
Log groups with no ingestion in this window: 1.
This run used mocked responses. /aws/lambda/orders-api is the worked example: nearly half of ingestion, with an incident day at 41 GB. /ecs/checkout-service is steady at 6.2 GB a day and never expires, so it also grows storage every month. /app/audit-archive is already in Infrequent Access, which is why 150 GB costs $37.50. The sixth group ingested nothing; the script to find and delete empty CloudWatch log groups deals with those.
How do you cut ingestion for the top log groups?
- Lower the log level in production. Debug logging left on after an incident is the most common cause of a big group. For Lambda functions that use the JSON log format, the function’s logging configuration can filter application and system logs by level.
- Log less per request. Drop full request and response bodies, log errors with context, and sample success paths (for example 1 in 100).
- Pick the class for new groups. A log group’s class can’t be changed after creation. For logs you only query after the fact, create the group as Infrequent Access and point the application at it. It lacks Live Tail, metric filters, subscription filters and
GetLogEvents, so check the log classes page before moving anything that feeds alarms. - Send bulk logs elsewhere. Lambda logs can use the Delivery class to go to Amazon S3 or Amazon Data Firehose instead of staying in CloudWatch. Subscription filters, by contrast, forward data that has already been ingested and billed.
- Check who else is logging. API Gateway execution logs and VPC Flow Logs can be large; the audits for API Gateway stage logging and VPC Flow Logs show where each is turned on.
To see what’s inside a noisy group before cutting it, run a stats count(*) by @logStream or a pattern query; the CloudWatch Logs Insights query guide for SDK v3 shows how from code. For the whole CloudWatch line, including metrics and alarms, see the total cost of CloudWatch for the current month.
Troubleshooting
- The total is far below Cost Explorer. Log groups in other Regions or accounts, or a
--dayswindow that misses a spike. Run all Regions with a 30-day window. - A group shows “see pricing”. It’s a Delivery-class group for Lambda logs sent to S3 or Firehose. Delivery to those destinations has its own per-GB prices on the pricing page, so the script doesn’t guess.
- Throttling on large accounts. Thousands of groups mean several
GetMetricDatacalls. The SDK retries throttled calls; the AWS SDK v3 retries and timeouts guide shows how to raisemaxAttempts. - Access denied. Check the policy above and troubleshooting AWS IAM access denied.
Ask ChatWithCloud instead
For a quick answer, ask ChatWithCloud “Which CloudWatch log groups ingested the most data in the last 30 days?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the answer; how ChatWithCloud works has the details. It can be wrong and doesn’t ask before running changes, so use a read-only profile. If the whole bill jumped, start with asking AI why your AWS bill increased. More reports are in the AWS practical examples library.
Frequently asked questions
How do I see CloudWatch Logs ingestion by log group?
Read the IncomingBytes metric in the AWS/Logs namespace with the LogGroupName dimension and the Sum statistic. The CloudWatch console can graph it; the script above ranks every group.
How much does CloudWatch Logs ingestion cost per GB?
In us-east-1, $0.50 per GB for Standard and $0.25 for Infrequent Access, as of September 2026. Vended logs such as Lambda logs get lower tiers above 10 TB a month.
Does setting log retention reduce ingestion cost?
No. Retention controls storage. Ingestion is charged when data arrives, so only logging less or using a cheaper class reduces it.
Can I change a log group to Infrequent Access?
No. The class is set when the group is created. Create a new Infrequent Access group and send new logs to it.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud