← Alle artikelen

Observability zonder ruis: alerts ontwerpen waar je team echt op vertrouwt

De meeste observability-stacks falen niet omdat ze te weinig data verzamelen, maar omdat ze te vaak vals alarm slaan, of juist stil blijven als het wél misgaat. Een praktische aanpak voor alerts met hoog signaal in productie, inclusief de queries.

De meeste teams waar we mee werken hebben geen monitoring-probleem. Ze hebben een alert-moeheid-probleem. De dashboards zien er prachtig uit in demo’s. De pages niet.

Een nuttige alert vertelt een on-call engineer drie dingen in de eerste vijf seconden:

  1. Wat is er stuk? (api-gateway p95 latency, niet latency_alert_3)
  2. Voor wie? (klantgericht, intern of achtergrond?)
  3. Waarom nu? (geleidelijke verslechtering, plotselinge piek, gekoppeld aan een deploy?)

Als je alerts die vragen niet kunnen beantwoorden, stopt je team uiteindelijk met lezen. Vanaf dat moment heeft observability je betrouwbaarheid actief verslechterd.

Drie gewoontes die de kwaliteit van alerts stilletjes verzieken

1. Alerten op symptomen in plaats van SLO’s

Een drempel van 200ms p95 zegt niets zonder doel. Alerten op een burn rate van het foutbudget gekoppeld aan een echte SLO (“99,9% van de checkout-requests onder 400ms over 28 dagen”) geeft je een stabiel signaal dat geen constant bijstellen vereist.

Het mechanisme: je hebt een foutbudget van 0,1% over 28 dagen. Een burn rate van 1 verbruikt dat precies op schema; een burn rate van 14,4 verbruikt er 2% van in één uur. Je alert op de burn rate, niet op de ruwe latency.

Begin met een recording rule, zodat de verhouding één keer berekend wordt in plaats van in elke alert en elk dashboardpaneel:

groups:
  - name: slo-checkout
    interval: 30s
    rules:
      # Aandeel checkout-requests dat de doelstelling NIET haalt
      # (fout, of trager dan 400ms). Eén regel per window.
      - record: job:slo_checkout_bad_ratio:rate5m
        expr: |
          (
            sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m]))
            +
            sum(rate(http_request_duration_seconds_count{job="checkout"}[5m]))
            -
            sum(rate(http_request_duration_seconds_bucket{job="checkout",le="0.4"}[5m]))
          )
          /
          sum(rate(http_request_duration_seconds_count{job="checkout"}[5m]))

      - record: job:slo_checkout_bad_ratio:rate1h
        expr: # ...dezelfde expressie met [1h]

Alert daarna op twee windows tegelijk. Het lange window zegt: dit is echt. Het korte window zegt: dit gebeurt nog steeds. Dat tweede window voorkomt dat een alert nog een uur doorloopt nadat het incident allang opgelost is.

      - alert: CheckoutErrorBudgetBurnFast
        # 14,4x burn = 2% van een 28-daags budget in 1 uur. Page.
        expr: |
          job:slo_checkout_bad_ratio:rate1h  > (14.4 * 0.001)
          and
          job:slo_checkout_bad_ratio:rate5m  > (14.4 * 0.001)
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "Checkout verbruikt foutbudget 14,4x. 2% weg in het laatste uur"
          runbook_url: "https://runbooks.internal/checkout-latency"

      - alert: CheckoutErrorBudgetBurnSlow
        # 6x burn over 6u = een echt, maar trager lek. Ticket, geen page.
        expr: |
          job:slo_checkout_bad_ratio:rate6h  > (6 * 0.001)
          and
          job:slo_checkout_bad_ratio:rate30m > (6 * 0.001)
        for: 15m
        labels:
          severity: ticket

Twee regels vervangen een stuk of twaalf statische drempels, en geen van beide hoeft opnieuw afgesteld te worden als het verkeer verdubbelt.

2. Pages zonder runbook

Als de eerste reactie van de on-call engineer “wat is dit eigenlijk” is, doet de alert zijn werk niet. Elke page hoort te linken naar een runbook van één alinea: wat het betekent, waar je het eerst moet kijken, en hoe je het stilzet als het een bekend patroon is.

Let op de runbook_url-annotatie hierboven. Maak die verplicht. Dat kun je afdwingen met een controle op je eigen regelmetadata, en je CI laten falen als een regel met severity: page er geen heeft.

3. Alerten op hetzelfde incident vanuit drie plekken

Latency, foutpercentage en saturation pieken vaak tegelijk. Drie pages voor één onderliggende oorzaak is drie keer de cognitieve belasting. Groepeer ze, stuur ze via één notificatie, en laat de on-call de gecorreleerde signalen via het dashboard onderzoeken. Niet via de inbox.

In Alertmanager is dat vooral één blok:

route:
  group_by: ['alertname', 'cluster', 'service']
  group_wait: 30s        # verzamel verwante alerts vóór de eerste notificatie
  group_interval: 5m     # bundel updates aan een bestaande groep
  repeat_interval: 4h

