If your log bill jumped and your traffic didn't, the vendor is usually not the problem — the shape of your log volume is. Every managed platform charges on some combination of bytes ingested, what gets indexed for search, and how long it is retained, and most teams push all three knobs to maximum for every line their app emits. Measure bytes per service per level first, cut the noise, and only then compare prices; switching vendors without doing that just moves the same volume to a different invoice.
Why does the log bill grow faster than traffic?
Log volume scales with code paths, not users. A new service, a debug line someone left in a hot loop, a retry wrapper that logs every attempt, a Kubernetes liveness probe hitting /healthz every five seconds across a dozen replicas — none of that shows up in your request graph, and all of it shows up on the invoice.
The other multiplier is structure. Structured JSON logging is the right call, but a log line that carries the full request headers, a serialized user object, and a 4 KB stack trace costs roughly ten times what the equivalent message-plus-fields line costs. You are billed on bytes, and nobody notices bytes until the finance channel does.
The takeaway: log spend correlates with the number of emit sites in your codebase, which grows monotonically and never gets reviewed.
What are you actually paying for?
Pricing pages hide behind different units, so compare models, not sticker numbers. As of September 2026, these are the structural differences that matter — verify current rates on each vendor's pricing page before you build a spreadsheet.
| Platform | Primary billing unit | Where the surprise usually hides |
|---|---|---|
| Datadog Logs | Ingested GB, then separately indexed events by retention period | Ingest is cheap relative to indexing; teams index everything by default |
| AWS CloudWatch Logs | GB ingested + GB stored + GB scanned by Logs Insights queries | Query cost is per-scan, so debugging a bad week costs money every time |
| Grafana Cloud Logs (Loki) | Ingested GB + active series/labels | High-cardinality labels (user ID, request ID) blow up the index |
| Elastic Cloud | Provisioned cluster resources | You pay for capacity whether or not you fill it; retention is your storage problem |
| Axiom | Ingested GB with query included | Fewer knobs, less control when you want tiered retention |
| Self-hosted Loki | Object storage + compute you run | Your on-call rotation now owns the logging system |
The distinction that saves the most money is ingest vs. index. Datadog's model exists precisely because most logs are written once and read never: you can send everything, index only the subset you actually query, and rehydrate the rest when you need it. If your team is on Datadog and hasn't configured exclusion filters, that is the single highest-leverage afternoon of work available to you.
The takeaway: you are not buying "logs," you are buying ingestion, searchability, and retention as three separately priced things.
How do I measure my log volume before I shop?
Do not start from the vendor dashboard — start from the bytes your app emits, grouped by the dimensions you can actually act on: service and level.
If you have a day of JSON logs on disk:
# Bytes and line counts grouped by service + level.
# Re-serialization makes this an approximation, but the ranking is what matters.
jq -rc '[(.service // "unknown"), (.level // "unknown"), (tostring | length)] | @tsv' app.jsonl \
| awk -F'\t' '{b[$1"\t"$2] += $3; n[$1"\t"$2]++}
END {for (k in b) printf "%.1f\t%d\t%s\n", b[k]/1048576, n[k], k}' \
| sort -rn \
| head -20
Output columns are MB, line count, service, level. In every audit I have run, the top three rows are the whole conversation — usually one chatty service, one debug level that was never turned off after an incident, and health checks.
On AWS, the equivalent first look is stored bytes per log group:
aws logs describe-log-groups \
--query 'logGroups[].[logGroupName,storedBytes,retentionInDays]' \
--output text \
| sort -k2 -rn \
| head -20
Watch for None in the retention column — that is "never expires," which is the CloudWatch default and quietly bills you forever for logs from a service you deleted last year.
The takeaway: if you cannot name your top three log sources by bytes, you are not ready to compare vendors.
What should you cut first?
In rough order of savings per hour of effort:
- Drop health checks and readiness probes at the logger, not the vendor. They are pure volume with zero diagnostic value.
-
Turn off
debugin production. Obvious, and still the most common finding. - Sample successful requests; keep every error. This is where the real reduction lives.
- Stop logging objects you already have elsewhere. Full request headers and serialized ORM entities belong in a trace, not a log line.
- Set retention per source. Access logs rarely need the same window as payment events.
Sampling is the one that scares people, because random sampling shreds traces — you get request 3 of a five-line story. Sample deterministically on the trace or request ID so a sampled request keeps all of its lines:
// sampling.js
import { createHash } from 'node:crypto'
// Deterministic: the same key always lands the same way.
export function sampled(key, rate) {
if (rate >= 1) return true
if (rate <= 0) return false
const h = createHash('sha1').update(String(key)).digest()
return h.readUInt32BE(0) / 0xffffffff < rate
}
Wired into an Express access log, with errors and slow requests exempt:
import express from 'express'
import pino from 'pino'
import { randomUUID } from 'node:crypto'
import { sampled } from './sampling.js'
const log = pino()
const app = express()
app.use((req, res, next) => {
const start = process.hrtime.bigint()
const requestId = req.headers['x-request-id'] ?? randomUUID()
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6
const interesting = res.statusCode >= 500 || ms > 1000
const rate = interesting ? 1 : 0.05
if (req.path === '/healthz') return
if (!sampled(requestId, rate)) return
log.info({ requestId, method: req.method, path: req.route?.path ?? req.path,
status: res.statusCode, ms: Math.round(ms) }, 'request')
})
next()
})
Note req.route?.path rather than req.path: logging the templated route (/users/:id) instead of the raw URL keeps cardinality bounded, which matters a lot on label-indexed backends like Loki.
The takeaway: deterministic sampling on request ID cuts volume without ever handing you half a trace during an incident.
When does self-hosting actually cost less?
Self-hosted Loki backed by S3 or R2 is genuinely cheap on storage, because it indexes only labels and treats log bodies as compressed chunks in object storage. If you are running Kubernetes already, have someone who is comfortable owning a stateful service, and your pain is "we generate a lot of low-value logs we still want searchable," Grafana Loki is the option that makes that volume affordable rather than making you delete it.
The honest cost is not the infrastructure. It is that your logging system becomes a thing that can page you, and it tends to page you during exactly the incidents when you need it. Loki's query performance also degrades badly if your label scheme is wrong, and fixing a label scheme after the fact means reindexing your habits, not just your config.
For a team under roughly ten engineers with no dedicated platform person, a managed platform is almost always cheaper once you price your own time honestly. Better Stack sits reasonably in that gap if you want managed logs without Datadog's per-feature complexity, though its ecosystem of integrations is thinner than the incumbents'. If you are already deep in AWS and mostly need logs for post-incident forensics rather than daily querying, CloudWatch Logs with aggressive per-group retention is the boring answer that stops the bleeding without a migration.
| Situation | Reasonable choice |
|---|---|
| Small team, AWS-native, logs read rarely | CloudWatch with per-group retention set |
| Want managed, dislike per-feature pricing complexity | Better Stack or Axiom |
| Already run Grafana + Kubernetes, high volume | Self-hosted or Grafana Cloud Loki |
| Need deep correlation with metrics/APM, have budget | Datadog with exclusion filters configured on day one |
The takeaway: self-hosting trades a predictable invoice for an unpredictable on-call surface — take that trade only if you already run stateful services well.
FAQ
How do I reduce Datadog log ingestion costs?
Configure exclusion filters so high-volume, low-value logs are ingested but not indexed, and sample access logs at the application before they leave the host. Indexing, not ingestion, is usually the larger line item, so cutting what gets indexed changes the bill fastest.
Is CloudWatch Logs cheaper than Datadog?
For storage and ingestion of logs you rarely query, usually yes; for logs you query daily, CloudWatch's per-GB-scanned Logs Insights charges can erase the difference. Compare on your actual query frequency, not just ingested volume.
Does log sampling break debugging?
Not if you sample deterministically on the request or trace ID and exempt errors and slow requests, because every retained request keeps its complete set of lines. Random per-line sampling does break debugging, which is why it has a bad reputation.
Bottom line
Audit before you migrate: one jq pass over a day of logs tells you more than any vendor comparison. If you are on Datadog, exclusion filters and deterministic sampling will cut the bill more than switching would. If you are AWS-native and mostly write logs you never read, set CloudWatch retention per log group today. Self-host Loki only if you already operate stateful infrastructure and your volume is genuinely large — otherwise you are trading a line item for an on-call burden.
Top comments (0)