Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

intermediateloki-ingestion~25 min

Break/Fix: Loki Receives No Logs

Reported symptoms

  • ●`{job="payment-service"}` has returned nothing at all since 04:12 on Sunday - not a reduced rate, a hard stop with no lines after that minute
  • ●The application is demonstrably alive and writing: `/var/log/payment-service/payment.log` is growing on every pod, the mtime is seconds old, and `tail` shows well-formed JSON
  • ●The collector on those same hosts is `active (running)` with zero restarts since Friday, and `loki_write_sent_entries_total` on each host is still climbing steadily
  • ●Loki is rejecting nothing: every `reason` label on `loki_distributor_discarded_samples_total` reads zero for the tenant, so no rate limit, stream limit or timestamp window is in play
  • ●The stream still exists in the index - `logcli series` returns `{job="payment-service"}` with its labels unchanged, so this is not a label rename that split the stream in two
  • ●Separately, and reported as an unrelated ticket: the `notifications` job on the same hosts is short about eight percent of its lines for the same weekend, found only because a nightly reconciliation count did not match

Evidence

  • · `stat /var/log/payment-service/payment.log` on three pods shows a modify time inside the last five seconds and a size growing at roughly 35 lines per second per pod
  • · `curl -s http://localhost:12345/metrics | grep loki_source_file_target_last_parsed_timestamp_seconds` returns a timestamp seconds old for the payment-service target: the collector is reading the file
  • · `loki_write_sent_entries_total` on the collector host is rising, but it is a per-host total across every job that host ships, and the host also ships `notifications`, `nginx` and the node journal
  • · `loki_process_dropped_lines_total{reason="oversized_line"}` is rising at about 35 per second per host - which is exactly the payment-service line rate on that host, plus a slow trickle
  • · The `loki.process` block contains a `stage.drop` with `longer_than = "2KB"` and `drop_counter_reason = "oversized_line"`, added fourteen months ago after an unrelated stack-trace flood
  • · The payment-service release at 04:10 on Sunday migrated the logger from logfmt to structured JSON; the mean line went from about 380 bytes to about 2.4 KB because every record now carries the full service, trace and request context
  • · `awk` over the last 10,000 lines of the source file reports a mean line length of 2,412 bytes, with all 10,000 of the sampled lines above the 2 KB threshold
  • · The same `awk` against the `notifications` source file reports a mean of 1,904 bytes with 812 of 10,000 lines above 2 KB, which matches the size of its loss exactly
Diagnosis and resolutionclick to reveal

Root cause

A `stage.drop` in the collector pipeline discards any line longer than 2 KB and counts it under `reason="oversized_line"`. The guard was added fourteen months ago, after a service logged full stack traces with request bodies attached and pushed a single stream past its rate limit, and it has been correct and invisible ever since. On Sunday the payment-service release migrated the logger from logfmt to structured JSON. Nothing about the pipeline changed and nothing about the service's volume changed, but the mean line went from roughly 380 bytes to roughly 2.4 KB, because the JSON formatter stamps the service name, version, environment, trace id, span id and the request context object onto every record rather than onto the few records that needed them. Every payment-service line is now on the wrong side of a threshold nobody was thinking about during the review, and the drop stage removes all of them inside the collector process, before the batch is built and before anything durable has touched them. The reason the failure is total for one service and partial for another is that the guard tests a property of the line rather than a property of the service: the `notifications` job was already writing a mean line of about 1.9 KB, so it sits astride the same threshold and loses only its longer records - the rendered-template ones - which is why its loss showed up as a reconciliation discrepancy rather than as an empty dashboard. Every hop the on-call runbook checks reports healthy because every hop is healthy. The application is emitting, the collector is running and reading the file, the push is succeeding, and Loki is discarding nothing, because Loki never received the lines to discard. The only honest signal in the estate is a counter on the collector's own metrics endpoint that no alert was ever written against.

Remediation

