Logging at Scale
Key Points
- Logs are the most expensive pillar at scale: ingestion volume × retention × indexing tier. A mid-size service can spend more on logs than on compute.
- Cost math: 100K req/s × 5 lines × 500 B = 250 MB/s = ~21 TB/day. At $2–5/GB ingest = $40–100K/day. Logs dominate budgets if you don't tier and sample.
- Tiering (hot / warm / cold) is the single biggest lever. Hot is expensive and searchable; cold is cheap and slow.
- Cardinality kills: high-cardinality fields like
userIdindexed as queryable explode storage costs. Index fewer fields; keep the rest as raw payload. - Sampling logs is different from sampling traces: rule-based, not statistical. Drop healthy 200s; keep all errors.
- Pipeline reliability: in-memory buffering loses data on crash. Disk-backed buffers (Fluent Bit, OTel Collector with file storage) survive restarts.
- PII redaction belongs in the pipeline, not in app code. Devs forget; pipelines don't.
- .NET specifics:
ILogger<T>is the abstraction;LoggerMessagesource-gen for hot paths; OTLP log exporter for vendor-neutral shipping.
Concepts (deep dive)
The cost surface
Cost = ingest_rate × bytes_per_event × retention_days × tier_multiplier
+ index_size × indexed_fields_factor
+ query_volume × scan_size
Three knobs: how much you send, how long you keep it, what you index. Most teams discover logging cost only after the bill spikes.
Worked example
| Variable | Value |
|---|---|
| Requests/sec | 100,000 |
| Lines per request | 5 |
| Avg bytes per line | 500 |
| Daily volume | ~21.6 TB |
| At $3/GB ingest (Datadog/Splunk-ish) | ~$65K/day |
| At $0.50/GB (Loki) | ~$11K/day |
| 30-day hot retention multiplier | ×30 |
A single chatty service can spend more on its logs than its EC2 bill. Senior decisions live here.
Tiering
HOT (0–7 days) SSD-backed, fully indexed, sub-second search. $$$
WARM (8–30 days) Indexed but slower; common cloud blob. $$
COLD (>30 days) Archive (S3 Glacier / ADLS cool); restore-on-need. $
Strategies: - Hot: live operational use. - Warm: post-incident forensic search. - Cold: compliance / audit. Often >90% of total volume but <5% of cost when archived.
Most platforms (Splunk SmartStore, Datadog Flex Logs, Elasticsearch ILM, Loki) support automatic tiering by index age.
Structured vs unstructured
Unstructured ("User 42 placed order 7 for $99"): - Cheap to ingest (1 string). - Expensive to query (regex/parse on read). - Bad for aggregation.
Structured JSON ({"user":42,"order":7,"total":99}): - More to ingest (field tags repeat). - Cheap to query (typed fields, indexed). - Aggregations are first-class.
Recommendation: structured for everything. Modern ingest pipelines compress field names; the cost delta is small. The query advantage is enormous.
Indexing tiers
| Field type | Use |
|---|---|
| Indexed (hot) | Frequently queried: service, level, status_code, traceId, tenant |
| Stored only | Rarely queried but kept on the line: requestPath, userAgent |
| Excluded / redacted | PII, secrets — drop or hash before ship |
A typical mistake: indexing every field "just in case." Each indexed field multiplies storage 1.5–3×.
Cardinality kills
A field's cardinality = distinct values. level has ~5 (Trace/Debug/Info/Warn/Error). userId has millions.
Indexed userId: - Index = inverted-index entry per distinct value. - 10M users × 30 days × per-bucket overhead = explosion.
Rule of thumb: don't index any field with cardinality > ~10K unless you specifically need it for queries. Keep userId as a payload field — searchable via full-text or a secondary lookup.
Sampling vs filtering
| Technique | When |
|---|---|
| Drop | Health checks, debug spam, known noise |
| Sample | Repetitive INFO ("connection pool acquired" 1M/s → 1% kept) |
| Aggregate | "Got 502 from upstream" — emit 1 line/min with count |
| Keep all | ERROR/WARN, business events, audit |
Anti-pattern: "we sample 5% of all logs." You'll lose most errors (errors are rare) and keep most noise.
The shipping pipeline
[App]
| (ILogger -> sink)
v
[Local agent: Fluent Bit / OTel Collector / Vector]
| - parse (if needed)
| - enrich (add k8s/cloud metadata)
| - filter / drop
| - redact (PII)
| - sample
| - buffer (memory + disk)
v
[Backend: Elastic / Loki / Datadog / Splunk / New Relic]
Reliability
| Buffer | Crash-safe? |
|---|---|
| In-memory | ❌ lose on restart |
| Disk-backed (file storage extension) | ✅ |
| Forward + tail-from-file (rotated logs) | ✅ if app writes to file |
For high-stakes services, write logs to a local file and ship via agent. The file is the truth source; the agent is the delivery mechanism.
Back-pressure
When the backend slows, the agent must: - Buffer to disk up to a cap. - Drop the oldest when cap exceeded (or block — but blocking your app is worse than losing logs). - Surface metrics: agent_buffer_size, agent_dropped_records. Alert on them.
Log levels at scale
| Level | Default in prod? |
|---|---|
| Trace | ❌ |
| Debug | ❌ — ridiculously expensive at scale |
| Info | ⚠️ — keep terse; one line per request, not per step |
| Warn | ✅ |
| Error | ✅ |
| Critical | ✅ |
A common smell: 50 INFO lines per request. At 100K req/s that's 5M lines/s. Either lower default level to Warn for chatty namespaces or drop them in the pipeline.
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
Sensitive data — pipeline redaction
# OTel Collector
processors:
redaction:
allow_all_keys: true
blocked_values:
- '\b\d{16}\b' # credit card
- '\b\d{3}-\d{2}-\d{4}\b' # SSN
summary: silent
Or attributes processor with delete actions. Don't trust developers to remember [Redacted] in every log line — they'll forget once and ship a regulator-fine.
ILogger and structured logging in .NET
public class OrderHandler(ILogger<OrderHandler> log)
{
public async Task HandleAsync(PlaceOrder cmd)
{
log.LogInformation(
"Order {OrderId} placed by {CustomerId} for {Total:C}",
cmd.OrderId, cmd.CustomerId, cmd.Total);
}
}
Properties become structured fields when sink is JSON-aware. Serilog, OTel logs, NLog all support this.
LoggerMessage source-gen (hot paths)
public partial class OrderHandler
{
[LoggerMessage(Level = LogLevel.Information,
Message = "Order {OrderId} placed by {CustomerId} for {Total:C}")]
static partial void LogOrderPlaced(ILogger l, Guid orderId, Guid customerId, decimal total);
}
Compile-time-generated method — no allocations, no boxing of value types, no format-string parsing per call. ~10× faster than log.LogInformation(...) on hot paths.
OTLP log exporter
Send logs through the OTel Collector for the same vendor-neutral pipeline as traces and metrics:
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeScopes = true;
o.IncludeFormattedMessage = true;
o.AddOtlpExporter(opts => opts.Endpoint = new Uri("http://otel-collector:4317"));
});
The Collector then redacts, samples, fans out to multiple backends, and survives backend outages with disk buffering. Strongly recommended in 2026.
Cost dashboards
Operational hygiene every senior owns: - $ per service per day — who's spending what. - GB ingested per service per hour — spike detection. - % indexed fields — drift over time. - Top 20 chatty loggers — namespace ranking. - Drop rate — pipeline back-pressure.
Datadog has Usage tab; Splunk has license usage; Elastic has ILM stats; build your own panel from Collector metrics.
Comparing backends (one paragraph each)
- Elasticsearch / OpenSearch: heavy indexing; powerful full-text; ops cost is real. Self-host = cheap volume / expensive ops; hosted = inverse.
- Grafana Loki: indexes only labels; payload stored cheap. Cardinality of labels is the cost lever, not volume. Excellent for K8s log labels.
- Datadog Logs: rich UI, expensive. "Flex Logs" for warm tier helps. Easy to overspend.
- Splunk: enterprise standard; license per GB ingested historically; SmartStore tiers to S3.
- New Relic Logs: lower per-GB; integrated with their APM.
- Azure Monitor Logs / Log Analytics: KQL queries; bundled with App Insights; reasonable for Azure-heavy shops; commitment tiers.
- AWS CloudWatch Logs + Insights: default for AWS shops; expensive at scale; use Firehose to S3 for cold.
For .NET on Azure: Application Insights (Logs workspace) + a cold-tier ADLS archive is the pragmatic default.
How it works under the hood
[App]
|
| ILogger.LogInformation(template, args)
|
v
[ILoggerProvider chain] (Console, OTel, Serilog sink, ...)
|
v
[Sink]
- serialize (JSON / OTLP / line)
- enrich (scopes, traceId, machine)
- emit
|
v
[Local agent]
- tail file or receive OTLP
- parse / enrich (k8s pod, container, region)
- filter / sample
- redact
- batch
- buffer (mem / disk)
- export
|
v
[Backend]
- tier hot/warm/cold (ILM)
- index configured fields
- retain per policy
- serve queries
Performance bottlenecks usually live in: (1) synchronous Console sink in prod (slow!), (2) string.Format in hot paths (use source-gen), (3) excessive indexed fields (vendor-side cost).
Code: correct vs wrong
❌ Wrong: string-interpolated message
Loses structured fields. Searches become regex. Source-gen can't help.
✅ Correct: template + args
❌ Wrong: high-cardinality indexed field
// shipped to backend with indexing on "userId"
log.LogInformation("Login {UserId}", userId); // 10M users => index explosion
✅ Correct: keep as payload, not index
# pipeline config: don't promote userId to indexed metadata
attributes:
actions:
- key: user.id
action: hash # or move to non-indexed payload
❌ Wrong: PII in logs unredacted
✅ Correct: redact in pipeline (defense in depth)
And avoid logging the field at all.
❌ Wrong: hot-path LogInformation with allocations
// 1M req/s; each call boxes value types and parses template
log.LogInformation("Trade {Id} {Px} {Qty}", id, px, qty);
✅ Correct: LoggerMessage source-gen
[LoggerMessage(Level = LogLevel.Information,
Message = "Trade {Id} {Px} {Qty}")]
static partial void LogTrade(ILogger l, Guid id, decimal px, int qty);
❌ Wrong: in-memory buffer in agent for critical logs
Crash → loss.
✅ Correct: disk-backed buffer
extensions:
file_storage:
directory: /var/lib/otelcol
exporters:
otlp:
sending_queue: { storage: file_storage }
Design patterns for this topic
Pattern 1 — "Tier hot / warm / cold"
- Intent: keep recent searchable; archive the rest cheaply.
Pattern 2 — "Index few, store many"
- Intent: searchable on bounded fields; payload cheap.
Pattern 3 — "Pipeline-side redaction"
- Intent: defense-in-depth for PII.
Pattern 4 — "Disk-backed shipping buffer"
- Intent: survive restarts and backend outages.
Pattern 5 — "Source-gen on hot paths"
- Intent: zero-allocation, fast logging.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Structured JSON | Queryable, aggregatable | Larger ingest |
| Hot tier indexing | Sub-second search | Expensive |
| Cold archive | Cheap retention | Slow restore |
| Pipeline redaction | Reliable | Extra config |
| Source-gen logging | Fast, no alloc | Codegen ceremony |
| Loki-style label index | Cheap volume | Cardinality of labels matters |
When to use / when to avoid
- Always ship via an agent + disk buffer.
- Always redact PII in pipeline.
- Always tier; never keep 90 days hot.
- Use source-gen
LoggerMessagefor hot paths. - Avoid indexing high-cardinality fields.
- Avoid Debug/Info chatter at full volume in prod.
Interview Q&A
Q1. Why is logging the most expensive pillar? Volume × retention × indexing. Easily $10–100K/day at scale. Compute is often cheaper.
Q2. Hot/warm/cold tiers? Hot: indexed, fast, expensive. Warm: indexed slower, cheaper. Cold: archive, cheap, slow restore.
Q3. Cardinality kills — meaning? High-cardinality fields (per-user) indexed → index size explodes. Keep them in payload, not indexed metadata.
Q4. Sampling logs vs traces? Logs: rule-based (drop healthy 2xx, keep errors). Traces: probabilistic + tail-sampling. Different shapes.
Q5. Why disk-backed buffering? Crash safety. In-memory loses on restart; disk buffer replays.
Q6. Where to redact PII? Pipeline. Devs forget; pipelines don't. Defense in depth — also avoid logging the field.
Q7. LoggerMessage source-gen? Compile-time method, no boxing, no template parsing per call. ~10× faster on hot paths.
Q8. ELK vs Loki vs Datadog? ELK: heavy index, full-text. Loki: cheap volume, label-based. Datadog: rich UI, expensive. Pick by query patterns and budget.
Q9. How to log structured in .NET? log.LogInformation("{Field} {Field2}", a, b). Sinks emit fields as structured. Don't interpolate.
Q10. OTLP for logs? Yes — OTel logs API + OTLP exporter. Same pipeline as traces and metrics. Vendor-neutral.
Q11. What's a chatty logger? A namespace emitting orders of magnitude more than business value. Filter via AddFilter("Namespace", LogLevel.Warning).
Q12. Should I sample 5% of all logs uniformly? No — you'll keep mostly noise and lose errors. Keep all errors; sample/drop healthy noise.
Gotchas / common mistakes
- ⚠️ Indexing every field — cost explosion.
- ⚠️ High-cardinality indexed fields (userId, requestId) — index blows up.
- ⚠️ In-memory buffer only — crash = data loss.
- ⚠️ PII in logs unredacted — regulatory risk.
- ⚠️
Consolesink in prod — synchronous, slow. - ⚠️ No log level tuning — Microsoft.* INFO drowns signal.
- ⚠️ String-interpolated messages — kills structured fields.