LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Effective Log Management Strategies

loggingobservabilitylokistructured-loggingretentiondevops

Logs are the cheapest telemetry to produce and the most expensive to keep, and almost every team discovers that ordering the hard way — usually when the observability bill arrives or when a 2 a.m. incident turns into an unsearchable wall of text. Good log management is not about choosing a vendor; it is a set of disciplines applied at the source, in the pipeline, and at query time, that together decide whether your logs are a debugging instrument or a write-only landfill you pay rent on. This post is about those disciplines: how to structure logs so they are queryable, how to think about the cost model before it thinks about you, what to drop before it ever hits storage, and how to keep logs useful and compliant over their whole lifecycle. For tool-by-tool comparisons I will point at the modern logging architecture shootout and the Loki deep-dive rather than re-litigate them here.


Structured logging is the whole game

If you take one thing from this post: emit logs as structured key-value records, not as human sentences. A line like ERROR: Failed to process payment for user 12345, order ord_789 - timeout is readable by a human and opaque to a machine. To find every failed payment for one user, you are now writing regular expressions against free text and hoping nobody changed the wording. The structured equivalent is a record you can filter, aggregate, and alert on:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "timestamp": "2026-01-07T10:30:00Z",
  "level": "error",
  "msg": "payment processing failed",
  "service": "checkout",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "user_id": "12345",
  "order_id": "ord_789",
  "error_kind": "upstream_timeout",
  "latency_ms": 30021
}

The wins compound. You can answer “how many upstream_timeout errors did checkout see per minute” with an aggregation instead of a grep. You can pivot from a log line to the full distributed trace because trace_id is right there. You can build an alert on a field rather than a substring that breaks the next time someone rewrites the message. The discipline that makes this work is stable field names: user_id is always user_id, never userId in one service and uid in another. A short shared logging library or an agreed schema is worth more than any backend you buy.

Two rules keep structured logging honest. First, the msg field should be a low-cardinality constant — "payment processing failed", not the message with the IDs interpolated in. Put the variables in their own fields. Second, never log an entire object because it is convenient; log the fields you will actually query. Dumping a serialized request body into a log is how a single endpoint quietly becomes 40% of your ingest.


Log levels that mean something

Levels are a routing and budget decision, not decoration. The point of a level is to let you keep the cheap, high-volume stuff at low retention and the rare, important stuff at high retention — and to let an on-call engineer raise verbosity under pressure without redeploying.

Level Meaning Typical retention Alert on it?
ERROR A request or job failed and a human may need to act 30–90 days Yes, on rate/spikes
WARN Unexpected but handled; a degraded path was taken 14–30 days On sustained increase
INFO Normal lifecycle events: startup, request completed 7–14 days No
DEBUG Detailed internal state for troubleshooting 1–3 days or sampled No
TRACE Firehose-level detail, usually off in production Off / on-demand No

The common failure is treating levels as a vibe — everything gets logged at INFO, or DEBUG is left on in production “just in case.” Both destroy the signal-to-cost ratio. The better pattern is dynamic log levels: expose an endpoint or config flag that lets you bump a specific service or module to DEBUG for ten minutes during an incident, then drop it back. You get the detail when you need it without paying to store TRACE for everything forever.


The cost model nobody plans for

Log spend is driven by two things people underestimate: volume (gigabytes ingested) and, for indexed systems, cardinality (the number of distinct values in indexed fields). Volume you can feel. Cardinality is the silent killer — an indexed field like user_id or request_id with millions of distinct values can blow up an index far out of proportion to the raw bytes.

  app stdout ─▶  AGENT  ─▶  buffer/queue ─▶  INGESTER ─▶  OBJECT STORE
  (JSON line)    (Alloy)    (backpressure)   (index)      (S3 / GCS)
                    │                                          │
       drop ◀───────┤ sample / redact / relabel                │ tiered
       early        │                                           │ retention
                    └───────────────── query ◀── logcli / Grafana / Kibana

Every stage is a place to spend or save. The cheapest log is the one you never ship. The decision of what to keep should be made as far left as possible — at the agent, before egress and storage costs apply — because filtering at query time still means you paid to ingest and store the noise.