The immediate repair is to stop the drop stage matching payment-service by raising `longer_than` above the new distribution, or by scoping the drop to the job that motivated it in the first place, and reloading the collector. Do not simply delete the stage, and do not ship any version of this change without first checking the tenant's ingestion budget, because the fix moves the failure rather than removing it: payment-service was shipping about 0.53 MB/s as logfmt and will ship about 3.4 MB/s as JSON, and the tenant's steady state is already around 13.5 MB/s against an `ingestion_rate_mb` of 16. Restoring these lines takes the tenant over its per-tenant ceiling at peak, at which point the distributor starts returning 429 and the `reason="rate_limit"` counter starts moving - and a per-tenant rejection is indiscriminate, so the blast radius grows from one service that has no logs to every service in the tenant losing lines at peak. The correct change is therefore two changes shipped together: the collector-side guard is narrowed or removed, and the tenant limit is raised to cover the new volume, with the cost of that volume acknowledged by whoever owns the budget. Sunday's lines are a separate decision. They are still on disk in the source files if rotation has not aged them out, so a replay is technically possible, but Loki rejects entries older than its timestamp window and will count them under `reason="older_than"` rather than accepting them, so a replay needs a deliberate, temporary and tenant-scoped widening of that window - which also reopens the door to clock-skewed garbage for as long as it is open. Hold is a legitimate answer here and should be written down as one: leave the guard in place, accept that payment-service has no logs until the ingest budget conversation concludes, and name the engineer and the date. What is not legitimate is shipping the narrow fix and calling the incident closed without the budget check, because the next peak turns a one-service outage into a tenant-wide one.

Verification

Verify at the counter that was silent, not at the dashboard, because the dashboard will look correct the moment the first line lands and will say nothing about the other eight jobs on the host. After the reload, `loki_process_dropped_lines_total{reason="oversized_line"}` must stop advancing for the payment-service pipeline; a rate that falls but does not reach zero means the threshold was raised to a number that still clips the tail. Confirm the lines arrive rather than confirming that they were sent: `logcli query --since=5m '{job="payment-service"}'` must return lines, and the count over a five-minute window should be within a few percent of 35 lines per second per pod multiplied by the pod count - a stream that returns something but returns a third of what the source files hold is the same bug at a higher threshold. Check the second victim explicitly, because it was reported as a different incident and will not be revisited otherwise: the `notifications` reconciliation count must match its source files for a full day, not for a spot sample. Then verify what the fix cost: `loki_distributor_bytes_received_total` for the tenant must be re-measured at peak, not at the hour the change shipped, and `loki_distributor_discarded_samples_total` must remain zero for every reason label across that peak - if `reason="rate_limit"` moves at all, the tenant limit was not raised far enough and the incident has been converted rather than closed. Finally, prove the new alert can fail: raise `longer_than` on a test pipeline until it clips a known stream, and confirm the drop-rate alert fires within its evaluation window. An alert on this counter that has only ever been quiet has not been tested.

Prevention

Alert on every drop counter the collector exposes, and treat a non-zero rate as a defect rather than as background. `loki_process_dropped_lines_total`, `loki_write_dropped_entries_total` and the source-side failure counters are the only signals in the estate that describe a line dying between the application and Loki; without an alert on each, that death is silent by construction, which is why fourteen months of correct behaviour and one weekend of total loss look identical from every dashboard. Prefer server-side limits to collector-side drops for anything protective. Loki's own `max_line_size` rejects an over-long entry, counts it with a reason label, and returns an error the collector logs; a `stage.drop` at the edge deletes the line and tells only a counter on one host. When a guard must live in the collector, scope it to the job that motivated it rather than leaving it to test every line that passes through the pipeline, and record in a comment which incident it came from and what it was measured against - a threshold with no provenance cannot be reviewed. Treat a logging-format change as a platform change, because it is one: a migration to structured logging multiplies line size by a factor that is easy to predict and easy to forget, and the review that matters is not "does the new format parse" but "what does this do to bytes per second, to the tenant's ingestion budget, and to every size-based or content-based filter between here and the bucket". Measure the line-length distribution before and after any such migration and compare it against every threshold on the path. And make the smoke test a standing one rather than an incident-time one: a synthetic line, emitted at the current production line length, pushed through the real pipeline and queried back from Loki, catches this entire class within one interval instead of within one reconciliation cycle.

