Fortgeschrittene Prometheus-Abfragen für sparse Daten

Eine einsame Silhouette auf einem Grat vor einem tiefblauen und orangen Abendhimmel
Foto von 想 李 / Unsplash

«Sparse Metriken mit PromQL abzufragen ist, wie darauf zu warten, dass der Teenager zurückschreibt - es passiert einmal am Tag, meistens um 2 Uhr nachts, und die Antwort ist nur ‹k›.»
opensight.ch - roman hüsler

Wer schon eine Weile mit Prometheus und PromQL arbeitet, kennt diesen Moment wahrscheinlich: Man starrt auf ein Grafana-Dashboard und fragt sich, warum eine völlig gesunde Metrik einfach verschwunden ist. Warum Messspitzen verschwinden, sobald man den Zeitbereich vergrössert. Oder warum rate() für eine Metrik, die eindeutig Daten hat, gelegentlich nichts zurückgibt. Nicht, weil die Daten fehlten — sondern weil das Staleness Handling und die Lookback-Mechanik von PromQL entschieden haben, dass sie nicht mehr relevant sind.

Genau darum geht es in diesem Beitrag. Darum, was unter der Haube passiert, wenn die Sample-Rate einer Metrik unter 5 Minuten fällt, und welche fortgeschrittenen Abfragetechniken man dann braucht.

Inhalt

TL;DR

PromQL hat ein 5-Minuten-Lookback-Fenster und Staleness Markers, die sparse Metriken stillschweigend verschlucken können. Ist das Scrape-Intervall lang (5 Minuten und mehr), sind die Metriken bursty oder arbeitet man mit Batch-Jobs, lassen einen Standard-Queries im Stich. Ob man Vanilla-Prometheus, Grafana Mimir oder Thanos betreibt: Die PromQL-Engine darunter ist dieselbe, und damit auch diese Eigenheiten. Dieser Beitrag richtet sich an SREs und erklärt die Mechanik dahinter sowie die fortgeschrittenen Techniken, um sie zu umgehen.

PromQL in 60 Sekunden

Bevor wir zu den Eigenheiten kommen, klären wir die Begriffe. PromQL ist die Abfragesprache, die Prometheus, Grafana Mimir, Thanos und andere kompatible Backends gemeinsam haben. Das hier beschriebene Staleness- und Lookback-Verhalten gilt für alle. PromQL kennt zwei grundlegende Query-Typen und zwei Datentypen, deren Zusammenspiel bei sparse Daten entscheidend ist.

Instant vs. Range Queries

Eine Instant Query wertet einen Ausdruck zu einem einzigen Zeitpunkt aus und liefert eine Momentaufnahme. Das bekommt man, wenn man eine Query in den Expression Browser von Prometheus eintippt. Eine Range Query wertet denselben Ausdruck wiederholt über ein Zeitfenster aus, in regelmässigen Abständen, den sogenannten Steps. Damit zeichnet Grafana seine Graphen: Es schickt eine Range Query mit den Parametern start, end und step.

# Instant query — evaluated once at "now":
http_requests_total{job="api"}

# Range query — Grafana asks Prometheus:
# "evaluate this expression every 15s from 10:00 to 11:00"
# → returns an array of results, one per step

Instant Vectors vs. Range Vectors

Ein Instant Vector ist eine Menge von Zeitreihen, in der jede Zeitreihe genau ein Sample hat — einen einzelnen Wert zu einem Zeitpunkt. Die meisten PromQL-Ausdrücke liefern Instant Vectors. Ein Range Vector ist eine Menge von Zeitreihen, in der jede Zeitreihe mehrere Samples über ein Zeitfenster enthält. Einen Range Vector erzeugt man, indem man eine Dauer in eckigen Klammern anhängt:

# Instant vector — one value per series:
http_requests_total{job="api"}

# Range vector — all samples from the last 5 minutes per series:
http_requests_total{job="api"}[5m]
Instant Vector vs. Range Vector: was PromQL zurückgibt
Bild - links: Ein Instant Vector liefert pro Zeitreihe einen Wert bei T=jetzt. Rechts: Ein Range Vector mit [5m] liefert pro Zeitreihe alle Samples im Fenster.