This is also where the two architectural philosophies diverge. Index-everything systems (Elasticsearch/OpenSearch, Splunk) make every field fast to search at the cost of storing a large index; you pay up front and query cheaply. Label-and-scan systems (Loki) index only a small set of low-cardinality labels and store the log body compressed in object storage, scanning it at query time; you pay little to store and more in compute when you run a broad query. Neither is “correct” — they are different bets about whether your dominant cost is storage or query.

Concern Index-everything (ES/Splunk) Label-and-scan (Loki)
Storage cost High (full inverted index) Low (compressed chunks in object store)
Arbitrary field search Fast Slow (scans chunks)
Cardinality risk Index bloat Label explosion (worse — breaks ingest)
Best when You query unpredictable fields constantly You filter by a few labels, then grep
Operational weight Heavy (cluster, shards, JVM) Lighter, but query tuning matters

The Loki cardinality trap deserves a specific warning: putting a high-cardinality value like user_id or path into a label (rather than leaving it in the log body) creates a separate stream for every distinct value and will bring the ingester to its knees. Labels are for things with bounded values — service, env, level, namespace. Everything else stays in the line and gets matched with a filter expression. This single mistake is the most common reason a Loki deployment falls over.


What to drop, sample, and aggregate before ingest

Once you accept that the cheapest log is the one you never ship, the agent becomes the most important component in the stack. Modern pipelines do real work here:

  • Drop known-useless lines outright: health-check 200s, load-balancer pings, noisy framework startup banners. A relabel rule that drops GET /healthz 200s often removes a double-digit percentage of total volume.
  • Sample high-volume successes. You rarely need every single request completed INFO line; keeping 1 in 10 of the 2xx responses while keeping 100% of 4xx/5xx preserves the signal and cuts the bill.
  • Redact secrets and PII at the agent (more below), so sensitive data never reaches storage.
  • Aggregate where a metric is the real answer. “How many requests per second returned 500” is a metric, not a log query. Emitting a counter and logging only the errors themselves is dramatically cheaper than logging every request and counting them later.

The Grafana stack’s current agent is Alloy (Promtail is deprecated as of 2025 and in maintenance mode — new deployments should use Alloy). A minimal Alloy pipeline that tails container logs, drops health checks, and ships to Loki looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
loki.source.file "containers" {
  targets    = local.file_match.containers.targets
  forward_to = [loki.process.clean.receiver]
}

loki.process "clean" {
  // drop successful health checks before they cost anything
  stage.match {
    selector = `{job="app"} |= "/healthz"`
    stage.drop {
      expression = `.*" 200 .*`
    }
  }
  // pull level and trace_id out of JSON into structured metadata
  stage.json {
    expressions = { level = "level", trace_id = "trace_id" }
  }
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}

If you are still on Promtail, the equivalent uses pipeline_stages with match, drop, and json stages; the concepts are identical, only the syntax differs.


A working local stack

For a homelab or a small service, a Loki-plus-Grafana stack in Docker Compose is enough to be genuinely useful, and it is cheap because Loki stores chunks in a filesystem or object bucket rather than a heavyweight index cluster:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
services:
  loki:
    image: grafana/loki:3.4.1
    command: -config.file=/etc/loki/config.yaml
    ports: ["3100:3100"]
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml
      - loki-data:/loki

  alloy:
    image: grafana/alloy:latest
    command: run /etc/alloy/config.alloy
    volumes:
      - ./config.alloy:/etc/alloy/config.alloy
      - /var/log:/var/log:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro

  grafana:
    image: grafana/grafana:latest
    ports: ["3000:3000"]

volumes:
  loki-data:

Querying is where LogQL earns its keep. Loki’s query language looks like PromQL with a log filter bolted on the front: select streams by label, filter the body, then optionally aggregate.

# every checkout error in the last hour, newest first
{service="checkout", level="error"}

# rate of upstream timeouts per minute, by service
sum by (service) (
  rate({env="prod"} |= "upstream_timeout" [1m])
)

