← All runbooks in Observability
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 statusorchronyc 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.
- 1Hop 1 and 2 - the source.
statthe 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. - 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/nullis a misconfigured entrypoint, not a pipeline fault. - 3Hop 3 - is the collector running?
systemctl status alloyor 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. - 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).
- 5Hop 5 - is the distributor accepting? Read
loki_distributor_bytes_received_totalfor the suspect tenant, thenloki_discarded_samples_totalbroken down byreason. A non-zero reason label names the exact limit that is rejecting the push - do not guess at it. - 6Map the reason to a limit, and to a cause. Rate reasons point at
ingestion_rate_mborper_stream_rate_limit; an age reason points atreject_old_samples_max_ageand therefore at a clock, not at a limit; a stream-count reason points atmax_streams_per_userand therefore at a label, not at a limit. - 7Hop 6 - is it a query problem after all? If the distributor received the bytes, run
logcli seriesfor 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. - 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.
- 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.
- 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.
- 11Verify end to end, not per-hop. Emit a known line with a known value, then query it back with
logcliwithin ten seconds. Every hop between the application and the index is proven by that one round trip and by nothing less. - 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_totalfor the suspect tenant is climbing at a rate consistent with the service traffic, not merely non-zero. - ✓
loki_discarded_samples_totalshows no growth for anyreasonlabel on the suspect tenant across two consecutive scrapes. - ✓
logcli series --since=15mfor 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_totalnames 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.
| Action | Reversible? | What it costs if wrong |
|---|---|---|
Reading metrics, stat, systemctl status | n/a | Nothing |
| Log level change via runtime endpoint | Yes, instantly | Scoped to what you name |
| Collector config change plus reload | Yes, on revert | A reload that fails leaves the OLD config running |
| Collector restart | No, for the buffer | Everything the buffer held is gone |
| Loki limit change via runtime overrides | Yes, seconds | Shared capacity; needs a budget owner |
| Enabling a disk spool | Yes | Disk; 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
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:8080Illustrative 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:
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-pagerLOG_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/nullIllustrative 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?
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.
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"} 0Illustrative output
Read the four families as a set:
| Counter family | Meaning when it moves | Meaning when it is frozen |
|---|---|---|
| Source-file target timestamp | The tail is advancing | The file is not growing - go back to hop 1 |
| Sent entries | The push is succeeding | Nothing is leaving the agent |
| Dropped entries | The buffer overflowed or the retry budget ran out | Healthy |
| Process dropped lines | A drop or filter stage is matching | Healthy, 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.
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"} 0Illustrative 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 at | Limit involved | What is actually wrong |
|---|---|---|
| A per-tenant rate | ingestion_rate_mb, ingestion_burst_size_mb | The tenant is genuinely over budget, or the budget was never sized |
| A per-stream rate | per_stream_rate_limit, per_stream_rate_limit_burst | One noisy stream, usually one label value |
| Sample age | reject_old_samples_max_age | A clock, not a limit. Fix time sync |
| Stream count | max_streams_per_user | A label carrying unbounded values |
| Line length | max_line_size | A 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.
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.
| Hop | Fix | Reversible form |
|---|---|---|
| 1-2 | Log level, destination, entrypoint | Runtime logger endpoint, not a redeploy |
| 3 | Start the collector; fix the reload error | Fix the config first, then start - a start with a bad config fails again |
| 4 | Correct the parse stage or the target glob | Reload, not restart, so the buffer survives |
| 5 | Raise a limit, or fix the source-side label | Runtime overrides file, not the static config |
| 6 | Correct the query, tenant or datasource | Edit 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.
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 see | Hop | Where to look |
|---|---|---|
| Source file mtime older than the window | 1-2 | Log level env var, or the entrypoint |
Process alive, descriptors 1 and 2 at /dev/null | 2 | The container entrypoint or pod manifest |
Collector inactive (dead) | 3 | Agent log for the reload error; journal for an OOM kill |
Collector in activating (auto-restart) | 3 | Crash loop - read the log, do not restart again |
| Panel half-full, collector healthy | 4 | Parse stage failure, or a drop stage matching too broadly |
| Source timestamp frozen, file growing | 4 | The target glob no longer matches the path |
| Collector sending, distributor bytes flat | 5 | loki_discarded_samples_total by reason |
| Agent log showing HTTP 429 | 5 | A rate limit; read which one before raising anything |
| Every line rejected, one host only | 5 | Clock drift on that host |
| Distributor receiving, query empty | 6 | Label rename, tenant header, or time range |
| Everything missing, every service | 3 or 5 | The 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.