Range Vectors lassen sich nicht direkt als Graph darstellen — sie müssen mit Aggregationsfunktionen wie rate(), avg_over_time() oder max_over_time() auf Instant Vectors reduziert werden. Diese Funktionen nehmen die vielen Samples eines Range Vectors und fassen sie pro Zeitreihe zu einem einzigen Wert zusammen.

# rate() takes a range vector and returns an instant vector:
rate(http_requests_total{job="api"}[5m])

# max_over_time() returns the highest sample in the window:
max_over_time(cpu_usage{instance="web-1"}[1h])

# last_over_time() returns the most recent sample in the window:
last_over_time(batch_job_status{job="etl"}[30m])

Ausserdem begegnet man der Subquery-Syntax [range:resolution] — sie nimmt einen Instant-Vector-Ausdruck, wertet ihn wiederholt in einer bestimmten Auflösung über ein Zeitfenster aus und erzeugt so einen Range Vector. So verschachtelt man Aggregationen:

# Subquery syntax: <instant_expr>[range:resolution]
#
# [2m:15s] means:
#   "evaluate this expression every 15s over the last 2 minutes"
#   → produces a range vector with ~8 samples
#
# Example: average of 5-minute rates, computed every 15s over 2 minutes:
avg_over_time(rate(http_requests_total[5m])[2m:15s])

Die zentrale Erkenntnis für diesen ganzen Beitrag: Instant Vectors unterliegen dem 5-Minuten-Lookback und den Staleness-Regeln. Range Vectors nicht. Dieser Unterschied macht die *_over_time()-Funktionen zum wichtigsten Werkzeug für Abfragen auf sparse Daten.

Das 5-Minuten-Lookback-Delta

Was den meisten an PromQL nicht bewusst ist: Jede Instant Query hat ein verstecktes 5-Minuten-Fenster. Wertet Prometheus eine Query zum Zeitpunkt T aus, sucht es nicht nur nach einem Sample genau bei T — es schaut bis zu 5 Minuten zurück (das --query.lookback-delta) und nimmt das jüngste Sample.

Das funktioniert bestens, solange das Scrape-Intervall kurz ist, etwa 15s oder 30s. Dann liegt immer ein aktuelles Sample im Fenster. Sobald die Daten aber sparse werden — Batch-Jobs, die alle 10 Minuten oder einmal am Tag melden, ein Service, der Metriken nur bei Events emittiert, oder ein Target, das gelegentlich Scrapes verpasst —, wird dieses 5-Minuten-Fenster zur Klippe.

Das 5-Minuten-Lookback-Delta: dichte vs. sparse Scrapes
Bild - bei dichten Scrapes (grün) liegt immer ein Sample im Lookback-Fenster. Bei sparse Scrapes (violette Rauten) fällt das letzte Sample aus dem Fenster heraus — und PromQL liefert nichts.
# This looks fine with 15s scrape intervals
up{job="my-service"}

# But with a 10-minute scrape interval?
# At evaluation time T, if the last sample was 6 minutes ago — gone.
# PromQL returns nothing. No error. No warning. Just empty.

Und es wird noch schlimmer. Bei Range Queries wertet Prometheus den Ausdruck bei jedem step innerhalb des Bereichs aus. Ist der Step 1 Minute, die Daten treffen aber nur alle 10 Minuten ein, finden die meisten Auswertungspunkte nichts — das Ergebnis ist ein Graph mit riesigen Lücken oder gar keinen Daten.

Step-Intervalle: Warum Spikes beim Herauszoomen verschwinden

Das ist wohl das verwirrendste PromQL-Verhalten für alle, die neu mit Prometheus arbeiten: Man schaut auf ein Dashboard und sieht einen klaren Spike. Man vergrössert den Zeitbereich, um mehr Kontext zu bekommen — und der Spike ist einfach weg. Die Daten sind immer noch in Prometheus. Die Metrik hat sich nicht verändert. Aber die Query findet sie nicht mehr.

