← All runbooks in Observability
Runbook: Investigate OTel Collector Failure
1 · Prerequisites
Confirm every item is in place before any state change.
- Collector Anatomy
- Collector Down
- Collector Failures
- OTel Collector Pressure
- OTel Collector Configuration
- Read access to the collector self-telemetry endpoint (
service::telemetry::metrics,localhost:8888by default) - A way to read the collector process logs, including the log of the PREVIOUS container or unit start
- The running configuration file, and the repository commit it is supposed to match
- Knowledge of which signals (traces, metrics, logs) share this collector - the pipelines are separate but the process is not
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The affected signal is named: traces, metrics or logs. A collector usually runs several pipelines in one process and only one of them may be failing; "telemetry is broken" is not yet a diagnosis.
- · The blast radius is known: one collector, one deployment, or every collector at once. All of them at once points at a shared configuration push or a shared destination, not at a host.
- · The restart count has been read BEFORE anything is restarted. A collector that has restarted three times in twenty minutes is a different incident from one that has been up for a week and stopped exporting.
- · The counters are read before any restart.
otelcol_receiver_accepted_*andotelcol_exporter_sent_*are cumulative from process start; a restart zeroes the evidence that would localise the failure. - · Whether the exporter queue is backed by disk (
file_storageextension) is established. Without it, everything buffered in memory is lost the moment the process restarts, which changes whether a restart is cheap. - · The time the symptom started is pinned against the deploy or config-push timeline for BOTH the collector and the backend it exports to.
- · It is established whether the collector is the only one in the path. An agent-plus-gateway topology has two collectors and the counters must be read at both, or the investigation attributes a gateway failure to an agent.
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Establish the process state before touching it.
systemctl status otelcolorkubectl get pods -l app=otel-collector. A restart loop is a START failure and is diagnosed from the previous log, not from the counters - the counters never got a chance to move. - 2On a restart loop, read the PREVIOUS log.
journalctl -u otelcol -n 100orkubectl logs deploy/otel-collector --previous. A start failure names the exact component: an unknown component key, an invalid field value, or a pipeline that references something the receivers block does not declare. The error is the answer; there is nothing to infer. - 3On a running collector, read the two counters that define the quadrant.
curl -s localhost:8888/metrics | grep -E "otelcol_(receiver_accepted|exporter_sent)". Nothing accepted is an inbound problem; accepted but not sent is an outbound problem; both climbing is a problem downstream of the collector. Do not skip this because the answer feels obvious. - 4If nothing is being accepted: prove the port is bound before blaming the network.
ss -lntp | grep -E "4317|4318"on the collector host. A bound port with no traffic is a network or client problem; an unbound port on a healthy process is a receiver that was never built - the classic typo in the receivers block, which starts cleanly and reports healthy because no receiver is unhealthy, it simply is not there. - 5Confirm the pipeline really contains the receiver. A component declared under
receivers:but never named inservice::pipelinesis loaded and unused. The zpages extension at/debug/pipelinezshows the pipelines the runtime actually built, which is the only authority on this. - 6If accepted is climbing but sent is not: go to the exporter, not the receiver. Read
otelcol_exporter_send_failed_*,otelcol_exporter_queue_sizeandotelcol_exporter_queue_capacitytogether. A queue at capacity with failures climbing is the signature; the queue depth alone tells you nothing without the capacity beside it. - 7Separate the three exporter bands, because they have three different fixes. Queue overflow (the destination keeps up but not fast enough), retry exhaustion (the destination was unreachable for longer than
max_elapsed_time), and permanent rejection (the destination is reachable and refusing - wrong auth, wrong tenant header, wrong endpoint). The exporter log line names which one. - 8Test the destination from the collector host, not from your laptop. Reachability from the collector network namespace is the only reachability that matters, and a service mesh or network policy makes the two answers differ.
- 9**If
otelcol_processor_refused_*is climbing, the memory limiter is shedding load - find out why before raising the limit.** A collector under memory pressure is usually a collector with a slow exporter behind it. Raisinglimit_mibin that state moves the failure from controlled shedding to an OOM kill, which loses the queue as well. - 10Make one change, at the edge the counters named. Validate first:
otelcol validate --config=/etc/otelcol/config.yaml. Then apply. A configuration reload is not universally supported by container images, so confirm the log records the reload rather than assuming it. - 11Verify by direction, not by absence of errors.
otelcol_receiver_accepted_*andotelcol_exporter_sent_*must both be rising, at rates in the same order of magnitude, withsend_failedandrefusedflat. Then query the backend for data written AFTER the fix. - 12Record the gap and close it out. Everything dropped at the receiver, everything shed by the memory limiter and everything that exhausted its retries is gone. Write down the window and the affected signals; the investigation that needs those minutes will happen later, by someone else.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓
otelcol_receiver_accepted_*for the affected signal is climbing at roughly the rate the sources produce, not merely non-zero. - ✓
otelcol_exporter_sent_*is climbing in the same order of magnitude as accepted. A large and persistent gap between the two is a pipeline that is still losing data, whatever the logs say. - ✓
rate(otelcol_exporter_send_failed_*)andrate(otelcol_processor_refused_*)are at zero over a window that is longer than one batch timeout. - ✓
otelcol_exporter_queue_sizesits well belowotelcol_exporter_queue_capacityin steady state. A queue that is merely draining is not yet a queue that is healthy - re-check after one full batch interval. - ✓The health check extension returns success on its configured port (13133 by default). Note this is a weak check on its own: a collector with no receiver built also reports healthy.
- ✓The destination actually holds the data: query Tempo, Loki or Prometheus for something written after the fix landed, by trace ID, stream selector or metric name. The exporter counter proves a send, not an ingest.
- ✓The process has not restarted since the change: an incrementing restart count with healthy counters means the collector is being killed and recovering between reads.
- ✓The gap window is documented with a start time, an end time and the list of signals affected.
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Every diagnostic step up to the configuration change is read-only and has nothing to undo.
- ↶To undo a configuration change: restore the previous file, run
otelcol validateagainst it, and restart. The restart costs whatever is in the in-memory sending queue - the same cost as the change that is being reverted, paid twice. - ↶If
limit_mibwas raised and the process is now being OOM-killed by the kernel or the container runtime, put it back immediately. Controlled shedding drops some data; an OOM kill drops the queue as well and takes the process with it. - ↶If
num_consumerswas raised and the backend degraded further, revert it. More parallel senders against a struggling destination is a load test aimed at the thing that is already failing. - ↶If
max_elapsed_timeorqueue_sizewas raised, the memory footprint rose with it. Revert on the same reasoning as above, and treat the sizing as a capacity change to be made in daylight rather than an incident action. - ↶There is no rollback for dropped telemetry. Spans refused at the receiver, records shed by the memory limiter, and batches that exhausted their retries are gone permanently; the only recoverable case is a disk-backed queue that survived the restart.
- ↶Any change merged under pressure is temporary until it exists in the repository with a reviewer on it - a collector whose running config differs from the repository is the next incident.
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate to the backend owner as soon as the failure is shown to be permanent rejection: a 4xx from Tempo, Loki or the remote-write endpoint is a contract problem (auth, tenant, path) that no collector-side tuning fixes.
- · Escalate to the network or platform team when the port is bound on the collector and unreachable from the client namespace. That is a policy, mesh or DNS problem and the collector configuration is not where it lives.
- · Escalate to the emitting service team when one source is responsible for the volume that pushed the collector into shedding. The collector-side mitigation buys time; the sampling or instrumentation fix belongs to them.
- · Escalate to capacity planning if the collector sheds load at steady-state volume with a healthy destination. That is a sizing conversation - more replicas, or a split of the traces and metrics pipelines into separate processes - and not something to solve with one more restart.
- · Escalate to security if the OTLP receiver is reachable from outside the expected client set, or if the volume spike originates from an unrecognised source. An unauthenticated receiver on a routable network accepts arbitrary telemetry from anyone who finds it.
- · Escalate to incident command as soon as the gap in telemetry becomes long enough to matter to an investigation in progress. A team debugging a production incident needs to be told that the evidence for the last twenty minutes does not exist.
The collector is a middle box, and every middle box has the same diagnostic shape: data comes in one edge and leaves the other, so the first question is always which edge. Everything else in this runbook is downstream of getting that answer, and the answer is two counters away.
What makes this incident expensive is not that the answer is hard. It is that the collector is a single process carrying every signal the platform has, so “traces are missing” and “logs are missing” and “the collector is fine, look, it says healthy” can all be true at the same time, about the same process, for three different reasons.
The trap: restarting before reading the counters
A restart is the reflex, and here it is actively destructive in two ways.
The self-telemetry counters are cumulative from process start. sent versus
accepted over the last hour is the entire diagnosis, and a restart replaces
it with two zeroes. You then have to wait for enough traffic to re-establish
the shape you just deleted, under time pressure, having lost the before-picture
that would have told you when it changed.
The second cost is the sending queue. Unless the exporter is backed by the
file_storage extension, that queue lives in memory, and a restart drops
everything in it. If the collector is buffering through a backend outage, the
restart converts a delay into permanent data loss - and the operator who does
it usually believes they are fixing the outage.
Read the counters. Then decide.
The quadrant
Two counter families answer the “which edge” question before any change is made.
receiver_accepted | exporter_sent | What it means | Where to work |
|---|---|---|---|
| Flat | Flat | Nothing is arriving | Inbound: process, port, receiver wiring, network |
| Climbing | Flat | Arriving, not leaving | Outbound: exporter, queue, destination |
| Climbing | Climbing | The collector is doing its job | Downstream: backend ingest, tenant, retention, query |
| Flat | Climbing | The inflow stopped; a backlog is draining | Inbound - and note the queue is emptying |
A fifth row hides inside the first: otelcol_processor_refused_* climbing while
accepted falls. That is the memory limiter shedding load, and it is not an
inbound failure at all - it is an outbound failure that has propagated backwards
through the pipeline until it became visible at the front door.
When this runbook applies, and when it does not
It applies when a signal is missing or partial downstream and the collector is in the path.
It does not apply when:
- Both counters climb at the expected rate and the backend still has no data. The collector has done its job. The failure is at the destination - wrong tenant header, wrong endpoint, an ingest limit, a retention setting, or a query that does not match what was written. Go to that backend’s runbook.
- The application never emitted anything. An SDK that was never initialised, a sampler set to drop, an endpoint environment variable pointing somewhere else: the receiver counter is flat because nothing is being sent, and nothing in this runbook reaches that far upstream.
- Only one signal is affected and its pipeline is not wired to this collector. Pipelines are declared per signal. Confirm the affected signal’s pipeline actually names this component before spending an hour on it.
Blast radius
Steps 1 to 9 are read-only. Everything after that changes what the collector does with data that is arriving right now.
| Action | Reversible? | What it costs if wrong |
|---|---|---|
| Reading counters, logs, config | n/a | Nothing |
| Restarting the collector | No | The in-memory sending queue, and the counter history |
Raising limit_mib | Yes | An OOM kill instead of controlled shedding - strictly worse |
Raising queue_size | Yes | Memory, roughly linear in queued items |
Raising num_consumers | Yes | More concurrent load on a destination that may already be failing |
Raising max_elapsed_time | Yes | More buffering, and delivery of data old enough to mislead |
Disabling memory_limiter | Yes | The only brake before the kernel’s |
The pattern in that table is worth naming: almost every collector-side knob trades memory for durability, and the ones that look like fixes for a memory problem are usually the ones that make an OOM more likely. The memory limiter shedding load is the system working. The kernel killing the process is the system failing.
Step 1 - Process state, before anything else
systemctl status otelcol --no-pager | head -20
# Or, on Kubernetes - the RESTARTS column is the part that matters:
kubectl get pods -n observability -l app=otel-collectorNAME READY STATUS RESTARTS
otel-collector-7c4b8f9d8d-abcde 1/1 Running 0
otel-collector-7c4b8f9d8d-fghij 1/1 Running 0
otel-collector-7c4b8f9d8d-klmno 0/1 CrashLoopBackOff 5Illustrative output
A restart loop and a running-but-not-exporting collector are different incidents with different evidence, and the branch is here.
Step 2 - A restart loop is diagnosed from the previous log
The collector fails fast and loudly on configuration errors. A component key the binary does not ship, an invalid value for a field, or a pipeline that names a component the receivers block never declared: each of these is a start failure, the process exits non-zero, and the supervisor restarts it.
journalctl -u otelcol -n 100 --no-pager
# On Kubernetes, --previous is the whole point: the current container
# may still be starting and its log will not contain the error yet.
kubectl logs deploy/otel-collector -n observability --previous | tail -40The error names the component. There is nothing to infer from it and no hypothesis to form - fix what it names, validate, and restart. The counters are irrelevant in this branch because the pipeline never ran.
Step 3 - The two counters
curl -s http://localhost:8888/metrics \
| grep -E '^otelcol_(receiver_accepted|exporter_sent)'otelcol_receiver_accepted_spans{receiver="otlp",transport="grpc"} 128420
otelcol_receiver_accepted_spans{receiver="otlp",transport="http"} 4211
otelcol_exporter_sent_spans{exporter="otlp/tempo"} 6104Illustrative output
Grep the family rather than an exact metric name. The suffix is per-signal -
_spans, _metric_points, _log_records - and the names have picked up a
_total suffix in newer collector releases, so a grep for one exact string is
how an operator concludes that a metric “does not exist” when it is sitting two
characters away.
Read the numbers as a ratio over time, not as levels. Accepted at 132,631 and sent at 6,104 is the second row of the quadrant, and it is the reason to stop reading the receiver configuration.
Step 4 - Inbound: a bound port is not the same as a built receiver
ss -lntp | grep -E '4317|4318'
# From a client, in the client's own network namespace:
nc -zv otel-collector.observability.svc 4317LISTEN 0 4096 *:4317 *:* users:(("otelcol",pid=814,fd=9))
LISTEN 0 4096 *:4318 *:* users:(("otelcol",pid=814,fd=12))Illustrative output
Connection refused means nothing is listening. A timeout means the network
path is blocked - a policy, a mesh, a firewall - and the collector is innocent.
Those two answers point at different teams, which is why the probe runs from
the client side rather than from the collector.
Step 5 - Outbound: read queue depth against queue capacity
curl -s http://localhost:8888/metrics \
| grep -E '^otelcol_exporter_(send_failed|queue_size|queue_capacity)'
curl -s http://localhost:8888/metrics \
| grep -E '^otelcol_(processor|receiver)_refused'otelcol_exporter_send_failed_spans{exporter="otlp/tempo"} 91204
otelcol_exporter_queue_size{exporter="otlp/tempo"} 5000
otelcol_exporter_queue_capacity{exporter="otlp/tempo"} 5000
otelcol_processor_refused_spans{processor="memory_limiter"} 44120Illustrative output
Queue depth alone is meaningless; depth against capacity is the diagnosis. A queue pinned at capacity with failures climbing means the destination is not draining it, and the refusal counter beside it shows the backpressure has already reached the front of the pipeline.
That combination - queue full, sends failing, memory limiter refusing - is one failure with three symptoms, and it is the commonest way this incident is misdiagnosed. The visible symptom is at the receiver. The cause is at the exporter. Anything done to the receiver treats the symptom.
Step 6 - Which of the three exporter bands
The exporter log line separates them, and they have three different owners.
| Band | What the log shows | Owner | Fix shape |
|---|---|---|---|
| Queue overflow | Enqueue failures at steady state, destination responding | Capacity | More consumers, larger queue, less volume upstream |
| Retry exhaustion | Send failures during a destination outage, then drops | The destination | Restore the destination; consider a disk-backed queue |
| Permanent rejection | 4xx with a message: auth, tenant, path | The destination contract | A configuration fix, on one side or the other |
Permanent rejection is the one that looks like an overload. The destination is reachable and refusing every request, the exporter keeps retrying, the queue fills, the memory limiter starts shedding, and every metric on the collector says “under pressure”. Nothing about scaling the collector helps: it is a wrong credential or a wrong tenant header, and it is visible only in the exporter’s own log line.
# Substitute the destination from the exporter block before running:
DEST_HOST=tempo.distribution.svc.cluster.local
DEST_PORT=4317
nc -zv "$DEST_HOST" "$DEST_PORT"
getent hosts "$DEST_HOST"Run this from the collector host or pod. Reachability from anywhere else is a different question with a different answer, and a service mesh guarantees the two will disagree.
Step 7 - Change one thing, at the edge the counters named
otelcol validate --config=/etc/otelcol/config.yaml
kill -HUP "$(pidof otelcol)"
# The exit code of kill is not evidence. The log entry is:
journalctl -u otelcol -n 20 --no-pager | grep -i reloadNot every deployment honours SIGHUP - some container images ignore it - so
confirm the log recorded a reload rather than assuming the signal arrived. An
operator rewriting a correct configuration three times because the process
never re-read it is a real and common way to lose an hour.
Common patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
| Pod in CrashLoopBackOff after a config push | Start failure: unknown component, bad field, or unwired pipeline reference | Read the previous log; it names the component |
| Health check green, no telemetry at all | Receiver typo - never built, so nothing is unhealthy | Check the bound ports and /debug/pipelinez |
| Accepted climbing, sent flat, queue at capacity | The destination is not draining | Work the exporter; do not touch the receiver |
| Every metric says pressure, destination is healthy | Permanent rejection - auth, tenant or path | Read the exporter’s 4xx message |
Refused climbing after limit_mib was raised | Memory pressure has an outbound cause | Fix the exporter; put the limit back |
| Collector fine, backend has no data | Wrong tenant, endpoint or retention at the destination | Different runbook - this one is finished |
| One signal missing, others fine | That pipeline is misconfigured or absent | Compare the affected signal’s pipeline against the others |