Self-Monitoring and Silent Drops
- Healthy is not delivering
- The metrics that matter
- Counting across layers
- Finding the processor that ate your data
- Five classes of silent loss
- Canaries and absence
- Our alert rules, reviewed
- Related lessons
Every component can be green while nothing reaches the backend.
Collector self-monitoring has to answer one question: did what came in go out? Health endpoints do not answer it.
Healthy is not delivering
| Signal | What it proves | What it does not |
|---|---|---|
/-/ready |
Process is up and serving | Anything about data |
/-/healthy |
Every component evaluated without error | That exporters reach their backends. A remote_write to a dead URL is Healthy (lesson 02) |
| UI graph all green | Config built | Delivery |
alloy_config_last_load_successful == 1 |
The last file parsed | That it was loaded — it stays 1 after a rejected reload (lesson 07) |
The metrics that matter
Alloy serves everything on :12345/metrics; the OTel Collector on :8888/metrics. Names below are as exposed by Alloy v1.16.1.
| Path | In | Out OK | Out failed | Buffered |
|---|---|---|---|---|
OTLP (otelcol.*) |
otelcol_receiver_accepted_{spans,metric_points,log_records}_total |
otelcol_exporter_sent_*_total |
otelcol_exporter_send_failed_*_total |
otelcol_exporter_queue_size / _capacity |
| OTLP refused at the door | otelcol_receiver_refused_*_total (e.g. memory_limiter) |
|||
| Processors | otelcol_processor_incoming_items_total |
otelcol_processor_outgoing_items_total |
||
| Prometheus remote write | prometheus_remote_storage_samples_in_total |
prometheus_remote_storage_samples_total |
prometheus_remote_storage_samples_failed_total |
prometheus_remote_storage_samples_pending, _retried_total |
| Loki write | loki_write_sent_entries_total |
loki_write_dropped_entries_total |
loki_write_batch_retries_total |
|
| Alloy runtime | alloy_component_controller_running_components{health_type}, go_gc_gomemlimit_bytes, process_resident_memory_bytes |
- Converters emit no
exporter_sent.otelcol.exporter.prometheusandotelcol.exporter.lokihand data to another pipeline; follow it intoprometheus_remote_storage_*orloki_write_*. *_failedand*_refusedseries appear only after the first event. Panels show “No data” rather than0.- Controller metrics are process-wide.
alloy_component_controller_running_componentstells you how many components are unhealthy, not which. For the name, use the UI orGET /api/v0/web/components.
Counting across layers
Every Alloy and every Collector exposes the same metric names. Our cluster has two layers exporting spans:
sum by (job) (rate(otelcol_exporter_sent_spans_total[10m]))
# job="alloy-collector" 0.41 (Beyla spans → gateway)
# job="alloy" 234 (gateway → Tempo)
- An unselected
sum()double-counts every record that passes through two layers. Always select byjob. - Measure throughput at the last hop — the one that writes to the backend.
- Measure loss per hop as a difference, sender versus receiver:
# What the node agents sent to the gateway, minus what they failed to send
sum(rate(otelcol_exporter_sent_spans_total{job="alloy-collector"}[10m]))
compared with what the gateway accepted from them. Our gateway also receives OTLP directly from OTel Demo, so here the difference is only meaningful with a canary (below) or per-sender labels.
- The
_totalsuffix differs by version. Alloy and current Collectors exposeotelcol_exporter_send_failed_spans_total; older Collectors exposedotelcol_exporter_send_failed_spans. A query with the wrong form returns an empty vector — which looks exactly like “zero errors”.
Finding the processor that ate your data
otelcol_processor_dropped_* is emitted only by memory_limiter. A filter or transform that throws data away increments nothing called “dropped”. Compare what went in and out of each processor instead:
sum by (job, processor) (
rate(otelcol_processor_incoming_items_total[5m])
- rate(otelcol_processor_outgoing_items_total[5m])
) > 0
Measured on Alloy v1.16.1: four log records sent, a filter rule matching INFO|DEBUG, one record left — incoming 4, outgoing 1, receiver response {"partialSuccess":{}}, HTTP 200, every component Healthy.
💪Exercise💪 (self-guided): Silent drop hunt — a filter eats half your logs while everything is green; find it from metrics alone.
Five classes of silent loss
| # | Class | Visible from the collector? | Where to look |
|---|---|---|---|
| 1 | Config not loaded — reload rejected, old config still running | Log line only | failed to reload config; /-/reload HTTP code |
| 2 | Over-broad drop rule — filter, drop, relabel keep |
Yes | processor incoming − outgoing; relabel metrics |
| 3 | Wrong routing target — tenant header, index, label points elsewhere | No — delivery succeeds | Query the backend where the data should be |
| 4 | Backend accepts the request, rejects the content — out-of-order samples, too-old timestamps, limits per tenant | Sometimes | Backend’s own discarded-samples metrics (cortex_discarded_samples_total in Mimir, loki_discarded_samples_total in Loki) |
| 5 | Timestamp out of range — parse failure, wrong timezone | No | Data lands, but at the wrong time; searches over “now” find nothing |
Classes 3–5 need end-to-end checks: the collector reports success because, from its side, the request succeeded.
Canaries and absence
A canary proves delivery end to end. It needs four things:
- A unique marker — a service name or label nothing else uses.
- A counter — a monotonically increasing value, so gaps are countable, not guessed.
- The same path as real data — same receiver, same processors, same exporter. A canary that skips the gateway proves nothing about the gateway.
- A checker outside the pipeline — an alert rule evaluated in the backend, not in the collector.
# Canary did not arrive for 10 minutes
absent_over_time(canary_heartbeat_total{job="collector-canary"}[10m])
For “a known producer went silent”, join against an inventory:
# Services that sent spans last week but not in the last 30 minutes
group by (service) (rate(traces_spanmetrics_calls_total[1h] offset 7d))
unless
group by (service) (rate(traces_spanmetrics_calls_total[30m]))
Our alert rules, reviewed
helm_values/alerts/otel-collector-alerts.yaml started as a copy of the community opentelemetry-collector-monitoring rules. Against Alloy v1.16, most of them could never fire:
| Original alert | Problem |
|---|---|
OtelCollectorDroppedSpans / Metrics / Logs |
otelcol_processor_dropped_spans (no _total), and only memory_limiter emits it — we run none. Cannot fire |
OtelCollectorExporterErrors |
otelcol_exporter_send_failed_spans without _total — cannot fire on Alloy |
OtelCollectorReceiverRefused |
Same suffix problem |
OtelCollectorHighMemory |
otelcol_process_resident_memory_bytes does not exist in Alloy; fixed 1.5 GB threshold ignores the 4 GiB limit |
AlloyConfigReloadFailed |
alloy_config_last_load_successful == 0 — stays 1 after a rejected reload. Cannot fire |
AlloyUnhealthy |
alloy_component_controller_running_components < 1 matches the healthy series dropping to 0, not unhealthy components appearing |
OtelCollectorExporterQueueFull, OtelCollectorDown |
Worked |
The file now uses the names the metrics actually have:
| Rule | Expression (core) |
|---|---|
OtelCollectorExporterSendFailed |
rate(otelcol_exporter_send_failed_{spans,metric_points,log_records}_total[5m]) > 0 |
OtelCollectorReceiverRefused |
rate(otelcol_receiver_refused_*_total[5m]) > 0 |
OtelCollectorProcessorDropRatioIncrease |
Drop ratio 1 − outgoing / incoming per processor, more than 20 points above its value offset 1d — legitimate filters and samplers stay quiet |
OtelCollectorExporterQueueFull / Critical |
otelcol_exporter_queue_size / otelcol_exporter_queue_capacity > 0.85 / 0.95 |
CollectorRemoteWriteBacklog / Failing |
prometheus_remote_storage_samples_pending > 10000; rate(prometheus_remote_storage_samples_failed_total[5m]) > 0 |
CollectorLokiWriteDropping |
rate(loki_write_dropped_entries_total[5m]) > 0 |
OtelCollectorHighMemory / HighCpu |
cAdvisor usage divided by kube_pod_container_resource_limits > 0.9 — works for Alloy and the Collector alike |
AlloyUnhealthyComponents |
alloy_component_controller_running_components{health_type!="healthy"} > 0 |
AlloyClusterMembershipMismatch |
Replicas report different cluster_node_peers{state="participant"} |
One failure mode has no metric at all: a reload rejected at graph level (bad reference) leaves every component healthy and the old config running. The only trace is the log line failed to reload config. It needs a log-based alert (Loki ruler), not a PrometheusRule.
The rules are applied with kubectl apply -k helm_values/alerts/ -n monitoring. No deploy script runs that step, so check kubectl -n monitoring get prometheusrule otel-collector-alerts after a fresh cluster.
The Grafana Alloy mixin (9 dashboards, ~14 alerts) is a better starting point than the community file, with gaps of its own: no resource or config-load alerts, and ClusterConfigurationDrift joins on a cluster_name label that cluster_node_info does not carry — on our gateway it is cluster_node_info{state="participant"}.