CPU-Auslastung: gleiche Metrik, anderer Zeitbereich
Bild - oben: Im 1-Stunden-Bereich ist ein klarer CPU-Spike auf 91 % zu sehen. Unten: dieselben Daten im 24-Stunden-Bereich — beide Spikes über dem Schwellwert von 80 % sind komplett unsichtbar.

Der Grund: Wenn Grafana (oder ein anderer PromQL-Client) eine Range Query ausführt, holt es nicht jedes einzelne gespeicherte Sample. Stattdessen wird der Query-Ausdruck in regelmässigen Abständen ausgewertet, den Steps. Die Step-Grösse ergibt sich aus Zeitbereich und Panel-Breite — Grafana berechnet sie typischerweise als time_range / panel_width_in_pixels und zielt auf ungefähr einen Datenpunkt pro Pixel.

  • 1-Tages-Bereich auf einem 1000px-Panel → Step ≈ 86s → ~1000 Auswertungspunkte
  • 7-Tages-Bereich auf einem 1000px-Panel → Step ≈ 10min → ~1000 Auswertungspunkte
  • 30-Tages-Bereich auf einem 1000px-Panel → Step ≈ 43min → ~1000 Auswertungspunkte

Bei jedem Step wendet Prometheus den 5-Minuten-Lookback an, um das jüngste Sample zu finden. Und hier ist die Rechnung, die sparse Daten zum Verhängnis wird:

Mit einem Step-Intervall von 1 Stunde und einem 5-Minuten-Lookback «sieht» jeder Step nur ein 5-Minuten-Fenster von je 60 Minuten. Das sind 8,3 % Abdeckung. Die restlichen 91,7 % der Zeit sind ein Blind Spot. Landet der Datenpunkt in diesem Blind Spot — und bei sparse Daten tut er das fast sicher —, ist er für die Query unsichtbar.

Formal lässt sich das als zeitliche Abdeckungsrate ausdrücken — der Anteil der Zeitachse, den die Query-Engine tatsächlich sieht:

Formel für die zeitliche Abdeckung: C = min(W/S, 1)
Ist C < 1, fallen sparse Samples mit der Wahrscheinlichkeit (1 - C) in einen Blind Spot. Um Sichtbarkeit zu garantieren, setzt man W ≥ S.
Warum Spikes verschwinden, wenn man den Zeitbereich vergrössert
Bild - oben: Im 1-Tages-Bereich mit feinen Steps liegt einer der vielen Auswertungspunkte innerhalb von 5 Minuten nach dem Spike. Unten: Im 7-Tages-Bereich mit gröberen Steps fällt der Spike zwischen zwei Steps und verschwindet.

Zoomen wir hinein und schauen, was rund um den Spike tatsächlich passiert:

Step-Intervall im Detail: Jeder 1-Stunden-Step schaut nur 5 Minuten zurück
Bild - jede blaue Linie ist ein Step-Auswertungspunkt (1h Abstand). Die orangen Bänder sind die 5-Minuten-Lookback-Fenster. Die roten Zonen dazwischen sind Blind Spots. Der Datenpunkt bei 86h 12m liegt mitten in einem 55-Minuten-Blind-Spot.

Das ist kein Bug — PromQL-Range-Queries sind so konstruiert. Das Step-Intervall ist eine Optimierung, damit nicht Millionen von Punkten ausgewertet werden müssen. Bei sparse Daten entsteht dadurch aber ein Sampling-Problem: Das Auswertungsraster der Query und das Timing der Daten sind völlig unabhängig voneinander.

Die Lösung folgt demselben Prinzip wie zuvor: *_over_time()-Funktionen mit einem Range-Fenster verwenden, das breiter ist als das Step-Intervall. Packt man die Query in max_over_time(my_metric[1h]), wertet jeder Step einen 1-Stunden-Range-Vector aus, statt nach einem einzelnen Zeitpunkt zu suchen — und Range Vectors sammeln alle Samples im Fenster ein, egal wann sie aufgetreten sind.

# Instant query — spike visible at 1-day range, gone at 7-day range:
my_sparse_metric{job="batch"}

# Range vector — spike visible at ANY time range:
max_over_time(my_sparse_metric{job="batch"}[1h])

