Skip to main content
RunBook Academy

← All runbooks in Observability

high riskcluster affecting~45 min

Runbook: Investigate Loki Ingestion Failure

1 · Prerequisites

Confirm every item is in place before any state change.

  • Missing Logs Anatomy
  • Application Not Emitting
  • Collector Not Running
  • Pipeline Broken
  • Loki Rate Limited
  • Loki Ingestion Limits
  • Shell access on the host that runs the affected application, and on the host that runs its collector
  • Read access to the collector local metrics endpoint (Alloy on port 12345, OpenTelemetry Collector on 8888) and to the Loki distributor metrics endpoint
  • logcli or Grafana Explore, with the tenant header the affected service pushes under
  • The deploy timeline for the affected service - the commonest cause of this incident is a change, and the change window is faster than any query

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The suspect window is written down with a start time. "Logs are missing" without a window cannot be distinguished from "logs were never expected", and the window is what every subsequent read is compared against.
  • · It is established whether logs are MISSING or WRONG. An empty result is this runbook; a result that returns the wrong lines is a query problem and starts at the other end of the chain.
  • · The scope is established: one service, one host, one tenant, or everything. Everything at once points at the collector fleet or at Loki, not at an application.
  • · Whether OTHER services are still arriving in Loki is confirmed. A cluster still ingesting from twenty other jobs rules out most of the Loki-side causes in one query.
  • · The Loki read path is confirmed healthy independently - a query for any known-busy job returns lines. If it does not, this is a Loki availability incident and the diagnosis order changes.
  • · Nothing has been restarted yet. A collector restarted before the metrics were read has destroyed the evidence AND whatever its buffer was holding.
  • · Host clock sanity is checked on the emitting host: timedatectl status or chronyc tracking. Clock drift produces a rejection that looks exactly like a pipeline failure.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Hop 1 and 2 - the source. stat the log file the collector is configured to read, or check the container stdout path. A modify time older than the suspect window is the single highest-signal read in this runbook, and it is the smoking gun for roughly half of these incidents.
  2. 2Hop 2 - is the application actually emitting? If the file is stale but the process is alive, read its environment (LOG_LEVEL, LOG_DESTINATION) and its file descriptors under /proc. Descriptors 1 and 2 pointing at /dev/null is a misconfigured entrypoint, not a pipeline fault.
  3. 3Hop 3 - is the collector running? systemctl status alloy or the equivalent container check. inactive (dead) with a start time before the suspect window bounds the data loss exactly. Read the agent log for the cause before restarting anything, because the restart erases it.
  4. 4Hop 4 - is the pipeline delivering? Read the collector local metrics: the source-file target timestamp gauge (recent, or frozen), sent-entries counters (climbing, or flat), and the dropped-entries and failed-file counters (any non-zero value names the failing stage).
  5. 5Hop 5 - is the distributor accepting? Read loki_distributor_bytes_received_total for the suspect tenant, then loki_discarded_samples_total broken down by reason. A non-zero reason label names the exact limit that is rejecting the push - do not guess at it.
  6. 6Map the reason to a limit, and to a cause. Rate reasons point at ingestion_rate_mb or per_stream_rate_limit; an age reason points at reject_old_samples_max_age and therefore at a clock, not at a limit; a stream-count reason points at max_streams_per_user and therefore at a label, not at a limit.
  7. 7Hop 6 - is it a query problem after all? If the distributor received the bytes, run logcli series for the selector. A stream list that comes back with a label value you did not expect means a relabel change created a new stream and the dashboard is querying the old one.
  8. 8State the hop before applying anything. The sentence "hop N is broken because X, evidence Y" is the gate on every change below. A fix applied without it is a guess that also destroys evidence.
  9. 9Apply the fix that matches the hop. Log level or entrypoint at hops 1-2; collector config or supervisor at hop 3; parse or drop stage at hop 4; a limit raise or a source-side label fix at hop 5; the dashboard or datasource at hop 6.
  10. 10Prefer the reversible form of every fix. Change the log level through the runtime endpoint rather than a redeploy. Correct a limit in the Loki runtime overrides file rather than the static config. Reload the collector rather than restarting it, so the buffer survives.
  11. 11Verify end to end, not per-hop. Emit a known line with a known value, then query it back with logcli within ten seconds. Every hop between the application and the index is proven by that one round trip and by nothing less.
  12. 12Bound the gap and write it down. Start time, end time, affected jobs, and whether the lines were buffered and replayed or lost outright. A missing-logs incident whose gap is undocumented is one that will be re-investigated the next time somebody queries that window.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓A synthetic line pushed or emitted now is queryable in Loki within ten seconds - this is the only check that proves all six hops at once.
  • ✓The collector sent-entries counter is climbing again and its dropped-entries counter is flat, read from the collector local metrics endpoint rather than inferred from its log.
  • ✓loki_distributor_bytes_received_total for the suspect tenant is climbing at a rate consistent with the service traffic, not merely non-zero.
  • ✓loki_discarded_samples_total shows no growth for any reason label on the suspect tenant across two consecutive scrapes.
  • ✓logcli series --since=15m for the service selector returns the stream list, and the labels in it match what the dashboard actually queries.
  • ✓The original failing dashboard panel renders lines, checked from the panel and not only from logcli - the datasource and tenant header are part of the chain.
  • ✓The gap boundaries are established: which lines were buffered and replayed, and which were lost. "The logs are back" is not the same statement as "no logs were lost".
  • ✓If a limit was raised, the new value is recorded against the capacity budget with an owner, so it is a decision rather than a drift.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Hops 1 to 6 are read-only diagnosis. There is nothing to roll back until a fix is applied.
  • ↶To undo a log-level change: set it back through the same runtime endpoint or config path. This is the cheapest item on the list and it is reversible in milliseconds.
  • ↶To undo a collector config change: revert the commit and reload the collector, then confirm the rule is absent from the collector ACTIVE component list, not merely from the file on disk. A config that failed to reload leaves the old rule running.
  • ↶To undo a Loki limit change: revert the value in the runtime overrides file (picked up within seconds) or the static limits config (needs a reload), then confirm the effective value at the Loki config endpoint.
  • ↶If the collector was restarted, its in-memory buffer is gone and so are the lines it held. There is no rollback; record the loss window.
  • ↶If a disk spool was enabled during the incident, leave it enabled. It is the control that turns the next Loki outage from data loss into a queue that drains.
  • ↶If host key material, credentials or a tenant header were changed by hand to restore the push, replace them with the managed values and remove the manual ones before closing the incident.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the service owner when the failure is at hop 1 or hop 2. The application is silent, the platform is innocent, and no platform-side change will fix it.
  • · Escalate to the platform team when loki_discarded_samples_total names a rate or stream limit. Raising a shared limit is a capacity decision with a budget owner, not an on-call decision.
  • · Escalate to whoever owns the host fleet when the reason label is an age rejection. The fix is time synchronisation across the fleet, and a single drifting host is usually not the only one.
  • · Escalate to a second operator before restarting a collector that is holding a buffer, or before moving a spool directory aside. Both are irreversible for whatever they hold.
  • · Escalate immediately if logs are missing for a system in scope for an audit or a retention obligation. The gap is a compliance event with a clock on it, independent of the technical fix.
  • · Escalate to security if the collector stopped for a reason nobody can account for, or if the tenant header was changed without a change record. Logs quietly stopping is also what tampering looks like.