Reported symptoms

At 09:20 on Monday an engineer opens the payment dashboard to check the weekend and finds it empty. Not sparse - empty. {job="payment-service"} returns its last line at 04:12 on Sunday and nothing after it.

Everything the on-call runbook tells you to check is fine:

  • The application is running and writing. payment.log is growing on every pod, the mtime is seconds old, and tail shows well-formed JSON with current timestamps.
  • The collector is up. systemctl status alloy says active (running) on every payment host, started Friday, no restarts.
  • The collector is reading the file. Its loki_source_file_target_last_parsed_timestamp_seconds for the payment-service target is seconds old.
  • The collector is pushing. loki_write_sent_entries_total on each of those hosts is climbing steadily.
  • Loki is not rejecting anything. Every reason label on the distributor’s discard counter reads zero for the tenant.
  • The stream has not been renamed. logcli series still returns {job="payment-service"} with the labels it has always had, last entry 04:12 Sunday.

There is one other thing, and it arrived as a different ticket from a different team. The notifications job - which runs on the same hosts, through the same collector, into the same tenant - is short about eight percent of its lines for the same weekend. It was noticed because a nightly reconciliation count did not match, not because anything looked wrong.

The payment-service release notes for Sunday 04:10 say: “migrate application logging from logfmt to structured JSON”. It was reviewed as an application change.

Evidence provided

Read-only / Safehop 1 and 2 are clear - the file is current and growing
$ stat -c '%y %s' /var/log/payment-service/payment.log
2026-08-17 09:21:44.118 +0000 41284117

Illustrative output

Read-only / Safehops 3 and 4 look clear - but read the second counter carefully
$ curl -s http://localhost:12345/metrics | grep -E 'loki_source_file_target_last_parsed|loki_write_sent_entries_total'
loki_source_file_target_last_parsed_timestamp_seconds{path="/var/log/payment-service/payment.log"} 1.755424903e+09
loki_write_sent_entries_total{component_id="loki.write.default"} 8241193

Illustrative output

Read-only / SafeLoki is discarding nothing at all
$ curl -s http://loki-distributor.monitoring.svc:3100/metrics | grep loki_distributor_discarded_samples_total
loki_distributor_discarded_samples_total{reason="rate_limit",tenant="prod"} 0
loki_distributor_discarded_samples_total{reason="stream_limit",tenant="prod"} 0
loki_distributor_discarded_samples_total{reason="older_than",tenant="prod"} 0

Illustrative output

Read-only / Safethe one counter nobody alerted on
$ curl -s http://localhost:12345/metrics | grep loki_process_dropped_lines_total
loki_process_dropped_lines_total{reason="oversized_line"} 4112837

Illustrative output

The stage that owns that counter has been in the pipeline since June last year:

// /etc/alloy/config.alloy (extract)
loki.process "host" {
  stage.json {
    expressions = {
      level = "level",
    }
  }

  // Added after the 2025-06 stack-trace flood: one service logged full
  // stack traces with the request body attached and pushed a single
  // stream past its rate limit.
  stage.drop {
    longer_than         = "2KB"
    drop_counter_reason = "oversized_line"
  }

  forward_to = [loki.write.default.receiver]
}

And the line-length distribution of the two affected source files, measured over the last ten thousand lines of each:

Read-only / Safepayment-service: mean bytes, lines over 2 KB, lines sampled
$ tail -n 10000 /var/log/payment-service/payment.log | awk '{ s += length($0); if (length($0) > 2048) big++ } END { print int(s/NR), big, NR }'
2412 10000 10000

