Monitoring & Observability Debugging Scenarios
Prometheus: Target Shows as DOWN
Symptom: Prometheus UI /targets shows a target as DOWN. No metrics from that service.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
classDef warn fill:#e74c3c,stroke:#c0392b,color:#fff
START["Target shows DOWN<br/>in /targets"]
REACH["Can Prometheus reach<br/>target IP:port?"]:::check
DNS["DNS resolves?<br/>nslookup from prom pod"]:::check
PORT["Port open?<br/>nc -zv target 9090"]:::check
AUTH["Auth required?<br/>bearer_token / tls_config"]:::check
LABEL["relabel_configs<br/>dropping the target?"]:::check
METRIC["Check scrape error<br/>in /targets hover"]:::check
START --> METRIC --> REACH
REACH -->|"no"| DNS
REACH -->|"yes"| AUTH
DNS -->|"fails"| PORT
AUTH --> LABEL
# Check error message on target (hover over error in /targets)
# Common errors:
# "connection refused" → app not running on that port
# "context deadline exceeded" → scrape_timeout too short or app slow
# "tls: certificate" → TLS config mismatch
# "401 Unauthorized" → missing bearer_token or basic_auth
# Test scrape manually from Prometheus pod
kubectl exec -n monitoring deploy/prometheus -- \
wget -qO- http://my-service:8080/metrics | head -20
# Check if ServiceMonitor selector matches service labels
kubectl get servicemonitor -n monitoring my-app -o yaml | grep selector
kubectl get svc -n my-app --show-labels
# Check Prometheus config for the job
kubectl exec -n monitoring deploy/prometheus -- \
wget -qO- http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | select(.health=="down")'
Prevention: Add a BlackboxProber synthetic probe to catch targets going down before Prometheus scrape fails. Use up == 0 as a P2 alert with for: 5m. In K8s: ensure ServiceMonitor namespaceSelector and selector are correct in staging before prod deploy — up going to 0 after a deploy means something regressed in the /metrics endpoint.
Prometheus: Metrics Missing / Gaps in Graphs
Symptom: Graph shows gaps or no data even though the target is UP.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["Gaps in metrics graph"]
RANGE["Query range too short?<br/>Use [5m] not [1m]"]:::check
STALENESS["Staleness: last sample<br/>> 5min ago?"]:::check
RESTART["Prometheus restarted?<br/>WAL replays, gaps normal"]:::check
SCRAPE["scrape_interval vs<br/>query range mismatch"]:::check
COUNTER["Counter reset?<br/>rate() handles, increase() may gap"]:::check
START --> RANGE
START --> STALENESS
START --> RESTART
START --> SCRAPE
START --> COUNTER
-- Check if target is being scraped
up{job="my-app"}
-- Check last scrape time
scrape_duration_seconds{job="my-app"}
-- Check for counter resets
resets(http_requests_total{job="my-app"}[1h])
-- Verify metric exists at all
{__name__=~"http_requests.*", job="my-app"}
Common cause: rate() with a range window smaller than 2 × scrape_interval. If scrape is every 30s, use [2m] minimum, not [1m].
# Fix: align query range with scrape interval
# scrape_interval: 30s → use [2m] or [5m] in rate()
# scrape_interval: 60s → use [5m] in rate()
Prevention: Standardize on a single scrape_interval cluster-wide (30s recommended). Document the rule: always use rate()[Xm] where X ≥ 2 × scrape_interval. Add recording rules for the most-used rate() expressions — recording rules pre-compute at consistent intervals, eliminating scrape-window mismatch gaps.
Prometheus: High Memory / OOM Killed
Symptom: Prometheus pod OOMKilled. Memory keeps growing.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
classDef warn fill:#e74c3c,stroke:#c0392b,color:#fff
START["Prometheus OOMKilled"]
SERIES["Check active series:<br/>prometheus_tsdb_head_series"]:::check
HIGH["Series > 1M?<br/>High cardinality"]:::warn
RETENTION["Retention too long?<br/>--storage.tsdb.retention.time"]:::check
RULES["Too many recording rules<br/>evaluated?"]:::check
LIMITS["Set --query.max-samples<br/>and memory limits"]:::fix
START --> SERIES --> HIGH
HIGH -->|"yes"| CARDINALITY["Drop high-cardinality<br/>labels via relabeling"]:::fix
HIGH -->|"no"| RETENTION --> RULES --> LIMITS
-- Active time series (>1M = investigate)
prometheus_tsdb_head_series
-- Top metrics by series count
topk(10, count by (__name__)({__name__=~".+"}))
-- Memory used by head block
prometheus_tsdb_head_chunks_storage_size_bytes
-- Total chunks in memory
prometheus_tsdb_head_chunks
# Check /tsdb-status in Prometheus UI for top cardinality contributors
# Reduce memory:
# 1. Drop high-cardinality labels with metric_relabel_configs
# 2. Reduce retention: --storage.tsdb.retention.time=15d
# 3. Use recording rules to pre-aggregate before storing
# 4. Increase memory limit and resource requests
Prevention: Monitor prometheus_tsdb_head_series — alert when it exceeds 2M series (typical OOM threshold is 3-4M). Add metric_relabel_configs to drop unused high-cardinality metrics at ingest time. Use Thanos or Mimir for long-term storage so Prometheus retention can stay at 2-7 days, reducing TSDB head size.
Prometheus: PromQL Query Slow / Times Out
Symptom: Grafana dashboard shows "query timed out" or takes 30s+ to load.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["Query slow or timeout"]
RANGE["Time range too large?<br/>Reduce to 6h or 24h"]:::check
REGEX["Regex label matchers?<br/>{__name__=~\".+\"} is brutal"]:::check
NOINDEX["No index matcher?<br/>Always filter by job/instance"]:::check
RECORD["Create recording rule<br/>for expensive query"]:::fix
STEP["Reduce resolution:<br/>increase step interval"]:::fix
START --> RANGE --> NOINDEX
START --> REGEX --> RECORD
START --> STEP
-- BAD: scans all series
rate({__name__=~"http.*"}[5m])
-- GOOD: specific job narrows down
rate(http_requests_total{job="api"}[5m])
-- BAD: huge time range with fine step
-- In Grafana: 90 days range + 15s step = millions of data points
-- Convert expensive query to recording rule:
-- groups:
-- - name: api_rules
-- rules:
-- - record: job:http_requests:rate5m
-- expr: rate(http_requests_total[5m])
# Check query stats in Prometheus UI /query-analysis
# Or enable query logging:
# --query.log-file=/var/log/prometheus/queries.log
Prevention: Create recording rules for any query used in a dashboard panel or alert rule — dashboards hitting raw high-cardinality rate() over long ranges are the #1 cause of slow queries. Use Thanos Query Frontend with query splitting and result caching to handle range queries > 1 day without hammering Prometheus directly.
AlertManager: Alerts Firing But No Notifications
Symptom: Alert shows FIRING in Prometheus but no Slack/PagerDuty notification received.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
classDef warn fill:#e74c3c,stroke:#c0392b,color:#fff
START["Alert FIRING<br/>no notification"]
REACH["Prometheus reaching<br/>AlertManager?"]:::check
ROUTE["Alert matches route?<br/>check matchers"]:::check
SILENCE["Active silence<br/>matching alert?"]:::check
INHIBIT["Inhibition rule<br/>suppressing?"]:::check
RECEIVER["Receiver config valid?<br/>amtool check-config"]:::check
WEBHOOK["Webhook/API reachable<br/>from AlertManager?"]:::check
START --> REACH --> ROUTE --> SILENCE
SILENCE --> INHIBIT --> RECEIVER --> WEBHOOK
# 1. Check AlertManager received the alert
curl http://alertmanager:9093/api/v2/alerts | jq '.[] | {labels, status}'
# 2. Check active silences
curl http://alertmanager:9093/api/v2/silences | jq '.[] | select(.status.state=="active")'
# 3. Validate config
amtool check-config /etc/alertmanager/alertmanager.yml
# 4. Test routing (which receiver would this alert go to?)
amtool config routes test \
--config.file=/etc/alertmanager/alertmanager.yml \
alertname=HighErrorRate severity=critical
# 5. Test notification manually
amtool alert add alertname=TestAlert severity=warning --annotation summary="test"
# 6. Check AlertManager logs
kubectl logs -n monitoring deploy/alertmanager | grep -i "error\|warn\|notify"
Prevention: Test the full alerting pipeline end-to-end in staging on every change: fire a synthetic alert via amtool, verify it routes to the right receiver. Store AlertManager config in Git with CI validation (amtool config check). Use amtool config routes test to validate routing logic before deploying.
AlertManager: Alert Storm / Too Many Notifications
Symptom: Hundreds of notifications firing at once. On-call engineer gets paged for every pod.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["Alert storm"]
GROUP["grouping configured?<br/>group_by: [alertname, cluster]"]:::check
WAIT["group_wait too short?<br/>default 30s, increase to 5m"]:::check
INHIBIT["Root cause alert?<br/>inhibit derived alerts"]:::fix
SILENCE["Silence during deploy<br/>or maintenance window"]:::fix
START --> GROUP --> WAIT
START --> INHIBIT
START --> SILENCE
# Fix: proper grouping
route:
group_by: ['alertname', 'cluster', 'namespace']
group_wait: 5m # wait 5m to collect more alerts before sending
group_interval: 5m # wait 5m between updates to same group
repeat_interval: 4h # resend if still firing after 4h
# Fix: inhibit pod alerts when node is down
inhibit_rules:
- source_matchers: [alertname="NodeDown"]
target_matchers: [alertname=~"Pod.*"]
equal: [node]
Prevention: Design group_by with ['alertname', 'cluster', 'service'] from day one — never group by individual pod/instance. Add inhibition rules for every parent-child alert relationship (node down → pod alerts, deployment degraded → individual pod alerts). Set group_wait: 30s and group_interval: 5m to batch rapid-fire alerts into one notification.
Grafana: Dashboard Shows "No Data"
Symptom: Grafana panel shows "No data" even though data exists in Prometheus.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["No data in panel"]
DS["Data source configured?<br/>Test in Connections"]:::check
QUERY["Run query in Explore<br/>does it return data?"]:::check
TIME["Time range correct?<br/>Last 1h vs last 24h"]:::check
VAR["Variables resolved?<br/>$namespace $pod correct?"]:::check
STEP["Min interval vs<br/>actual scrape interval"]:::check
START --> DS --> QUERY --> TIME --> VAR --> STEP
# Debug in Grafana Explore tab — run raw PromQL query
# Check if variable values are resolving correctly
# Panel → Edit → Query Inspector → show raw request/response
# Common causes:
# 1. Data source URL wrong (http vs https, wrong port)
# 2. Time range doesn't overlap with data
# 3. Variable $job has no value selected (shows "All" but query needs specific value)
# 4. Metric name changed in app and dashboard not updated
# 5. min_interval in panel > scrape_interval → coarsens data away
Prevention: Use Grafana dashboard-as-code (grafonnet or Terraform grafana provider) — metric name changes in the app trigger a CI check that validates dashboard queries still return data. Pin dashboard variables to specific values in CI snapshot tests. Add min_interval equal to your scrape interval in all panels.
Grafana: Dashboard Loads Slowly
Symptom: Dashboard takes 30s+ to load. Browser tab spinning.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["Dashboard loads slowly"]
PANELS["Too many panels?<br/>> 20 = split dashboard"]:::check
QUERIES["Expensive PromQL?<br/>Check query inspector"]:::check
RANGE["Time range too large?<br/>Reduce default range"]:::check
REFRESH["Auto-refresh too fast?<br/>5s for 30+ panels = overload"]:::check
RECORD["Pre-compute with<br/>recording rules"]:::fix
START --> PANELS --> QUERIES --> RANGE --> REFRESH --> RECORD
# Panel → Edit → Query Inspector → Stats tab shows query time
# Grafana → Settings → Performance: enable query caching
# Fix: recording rule for expensive dashboard query
# Instead of: sum(rate(http_requests_total[5m])) by (service)
# Create: record: service:http_requests:rate5m
# expr: sum(rate(http_requests_total[5m])) by (service)
# Dashboard uses: service:http_requests:rate5m (instant, no computation)
Prevention: Every dashboard panel that will be on a TV screen or loaded frequently must use a recording rule — not a raw rate() on high-cardinality metrics. Set Grafana max_data_points per panel to match expected resolution. Use Thanos Query Frontend with query result caching to serve repeated identical queries from cache.
Loki: Logs Not Appearing
Symptom: LogQL query returns no results. Expected logs are missing.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["No logs in Loki"]
PROMTAIL["Promtail running?<br/>kubectl get pods -n monitoring"]:::check
TARGETS["Promtail scraping<br/>correct pods?"]:::check
LABELS["Labels match query?<br/>{namespace=\"prod\"}"]:::check
PARSE["Log format parsed?<br/>json/logfmt pipeline stage"]:::check
INGEST["Loki ingester healthy?<br/>check distributor metrics"]:::check
START --> PROMTAIL --> TARGETS --> LABELS --> PARSE --> INGEST
# Check Promtail targets
kubectl port-forward -n monitoring ds/promtail 9080:9080
curl http://localhost:9080/targets # shows which pods are being tailed
# Check Promtail logs for errors
kubectl logs -n monitoring ds/promtail | grep -i "error\|warn\|drop"
# Verify labels match your query exactly
# Loki is strict: {namespace="prod"} ≠ {namespace="production"}
# Check if logs are being dropped (rate limit)
kubectl logs -n monitoring deploy/loki | grep "rate limit\|drop"
# Test push directly to Loki
curl -H "Content-Type: application/json" -XPOST \
http://loki:3100/loki/api/v1/push \
--data-raw '{"streams":[{"stream":{"job":"test"},"values":[["'$(date +%s%N)'","test log line"]]}]}'
# Then query:
curl "http://loki:3100/loki/api/v1/query_range" \
--data-urlencode 'query={job="test"}' \
--data-urlencode 'start='$(date -d '5 minutes ago' +%s%N) \
--data-urlencode 'end='$(date +%s%N)
Prevention: Add a log pipeline smoke test in CI: deploy a test pod that writes a known log line, query Loki after 60s and assert the line appears. Monitor loki_ingester_streams_created_total and Promtail promtail_sent_entries_total — if sent > ingested, pipeline has a drop. Alert on Promtail pod restarts.
Loki: High Cardinality Labels Causing OOM
Symptom: Loki OOMKilled or very slow queries. Too many unique label combinations.
Bad labels (high cardinality):
{pod="api-7f8b9c-xk2pq"} ← pod name changes on every deploy
{request_id="abc-123"} ← unique per request
{user_id="usr_456"} ← millions of users
Good labels (low cardinality):
{namespace="prod"}
{app="api"}
{env="production"}
{level="error"}
# Fix: strip high-cardinality labels in Promtail pipeline
- pipeline_stages:
- labeldrop:
- pod # don't use pod name as label
- container_id # unique per container instance
- labels:
app: # keep these (low cardinality)
namespace:
level:
Prevention: Enforce a Loki label allowlist in Promtail pipeline config from day one — never expose container_id, pod_ip, or request-ID-style values as labels. Monitor loki_ingester_streams_created_total — alert when unique stream count exceeds 10,000. Review new label additions in code review.
OpenTelemetry: Traces Not Appearing in Jaeger
Symptom: App is instrumented but no traces show up in Jaeger/Tempo.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["No traces in Jaeger"]
SDK["OTEL SDK initialized?<br/>tracer provider set?"]:::check
EXPORT["Exporter configured?<br/>OTEL_EXPORTER_OTLP_ENDPOINT"]:::check
COLLECT["Collector reachable?<br/>from app pod"]:::check
SAMPLE["Sampling rate?<br/>AlwaysSample vs ratio"]:::check
PROPAGATE["Context propagated?<br/>W3C traceparent header"]:::check
START --> SDK --> EXPORT --> COLLECT --> SAMPLE --> PROPAGATE
# Check OTEL env vars in pod
kubectl exec deploy/my-app -- env | grep OTEL
# Required env vars:
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
# OTEL_SERVICE_NAME=my-service
# OTEL_TRACES_SAMPLER=parentbased_always_on (or traceidratio)
# Test collector reachability from app pod
kubectl exec deploy/my-app -- nc -zv otel-collector 4317
# Check collector logs for received/dropped spans
kubectl logs deploy/otel-collector | grep -i "traces\|span\|drop\|error"
# Check collector pipeline config
# receivers: [otlp] exporters: [jaeger] pipelines: traces: receivers:[otlp] exporters:[jaeger]
# Enable debug exporter in collector to see spans
# exporters:
# debug:
# verbosity: detailed
Prevention: Add a trace smoke test in CI: send a synthetic span to the collector, query Jaeger API and assert it appears within 30s. Monitor otelcol_exporter_sent_spans vs otelcol_exporter_send_failed_spans — alert if failed > 0 for > 5 min. Use tail sampling to ensure 100% of error traces are captured regardless of overall sampling rate.
Monitoring Stack: Prometheus Can't Scrape K8s Pods
Symptom: Pods exist and are running, but no metrics in Prometheus for them.
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
START["K8s pod metrics missing"]
SM["ServiceMonitor exists?<br/>kubectl get servicemonitor"]:::check
NS["ServiceMonitor in same<br/>namespace as Prometheus?"]:::check
SEL["Prometheus serviceMonitorSelector<br/>matches ServiceMonitor labels?"]:::check
SVC["Service labels match<br/>ServiceMonitor selector?"]:::check
PORT["Port name matches?<br/>name: metrics in service spec"]:::check
RBAC["Prometheus RBAC allows<br/>get/list/watch pods/endpoints?"]:::check
START --> SM --> NS --> SEL --> SVC --> PORT --> RBAC
# Check ServiceMonitor exists
kubectl get servicemonitor -A
# Check Prometheus operator has picked it up
kubectl logs -n monitoring deploy/prometheus-operator | grep "ServiceMonitor\|sync"
# Verify selector chain:
# Prometheus.spec.serviceMonitorSelector → ServiceMonitor.metadata.labels
# ServiceMonitor.spec.selector → Service.metadata.labels
# Service.spec.ports[].name → ServiceMonitor.spec.endpoints[].port
# Check Prometheus RBAC
kubectl auth can-i get pods --as=system:serviceaccount:monitoring:prometheus
# View Prometheus config (after operator processes it)
kubectl exec -n monitoring prometheus-0 -- \
cat /etc/prometheus/config_out/prometheus.env.yaml | grep -A5 "job_name"
Prevention: Use the Prometheus Operator and define ServiceMonitors alongside each service deployment — scrape config travels with the code, not managed separately. Add a CI check that validates ServiceMonitor selector labels match the Service labels in the same PR. Alert on absent(up{job="my-service"}) — fires if the job disappears entirely from Prometheus's target list.
Quick Reference: Monitoring Debugging Commands
| Problem | First command | What to look for |
|---|---|---|
| Prometheus target DOWN | Check error in /targets UI |
connection refused, 401, timeout |
| Missing metrics | up{job="..."} in PromQL |
Returns 0 = scrape failing |
| High memory | prometheus_tsdb_head_series |
> 1M series = cardinality issue |
| Slow queries | Panel → Query Inspector → Stats | Query time > 5s |
| Alert not notifying | amtool config routes test |
Which receiver matches |
| Alert storm | Check group_by config |
Missing grouping keys |
| No Grafana data | Grafana Explore → run query directly | Data source connectivity |
| No Loki logs | Promtail /targets endpoint |
Pod not being tailed |
| Loki OOM | Check label count | High-cardinality labels |
| No OTEL traces | Check OTEL_EXPORTER_OTLP_ENDPOINT |
Collector unreachable |