“Logs are missing” is one symptom produced by six independent failures. Between the moment an application calls its logger and the moment a panel shows the line there are six hops, each can break on its own, and every one of them presents as the same empty result.

  1. Source        2. App emitter    3. Collector
     file or          level, format     process alive?
     stdout           destination
        |                  |                 |
        +------------------+-----------------+
                           |
                           v
  6. Query         5. Distributor      4. Pipeline
     LogQL,           limits, age,        parse, drop,
     tenant,          stream count        buffer, push
     time range

The entire value of this runbook is the order. Walk from hop 1 forward. Never start at the panel.

The three reflexes, and what each one destroys

Each of these is the first thing an operator reaches for, and each one makes the incident harder.

  • “I will reload the collector.” The collector is usually already running. A reload restarts its in-memory state; it does not cause an application to emit a line it was never going to emit. If the collector was buffering during a Loki outage, a restart discards that buffer - you have converted a recoverable queue into permanent loss while trying to diagnose.
  • “I will restart Loki.” Loki rarely causes a missing-logs incident, and restarting it removes the only evidence about which hop is broken. You lose the discarded-samples counters, the per-tenant byte rates, and the ability to distinguish a rate rejection from an age rejection.
  • “I will rewrite the query.” The dashboard query did not change at 03:14. Something upstream did. Rewriting it produces a second empty result and a growing suspicion that the query language is at fault.