# The range window must be equal to (or greater than) your step interval
# for complete temporal coverage — ensuring all samples are considered
# For 7-day dashboards: steps are ~10min, so [15m] is safe
# For 30-day dashboards: steps are ~43min, so [1h] is safe
# For 90-day dashboards: steps are ~2h, so [3h] is safe

Warum muss das Range-Fenster mindestens so breit sein wie der Step? Weil sich die Range-Fenster aufeinanderfolgender Steps berühren oder überlappen müssen, damit garantiert kein Sample durch die Maschen fällt. Ist die Range kleiner als der Step, entsteht zwischen den Lookback-Fenstern der einzelnen Steps ein Blind Spot, in dem Datenpunkte unsichtbar sind. Ist die Range gleich dem Step, wird jeder Moment auf der Zeitachse von genau einem Auswertungsfenster abgedeckt — vollständige zeitliche Abdeckung ohne Lücken.

Warum das Range-Fenster für eine lückenlose zeitliche Abdeckung >= dem Step-Intervall sein muss
Bild - drei Szenarien mit 1h-Steps. Oben: Eine 30m-Range lässt zwischen den Steps Blind Spots (Sample verpasst). Mitte: Eine 1h-Range ergibt eine perfekte Abdeckung. Unten: Eine 1,5h-Range erzeugt Überlappung — redundant, aber sicher.

Staleness Markers: der stille Killer

Mit Prometheus 2.0 kamen Staleness Markers: spezielle NaN-Werte, die eingefügt werden, wenn eine Zeitreihe aus einem Scrape-Target verschwindet. Die Absicht ist gut — wird eine Metrik nicht mehr exponiert, markiert Prometheus sie als stale, damit die Query-Engine nicht endlos den letzten bekannten Wert zurückgibt.

Bei sparse Daten wird es hier aber heikel:

Staleness Markers: wenn Metriken zwischen Scrapes verschwinden
Bild - grüne Kreise zeigen vorhandene Samples mit ihren Werten. Rote X markieren Staleness Markers, die eingefügt werden, wenn die Metrik fehlt. In den rot schattierten Bereichen liefern Queries nichts.
  • Ein Target wird gescrapt, die Metrik ist da → Sample wird gespeichert
  • Beim nächsten Scrape fehlt die Metrik (vielleicht eine Batch-Metrik oder ein bedingter Gauge) → Staleness Marker wird eingefügt
  • Die Query trifft auf den Staleness Marker → PromQL sagt «diese Zeitreihe ist weg» und liefert nichts
  • Zwei Scrapes später ist die Metrik zurück → es gibt wieder Daten, aber die Lücke ist real

Das Ergebnis: Dashboards mit sporadischen Löchern, flappende Alerts und SREs, die an ihrem Verstand zweifeln.

Range Vectors als Rettung

Hier die erste zentrale Erkenntnis: Range-Vector-Selektoren ignorieren Staleness Markers. Das ist zwar dokumentiert, wird aber selten betont. Verwendet man einen Range-Selektor wie [10m], sammelt Prometheus alle Roh-Samples in diesem Fenster ein — Stale Markers werden einfach übersprungen.

Instant Query vs. Range Vector: Abdeckung im Vergleich
Bild - dieselben sparse Daten, zwei Query-Strategien. Die Instant Query (links) hat Lücken, wo kein Sample im 5-Min-Lookback liegt. Der Range Vector mit last_over_time und einem 15m-Fenster (rechts) deckt fast alles ab.

Damit lassen sich die Lücken mit *_over_time()-Funktionen überbrücken:

# Instead of this (fails with sparse data):
my_batch_metric{job="etl"}

# Use this — grabs the last value within a 15m window:
last_over_time(my_batch_metric{job="etl"}[15m])

# Or if you want the max value seen in the last hour:
max_over_time(my_batch_metric{job="etl"}[1h])

last_over_time() ist der beste Freund für sparse Gauge-Metriken. Die Funktion liefert das jüngste Sample innerhalb der Range und ignoriert dabei die Staleness. Das Range-Fenster sollte man deutlich grösser wählen als das erwartete Meldeintervall.