Illustrative output

Read-only / Safenotifications: eight percent over the same threshold, which is what it lost
$ tail -n 10000 /var/log/notifications/app.log | awk '{ s += length($0); if (length($0) > 2048) big++ } END { print int(s/NR), big, NR }'
1904 812 10000

Illustrative output

Work the evidence before reading on

Four questions, in the order they can be answered.

  1. Every hop from the source file to the distributor reports healthy, and the distributor discards nothing under any reason label. Given that, where in the chain can a line disappear such that neither end has a counter that moves?
  2. loki_write_sent_entries_total is climbing on the payment hosts. What does that counter aggregate over? What would you have to break it down by before it becomes evidence about this job rather than about the host?
  3. One service lost one hundred percent of its lines; another on the same host, through the same collector, lost eight percent. What kind of filter produces a total loss for one and a partial loss for another from a single unchanged rule?
  4. The release at 04:10 changed the logging format and nothing else. Name the property of the line - not of the service, not of the pipeline - that the change moved, and find the threshold it moved across.

Before continuing: if you removed the offending stage right now, what would happen to the other eight jobs in this tenant at the evening peak?

Root cause

The guard tests the line, not the service

stage.drop with longer_than discards any line above a byte threshold and increments loki_process_dropped_lines_total with the configured reason. It does not know which job the line came from, what the line means, or that the service which motivated it was decommissioned months ago. It is a predicate over bytes, applied to everything that passes through the pipeline.

That is exactly why it survived fourteen months without being noticed. In a fleet where every service writes logfmt at a few hundred bytes a line, a 2 KB threshold is invisible: it fires for the pathological stack trace it was written for and for nothing else. The guard was correct on the day it shipped and every day after, right up to the moment a service changed the size of its lines.

A format migration is a size migration

The Sunday release did not change how much payment-service logs. It changed what each record carries. Structured JSON stamps the service name, the version, the environment, the trace id, the span id and the request context object onto every record, where logfmt carried them on the few records that needed them. The line count is identical; the mean line went from about 380 bytes to about 2,412.

The whole distribution moved, and it moved past the threshold in one step. In a ten-thousand-line sample, all ten thousand lines are above 2 KB and the mean is 2,412 bytes: there is no partial survival here, because there is no part of the distribution left below the threshold.

notifications is the control group that makes the mechanism legible. It did not change at all. Its mean line was already 1,904 bytes - the rendered template is bulky - so it has been sitting astride this threshold the whole time, losing its longest records and only its longest records. Eight percent of its lines are over 2 KB. Eight percent is what it lost. The same rule, unchanged, produces total loss for one service and a rounding error for another, purely as a function of where each service’s line-length distribution sits.

Every watched signal was green because every watched signal was correct

This is the part worth carrying. The chain is: source file, application emitter, collector process, pipeline, distributor, query. The on-call runbook walks it hop by hop, and every hop answered honestly:

HopWhat was checkedAnswer
Source filemtime, sizegrowing, current
Applicationformat, levelemitting valid JSON
Collector processunit state, restartsrunning since Friday
Pipeline insource-file parse timestampseconds old
Pipeline outloki_write_sent_entries_totalclimbing
Distributordiscard counter by reasonall zero

Two of those answers are true and misleading. loki_write_sent_entries_total is a per-host total across every job the host ships, and the host also ships notifications, nginx and the node journal; it kept climbing because those kept flowing. And the distributor discarded nothing because the distributor never saw the lines - they were deleted one process earlier, in memory, before the batch was built.

The only counter that describes what actually happened is loki_process_dropped_lines_total, on the collector’s own metrics endpoint, and no alert had ever been written against it. Fourteen months of correct behaviour and one weekend of total data loss are indistinguishable from every dashboard in the estate.