When this runbook applies, and when it does not

It applies when a LogQL query that should return lines returns nothing, and the window is recent enough to be inside retention.

It does not apply when:

  • Volume is high rather than absent. That is the ingestion-spike path, and it has a different runbook and different levers.
  • The panel returns lines, but the wrong ones. That is a query, a parser or a time-range problem, and it starts at hop 6 rather than hop 1 - one of the very few cases where walking backwards is correct.
  • The window is outside retention, or inside a compaction eviction window. The logs are gone by policy. That is a retention conversation, not an ingestion failure.
  • Loki itself is down or not serving reads. Confirm the read path independently first; if a query for a known-busy job also returns nothing, this is a Loki availability incident.

Blast radius

Hops 1 to 6 are read-only. The risk badge on this page reflects the remediation, not the diagnosis: the fixes reach into an application configuration, a collector, and in the worst case a shared Loki limit.

ActionReversible?What it costs if wrong
Reading metrics, stat, systemctl statusn/aNothing
Log level change via runtime endpointYes, instantlyScoped to what you name
Collector config change plus reloadYes, on revertA reload that fails leaves the OLD config running
Collector restartNo, for the bufferEverything the buffer held is gone
Loki limit change via runtime overridesYes, secondsShared capacity; needs a budget owner
Enabling a disk spoolYesDisk; almost always worth it

Notice the asymmetry between reload and restart on the collector. They look interchangeable in a chat message and they are not.

Step 1 - The source file, and the application behind it

Read-only / Safethirty seconds that closes half of these incidents
LOGFILE=/var/log/payment-service/payment.log

stat "$LOGFILE"
tail -n 2 "$LOGFILE"
  File: /var/log/payment-service/payment.log
Size: 18421     Blocks: 40         IO Block: 4096   regular file
Modify: 2026-08-14 02:38:11.000000000 +0000

2026-08-14T02:38:11Z INFO  payment worker started
2026-08-14T02:38:11Z INFO  listening on 0.0.0.0:8080

Illustrative output

A modify time older than the suspect window ends the search at hop 1 or hop 2. The collector is innocent, the distributor is innocent, and the query is innocent, and you now know that without having touched any of them.

If the file is stale but the process is alive, ask the application three questions:

Read-only / Safelevel, destination, and where the descriptors actually point
PID=$(pgrep -f payment-service | head -1)

tr '\0' '\n' < "/proc/$PID/environ" | grep -i -E 'log|level'

ls -la "/proc/$PID/fd" | head -10

journalctl -u payment-service -n 30 --no-pager
LOG_LEVEL=warn
LOG_FORMAT=json
LOG_DESTINATION=stdout

lrwx------ 1 app app 64 Aug 14 01:00 0 -> /dev/null
lrwx------ 1 app app 64 Aug 14 01:00 1 -> /dev/null
lrwx------ 1 app app 64 Aug 14 01:00 2 -> /dev/null

Illustrative output

LOG_LEVEL=warn on a service whose routine traffic is info is the whole incident. So is stdout pointing at /dev/null - the process is running, healthy, and writing every line into the void, and no amount of collector work will retrieve them.

Step 2 - Is the collector running at all?

Read-only / Safeunit state first, cause second
systemctl status alloy --no-pager | head -12

journalctl -u alloy -n 100 --no-pager | tail -30
● alloy.service - Grafana Alloy
   Loaded: loaded (/etc/systemd/system/alloy.service; enabled)
   Active: inactive (dead) since Fri 2026-08-14 01:12:04 UTC
 Main PID: 18421 (code=exited, status=1/FAILURE)

Illustrative output

inactive (dead) with a stop time before the suspect window bounds the data loss precisely: everything the service emitted between that timestamp and now is gone from Loki, and nothing will bring it back except the source file, if the source file still exists.