rate() und increase(): die Counter-Falle

Counter und sparse Daten sind eine besonders üble Kombination. rate() braucht mindestens zwei Samples im Range-Fenster, um eine Rate pro Sekunde zu berechnen. Bei sparse Daten bekommt man oft nur eines — oder gar keines.

rate() braucht mindestens 2 Samples im Range-Fenster
Bild - bei einem Scrape-Intervall von 7 Minuten erwischt ein 5m-Fenster (rot) bei T=20 nur ein Sample, also schlägt rate() fehl. Ein 20m-Fenster (grün) erfasst mehrere Samples und funktioniert. Faustregel: Range >= 4x Scrape-Intervall.
# With a 15s scrape interval, this works fine:
rate(http_requests_total{job="api"}[5m])

# But with sparse data (scrape every 5m), you need a wider window:
rate(http_requests_total{job="api"}[15m])

# Rule of thumb: range window >= 4x your scrape interval
# Why 4x? You need at least 2 samples, plus margin for missed
# scrapes and staleness. This is the Prometheus docs recommendation.

Und dann ist da noch der Sonderfall increase(). Prometheus extrapoliert den Anstieg auf das ganze Range-Fenster, was selbst bei ganzzahligen Countern gebrochene Werte ergeben kann. Bei sparse Daten wird diese Extrapolation noch unzuverlässiger, weil die Abstände zwischen den Samples unregelmässig sind.

Braucht man exakte Zählwerte aus sparse Countern, sollte man Recording Rules in Betracht ziehen, die in einem bekannten Intervall voraggregieren, oder auf rate() umsteigen und selbst mit der Fenstergrösse multiplizieren.

Subqueries: wenn man eine Aggregation aggregieren muss

Manchmal muss man ein bereits aggregiertes Ergebnis noch einmal über die Zeit aggregieren. Dafür gibt es Subqueries. Mit einer Subquery wertet man eine Instant Query über eine Range in einer frei wählbaren Auflösung aus:

# Syntax: <instant_query>[range:resolution]

# Average rate over the last hour, evaluated every 5 minutes:
avg_over_time(rate(http_requests_total[5m])[1h:5m])