Resolution

  1. Measure before you change anything. Take the line-length distribution of every job on the affected pipeline, not just the two you know about, and compare each against the 2 KB threshold. A third job sitting at 1.95 KB is the next incident.
  2. Compute the restored volume. Lines per second multiplied by the new mean line length, per job, summed - then add it to the tenant current rate and compare against ingestion_rate_mb and ingestion_burst_size_mb. This number decides whether the fix is one change or two.
  3. Raise the tenant limit first, if the arithmetic says it is needed, and let it settle before restoring the volume. Restoring the volume into an unraised limit converts a single-service outage into a tenant-wide one at the next peak.
  4. Narrow the guard rather than deleting it. Scope the drop to the job that motivated it, or raise longer_than to a value above the new distribution with headroom, and record in a comment which incident it came from and what it was measured against.
  5. Reload the collector and watch the drop counter, not the dashboard. loki_process_dropped_lines_total{reason="oversized_line"} should stop advancing for this pipeline within one scrape interval.
  6. Decide about Sunday explicitly, and write the decision down. The lines are still in the source files if rotation has not aged them out, but Loki rejects entries older than its timestamp window, so a replay needs a temporary, tenant-scoped widening of that window - which also admits clock-skewed garbage for as long as it is open. Holding, and accepting the gap, is a defensible answer with an owner and a date.
  7. Close the notifications ticket against this incident. It was filed separately, it has the same cause, and nobody will revisit it once the payment dashboard looks right.

Verification

  1. The drop counter stops. loki_process_dropped_lines_total{reason="oversized_line"} must go flat for this pipeline, not merely slow down. A rate that falls but stays non-zero means the new threshold still clips the tail of the distribution.
  2. The lines arrive, at the right rate. logcli query --since=5m for the job must return lines, and the count must reconcile against the source files - roughly 35 lines per second per pod. A stream that returns something but returns a third of what disk holds is the same bug at a higher threshold.
  3. The second victim is whole. The notifications reconciliation count must match its source files across a full day, not a spot sample. Its loss was eight percent; a spot check is not sensitive enough to see eight percent.
  4. The tenant survived the fix. Re-read loki_distributor_discarded_samples_total for every reason label at the evening peak, not at the hour the change shipped. Any movement on reason="rate_limit" means the limit was not raised far enough and the incident has been converted, not closed.
  5. The alert can fail. On a test pipeline, lower longer_than until it clips a known stream and confirm the new drop-rate alert fires inside its evaluation window. An alert on this counter that has only ever been quiet has not been tested.
  6. The smoke test passes end to end. Emit a synthetic line at current production length, through the real pipeline, and query it back from Loki within ten seconds. This is the check that would have caught the incident on Sunday morning.

Prevention

  • Alert on every drop counter the collector exposes. loki_process_dropped_lines_total, loki_write_dropped_entries_total and the source-side failure counters are the only signals that describe a line dying between the application and Loki. Without an alert on each, that death is silent by construction.
  • Prefer a server-side limit to a collector-side drop for anything protective. Loki’s own max_line_size rejects an over-long entry, counts it under a reason label on the distributor, and returns an error the collector logs. A stage.drop at the edge deletes the line and tells one counter on one host.
  • Scope every guard to the thing that motivated it, and comment it with the incident and the measurement it was sized against. A threshold with no provenance cannot be reviewed, and this one survived fourteen months of reviews precisely because nobody could say what it was for.
  • Treat a logging-format change as a platform change. The review question is not “does the new format parse” but “what does this do to bytes per second, to the tenant’s ingestion budget, and to every size-based or content-based filter between here and the bucket”.
  • Measure the line-length distribution on both sides of any format migration and compare it against every threshold on the path. It is one awk over a sample of the source file and it is the whole of this investigation.
  • Run the synthetic-line smoke test on a schedule, not during incidents. A known line at production length, pushed through the real pipeline and queried back, catches this entire class within one interval instead of within one reconciliation cycle.