Read the agent log for the cause before restarting. The commonest four are a failed config reload, an OOM kill, a liveness probe restart loop, and a missing dependency, and the restart erases the evidence for three of them. An OOM leaves nothing in the agent log at all - the kernel records it, so check the journal for the kill line rather than concluding the agent exited cleanly.

activating (auto-restart) with a start time seconds ago is a different diagnosis entirely: that is a crash loop, and restarting it again is the one move guaranteed to teach you nothing.

Step 3 - Is the pipeline delivering?

A collector can be running and still be silently dropping. This is the shape that produces a half-full panel, which is harder to notice than an empty one.

Read-only / Safethe collector's own counters - the only honest signal
curl -s http://localhost:12345/metrics \
| grep -E '^loki_(source_file|write|process)' \
| grep -v '^#'
loki_source_file_target_last_parsed_timestamp_seconds{path="/var/log/payment-service/payment.log"} 1.7708e+09
loki_write_sent_entries_total{endpoint="default"} 18421
loki_write_dropped_entries_total{endpoint="default"} 0
loki_process_dropped_lines_total{reason="noisy_debug"} 0

Illustrative output

Read the four families as a set:

Counter familyMeaning when it movesMeaning when it is frozen
Source-file target timestampThe tail is advancingThe file is not growing - go back to hop 1
Sent entriesThe push is succeedingNothing is leaving the agent
Dropped entriesThe buffer overflowed or the retry budget ran outHealthy
Process dropped linesA drop or filter stage is matchingHealthy, or the stage is not loaded

A frozen source timestamp with a growing file means the target glob no longer matches - a very common outcome of a log-path change nobody linked to the collector config. A non-zero dropped-entries count with a healthy sent count means the pipeline is working and the backend is refusing, which hands you straight to the next step.

Step 4 - Is the distributor accepting? Read the reason label

This is the step that most often gets skipped, and skipping it is expensive: a collector that is healthy and pushing into a distributor that is rejecting looks, from every hop above, exactly like a healthy pipeline.

Read-only / Safebytes in, and what is being thrown away
LOKI=http://loki-distributor.monitoring.svc:3100

curl -s "$LOKI/metrics" | grep '^loki_distributor_bytes_received_total'

curl -s "$LOKI/metrics" | grep '^loki_discarded_samples_total'
loki_distributor_bytes_received_total{tenant="prod"} 4.84e+08
loki_discarded_samples_total{reason="rate_limited",tenant="prod"} 0
loki_discarded_samples_total{reason="stream_rate_limited",tenant="prod"} 4812
loki_discarded_samples_total{reason="greater_than_max_sample_age",tenant="prod"} 0

Illustrative output

The reason label names the limit. Do not guess at it, and do not raise a limit before reading it - raising the per-tenant rate when the per-stream rate is what fired makes the symptom vanish for an hour and return under the next load peak, which is the most expensive possible outcome.

Reason points atLimit involvedWhat is actually wrong
A per-tenant rateingestion_rate_mb, ingestion_burst_size_mbThe tenant is genuinely over budget, or the budget was never sized
A per-stream rateper_stream_rate_limit, per_stream_rate_limit_burstOne noisy stream, usually one label value
Sample agereject_old_samples_max_ageA clock, not a limit. Fix time sync
Stream countmax_streams_per_userA label carrying unbounded values
Line lengthmax_line_sizeA payload being logged that should not be

Two of those rows are not limit problems at all. An age rejection is a time-synchronisation problem on the emitting host - confirm with chronyc tracking or timedatectl status before touching Loki. A stream count rejection is a label-set problem in the collector pipeline; raising max_streams_per_user to accommodate an unbounded label is how a missing-logs incident becomes next month’s ingester memory incident.

Step 5 - Only now, the query

If the distributor received the bytes, the lines are in Loki and the problem is on the read side.

Read-only / Safewhat streams actually exist, versus what the dashboard asks for
export LOKI_ADDR=http://loki-query.monitoring.svc:3100
export LOKI_ORG_ID=prod

logcli series --since=1h '{job="payment-service"}'