# Max CPU usage (already aggregated by instance) over 24h:
max_over_time(
  max by (instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
[24h:5m])

Für sparse Daten sind Subqueries mächtig, weil man die innere Auflösung an die Datenfrequenz anpassen und die äussere Range so wählen kann, dass sie die Lücken abdeckt. Man sollte sie aber mit Bedacht einsetzen — sie sind teuer. Jeder Subquery-Step erzeugt eine vollständige innere Auswertung. Eine [24h:1m]-Subquery bedeutet 1440 innere Auswertungen. Verwendet man dieselbe Subquery in mehreren Dashboards, lohnen sich Recording Rules.

absent() vs. absent_over_time(): Lücken erkennen

Wer auf fehlende sparse Daten alarmieren will, muss das richtige Werkzeug wählen:

absent() vs. absent_over_time() bei sparse Metriken
Bild - absent() feuert 25-mal in 55 Minuten, weil es den 5-Min-Lookback respektiert und zwischen allen sparse Samples Lücken sieht. absent_over_time() mit einem 30m-Fenster erzeugt null Fehlalarme.
# absent() — fires when the metric has NO current value
# Respects staleness, so it fires immediately after a stale marker
absent(my_batch_metric{job="etl"})

# absent_over_time() — fires when NO samples exist in the window
# Ignores staleness markers, looks at actual samples
absent_over_time(my_batch_metric{job="etl"}[30m])

Bei sparse Metriken feuert absent() zwischen den Datenpunkten ständig — nicht hilfreich. Was man will, ist absent_over_time() mit einem grosszügigen Fenster. Das Fenster sollte das 2- bis 3-Fache des erwarteten Meldeintervalls betragen, damit man nur dann alarmiert, wenn die Daten wirklich fehlen, und nicht schon, wenn sie bloss sparse sind.

Praxismuster für typische Szenarien

Batch-Jobs (Pushgateway oder eigener Exporter)

# Last known status of a batch job
last_over_time(batch_job_success{job="nightly-etl"}[25h])

# Alert if batch job hasn't reported in 25 hours
absent_over_time(batch_job_success{job="nightly-etl"}[25h])

Event-getriebene Metriken (nur bei Aktivität emittiert)

# Total errors in the last hour, even if metric is sparse
max_over_time(errors_total{service="payments"}[1h])
  -
min_over_time(errors_total{service="payments"}[1h])

# Or use increase with a wide enough window
increase(errors_total{service="payments"}[1h])

Lange Scrape-Intervalle (Kostenoptimierung)

# If scraping every 5m, use at least 20m windows for rate:
rate(requests_total[20m])

# For dashboards, match your $__rate_interval or set a minimum:
rate(requests_total[$__rate_interval])
# Grafana's $__rate_interval auto-adjusts based on scrape interval

Recording Rules zum Glätten

# rules.yml — pre-compute at a consistent interval
groups:
  - name: sparse_metrics
    interval: 5m
    rules:
      - record: job:batch_status:last
        expr: last_over_time(batch_job_success[25h])
      - record: job:request_rate:5m
        expr: rate(requests_total[20m])

Recording Rules werden in einem festen Intervall ausgewertet, unabhängig von der zugrunde liegenden Scrape-Frequenz. Das ergibt eine konsistente Zeitreihe, auf die sich nachgelagerte Queries verlassen können, ohne sich um Sparsity kümmern zu müssen.

Die wichtigsten Erkenntnisse

  • Das 5-Minuten-Lookback-Delta ist die Ursache der meisten Probleme mit «verschwindenden Metriken». Sind die Daten dünner als alle 5 Minuten, haben normale Instant Queries Lücken. Mit --query.lookback-delta lässt sich das global anpassen, aber das wirkt sich auf alles aus.
  • Step-Intervalle verschärfen das Problem. Breitere Zeitbereiche bedeuten breitere Steps, und damit deckt der 5-Minuten-Lookback jedes Auswertungspunkts einen kleineren Teil der Zeitachse ab. Ein Spike, der in der 1-Tages-Ansicht sichtbar ist, kann in der 7-Tages-Ansicht verschwinden — nicht weil die Daten weg sind, sondern weil zufällig kein Lookback-Fenster eines Steps auf ihm landet.
  • Staleness Markers killen sparse Gauge-Metriken. Ist eine Metrik nicht in jedem Scrape vorhanden, wird sie als stale markiert. Mit last_over_time() schaut man durch die Staleness hindurch.
  • Range Vectors ignorieren Staleness. Der Wechsel von Instant-Selektoren zu *_over_time()-Funktionen ist die mit Abstand wirksamste Technik für sparse Daten.
  • rate() braucht mindestens 2 Samples. Bei sparse Countern sollte das Range-Fenster mindestens das 4-Fache des Scrape-Intervalls betragen.
  • Subqueries sind mächtig, aber teuer. Man setzt sie ein, wenn man eine bereits aggregierte Metrik über die Zeit aggregieren muss. Für alles, was an mehreren Stellen verwendet wird, sind Recording Rules die bessere Wahl.
  • absent_over_time() statt absent() für Alerts auf sparse Metriken. Ersteres sucht nach tatsächlichen Samples im Fenster; Letzteres respektiert die Staleness und schlägt zwischen sparse Samples fälschlich an.
  • Recording Rules sind der Stabilisator. Sie erzeugen eine konsistente Ausgabezeitreihe, egal wie sparse die Eingabe ist. Alles, was man häufig abfragt, sollte man vorberechnen.

PromQL ist eine mächtige Sprache — ob man nun Prometheus direkt, Grafana Mimir oder Thanos abfragt —, aber die Staleness- und Lookback-Mechanik wurde für den Normalfall entworfen: dicht gescrapte Infrastrukturmetriken in Intervallen von 15 bis 30 Sekunden. Sobald man diese Komfortzone verlässt — Batch-Jobs, event-getriebene Systeme, kostenoptimierte lange Scrape-Intervalle —, muss man diese Interna verstehen, um Queries zu schreiben, die tatsächlich funktionieren.