# p95 latency parsed out of a JSON field
quantile_over_time(0.95,
  {service="checkout"} | json | unwrap latency_ms [5m]) by (service)

From the command line, logcli query '{service="checkout",level="error"}' --since=1h does the same thing for scripting and incident response. The point is not the specific syntax — it is that structured fields and bounded labels are what make any of these queries fast and cheap.


Retention, tiering, and compliance

Logs have a value half-life measured in days for debugging and months-to-years for audit. Treating all logs with one retention policy is how you either lose data you needed or hoard data you did not. Three policies, not one:

  • Hot, short, queryable: recent operational logs (DEBUG/INFO) at a few days to two weeks, on fast storage, fully searchable.
  • Warm, medium: ERROR/WARN at 30–90 days for trend analysis and post-incident review.
  • Cold, long, cheap: audit and compliance logs (access records, security events) shipped to cheap object storage with object-lock/immutability, kept for the legally required window — often a year or more — and rarely queried but never deleted early.

Loki and most cloud log products express this with per-stream retention rules and lifecycle policies on the underlying bucket (transition to infrequent-access or Glacier-class storage after N days). The discipline is to decide retention by log meaning, not by convenience, and to make sure the compliance-relevant subset is genuinely immutable so an attacker — or a panicked engineer — cannot truncate the evidence.


Security: PII, secrets, and the audit trail

Logs are a classic data-leak vector because they are written everywhere and reviewed nowhere until something goes wrong. Three rules:

  1. Never log secrets. Authorization headers, tokens, passwords, full card numbers, and session cookies must be redacted at the source or at the agent. A redaction stage that masks anything matching a token pattern is a cheap insurance policy; assume any secret that reaches storage is now in your backups and your vendor’s systems too.
  2. Minimize and mask PII. Log a user_id, not an email and a home address. Where you must capture an identifier for support, mask it (j****@example.com). Several jurisdictions treat logs as in-scope for data-subject deletion requests, so logging less PII is also less compliance surface.
  3. Protect the audit trail itself. Security and access logs should be write-once and tamper-evident, stored separately from application logs, with their own access controls. If the same credentials that run the app can rewrite the audit log, you do not really have an audit log.

Redaction belongs as far left as possible — ideally the logging library refuses to serialize fields tagged sensitive, with the agent as a backstop. Catching PII only at query time means it was already stored, replicated, and backed up in the clear.


Logs are one pillar, not the whole building

The last discipline is knowing what logs are for. Logs answer “what exactly happened in this one execution” — the detailed, high-context narrative of a single request or job. They are the wrong tool for “what is the overall error rate right now” (that is a metric) or “where did this request spend its 800 milliseconds across nine services” (that is a distributed trace). The modern move is to wire all three together: a metric alert fires, you pivot to the trace for the slow request, and you jump from a span to the exact log lines via the shared trace_id. That correlation is why the trace_id field in every structured log earlier in this post matters so much, and it is the backbone of any real observability practice. Logging everything at DEBUG to compensate for missing metrics and traces is the expensive anti-pattern these pillars exist to replace; emitting fewer, structured, correlated logs alongside good metrics is both cheaper and more useful. Alerting, in turn, should mostly hang off metrics and a few high-signal log patterns rather than raw log volume — see alerting without burnout for that side of it.


Verdict

Effective log management is an economics problem dressed up as a tooling problem. The teams that do it well make the same handful of decisions: emit structured logs with stable field names so everything is queryable; use levels as a real retention-and-budget lever, not decoration; treat cardinality as the thing most likely to break or bankrupt you and keep high-cardinality values out of indexes and labels; drop, sample, and redact as far left in the pipeline as possible because the cheapest log is the one you never ship; tier retention by what a log means rather than by convenience; and keep logs correlated with metrics and traces so each pillar does the job it is actually good at. Pick the backend that matches your cost shape — index-everything if you query unpredictable fields constantly, label-and-scan if you filter by a few labels and grep the rest — but understand that the backend is the last decision, not the first. Get the discipline right and a modest, cheap stack will out-debug an expensive one drowning in unstructured noise.


Sources

Comments