logcli labels job
{cluster="prod", env="prod", instance="payments-7d4b", job="payment-service"}
{cluster="prod", env="prod", instance="payments-9f21", job="payment-service"}

Illustrative output

A stream list that comes back with a label value the dashboard does not use means a relabel change created a new stream and the panel is still querying the old one. An empty stream list with healthy distributor bytes means the tenant header differs between what the collector pushes and what the datasource queries - the lines are in Loki, under a tenant you are not looking at.

Step 6 - Fix at the hop, in the reversible form

The fix belongs to the hop the evidence names, and every fix has a cheaper reversible variant that is almost always available under incident conditions.

HopFixReversible form
1-2Log level, destination, entrypointRuntime logger endpoint, not a redeploy
3Start the collector; fix the reload errorFix the config first, then start - a start with a bad config fails again
4Correct the parse stage or the target globReload, not restart, so the buffer survives
5Raise a limit, or fix the source-side labelRuntime overrides file, not the static config
6Correct the query, tenant or datasourceEdit the panel, not the pipeline
# /etc/loki/runtime-config.yaml
# The right place for an incident-scoped limit change: one tenant, one
# file, picked up within seconds, and trivially revertible. The static
# limits_config is for the deliberate change that follows the review.
overrides:
  prod:
    ingestion_rate_mb: 32
    ingestion_burst_size_mb: 48
    per_stream_rate_limit: 8MB
    per_stream_rate_limit_burst: 16MB

Hold, when the queue is draining

If Loki has recovered and the collectors are working through a backlog, the correct action is frequently to do nothing and watch the sent-entries counters climb. Lines are arriving late rather than not at all, and every restart during that window converts late into lost.

State the hold the same way as any other action: an owner by name, a checkpoint (sent-entries and dropped-entries re-read every five minutes), and an exit condition (the backlog is drained, or dropped-entries starts climbing, which means the retry budget has run out and the queue is being shed).

Verify with a round trip, not with six checks

Per-hop checks prove per-hop health. Only an end-to-end round trip proves the chain.

Read-only / Safeemit a known value, then find it
MARKER="smoke-$(date -u +%s)"
logger -t payment-service "$MARKER ingestion smoke test"

sleep 10

export LOKI_ADDR=http://loki-query.monitoring.svc:3100
export LOKI_ORG_ID=prod
logcli query --since=5m --limit=10 --quiet "{job=\"payment-service\"} |= \"$MARKER\""

Run this after every collector configuration change, not only during incidents. It is the check that would have caught the change before it became a page.

Common patterns

What you seeHopWhere to look
Source file mtime older than the window1-2Log level env var, or the entrypoint
Process alive, descriptors 1 and 2 at /dev/null2The container entrypoint or pod manifest
Collector inactive (dead)3Agent log for the reload error; journal for an OOM kill
Collector in activating (auto-restart)3Crash loop - read the log, do not restart again
Panel half-full, collector healthy4Parse stage failure, or a drop stage matching too broadly
Source timestamp frozen, file growing4The target glob no longer matches the path
Collector sending, distributor bytes flat5loki_discarded_samples_total by reason
Agent log showing HTTP 4295A rate limit; read which one before raising anything
Every line rejected, one host only5Clock drift on that host
Distributor receiving, query empty6Label rename, tenant header, or time range
Everything missing, every service3 or 5The collector fleet config, or Loki - not the applications

Prevention

Three controls catch this class before a human does.

The first is an alert on loki_distributor_bytes_received_total going flat for a tenant that is normally busy. Absence of data is the one condition dashboards never show and alerts on rate rarely catch.

The second is an alert on any growth in loki_discarded_samples_total, routed with the reason label in the notification. That single label turns a twenty-minute pipeline investigation into a one-line diagnosis.

The third is a synthetic writer - Loki Canary is the purpose-built one - which continuously writes lines and reads them back through the same path the fleet uses. Its value is that it fails when the pipeline fails, independently of whether any application happens to be logging, so the platform learns about the outage before a user does.

References

  1. Loki configuration: limits_config
  2. Loki operations: ingestion tuning
  3. Loki component metrics reference
  4. Loki Canary
  5. logcli
  6. Grafana Alloy: loki.source.file
  7. Grafana Alloy: loki.write