Groeperen op service in plaats van alleen op alertname wordt vaak overgeslagen. Toch is dat het belangrijkste deel. Het bundelt verschillende alerts over dezelfde service tot één notificatie. Precies het geval van drie pages met één oorzaak, dus.

De belangrijkste alert is degene die je nooit geschreven hebt

Alles hierboven gaat ervan uit dat je monitoring werkt. Er is één storing die je meer kost dan alle luidruchtige alerts bij elkaar: de metric die helemaal geen data teruggeeft.

Op een productie-Kubernetescluster dat we overnamen stond de Thanos-compactor ongeveer drie maanden stil voordat iemand het merkte. Compactie lag stil, waardoor de langetermijnopslag stilletjes verslechterde en queries over historische data steeds trager en duurder werden.

Er was een voor de hand liggende metric voor:

thanos_compact_halted

Die gaf niets terug. Niet 0, maar helemaal niets. Prometheus had zelf maar twee scrape-jobs gedefinieerd; al het echte scrapen was uitbesteed aan Grafana Alloy, waarvan de discovery pods ophaalt met prometheus.io/scrape: "true". De Thanos-chart leverde die annotatie niet mee, dus geen enkel Thanos-onderdeel was ooit gescrapet. De metric die het probleem had moeten vangen had nooit bestaan.

De oplossing is een annotatie, met de poort vastgezet:

global:
  podAnnotations:
    prometheus.io/scrape: "true"
    prometheus.io/path: /metrics
    prometheus.io/port: "10902"

Die poort is belangrijk. Alloy genereert één target per containerpoort en filtert niet op naam, dus zonder dat je hem vastzet wordt de gRPC-poort (10901) ook gescrapet en staat die permanent down.

Maar de blijvende les zit in de alertregel die eruit voortkwam:

      - alert: ThanosCompactorHalted
        expr: max(thanos_compact_halted) > 0
        for: 15m
        labels:
          severity: critical
          component: thanos

…ingesteld met noDataState: Alerting. Die ene instelling is de hele kern. Wat dit drie maanden verborgen hield, was nu juist dat de metric ontbrak. “Geen data” moet dus net zo hard afgaan als “gestopt”. Een regel die alleen op > 0 reageert had nog eens drie maanden gezwegen.

Controleer dus rechtstreeks op blinde vlekken. Dit vindt elk target dat down is:

up == 0

En dit vindt het gevaarlijkere geval: een job waarvan je denkt dat je hem scrapet, maar die helemaal geen targets heeft:

absent(up{job="thanos"})

Draai absent() tegen elk onderdeel waarvoor je gepaged zou willen worden. Krijg je een resultaat terug, dan heb je een alert die nooit kan afgaan.

Kijk meteen ook naar wat je opslag kost

Datzelfde cluster produceerde Thanos-blocks van 2 uur met een omvang van 2,8–2,9 GB. Bij ~36.000 samples/sec en ~275k actieve series zei de rekensom dat ze 0,3–0,5 GB hadden moeten zijn: zes tot negen keer kleiner.

De blocks waren niet stuk. Ze bevatten meerdere kopieën van dezelfde data.

De oorzaak zit in Alloy. Die maakt één target per containerpoort. De scrape-job herschreef ze vervolgens allemaal naar hetzelfde adres, maar labelde ze per container. Daarmee werden identieke targets tóch van elkaar onderscheiden. Gevolg: hetzelfde endpoint werd één keer per container gescrapet en opgeslagen.

# Eén pod, drie containers, drie scrapes van hetzelfde :9090-endpoint
up{job="prometheus.scrape.annotated_pods", pod="prometheus-server-0"}

Clusterbreed: 66 targets voor 26 pods, waarvan 61% duplicaat. Door het container-label weg te laten vielen ze samen. Eén pod ging van 3 targets naar 1, en het aantal head series van 3 identieke kopieën naar 1.

Je kunt je eigen cluster hierop controleren met één query:

# Elke instance die vaker dan één keer gescrapet wordt, is een duplicaat
count by (instance) (up) > 1

Dit werkte overal in door: aantal series, samplevolume, blockgrootte in S3, belasting van de Store Gateway, en de scratch-disk van de compactor. Ruis is niet alleen wat je om 3 uur 's nachts uit bed belt. Het is ook wat je betaalt om te bewaren.

Waar we meestal beginnen

Als we een omgeving binnenkomen voor een observability-rebuild, gaat de eerste week zelden over het toevoegen van dashboards. De eerste week gaat over het verwijderen van alerts. Op het cluster hierboven hebben we 57% van de bestaande alertregels verwijderd in de eerste sprint. De regels die overbleven kregen gekoppelde runbooks, SLO-uitgelijnde drempels en duidelijk eigenaarschap.

Maar we beginnen met de omgekeerde vraag: wat zou er moeten piepen en doet dat niet? Draai absent() over elk kritiek onderdeel voordat je ook maar één regel verwijdert. Een cluster met 200 luidruchtige alerts en één stille blinde vlek gaat je uiteindelijk op die blinde vlek pakken.

Het resultaat is een rustigere inbox, snellere incidentrespons, en een team dat de signalen uit hun monitoring-stack ook echt vertrouwt.

Als dat het soort werk is waar je naar op zoek bent, neem contact op.