Du baust eine proaktive Monitoring-Pipeline: n8n scannt alle 15 Minuten Loki auf Error-Spikes, und wenn es welche gibt, feuert ein Webhook an deine Self-Heal-Bridge, die dann Claude um Diagnose bittet und dir per Telegram Bescheid gibt.

Realität in meinem Fall: Der Workflow feuerte jede Viertelstunde. Zuverlässig. Ohne echten Spike. Mein Telegram sah aus wie eine schlecht programmierte Werbetafel.

🔔 Zabbix [Average] srvdocker02
Loki Proactive: Error Spike
🔔 Zabbix [Average] srvdocker02
Loki Proactive: Error Spike
🔔 Zabbix [Average] srvdocker02
Loki Proactive: Error Spike
(...noch 40 mal)

Hier die Geschichte wie ich herausgefunden habe, dass das Problem in einer Stelle saß an der man garantiert nicht zuerst sucht — und was die Debug-Session außerdem noch über die nachgelagerte Self-Heal-Bridge verraten hat.

TL;DR

  1. Ein n8n IF-Node mit leftValue: "={{ $json.aboveThreshold }}" und rightValue: true (Boolean-Operator equals) liefert immer true, auch wenn $json.aboveThreshold === false. Grund: {{ }} rendert den Wert als String, und der nicht-leere String "false" wird in der Boolean-Coercion als truthy behandelt.
  2. Fix: Auf Number-Compare umstellen. Direkt errorCount > threshold vergleichen statt einen vorberechneten Boolean-Flag zu prüfen.
  3. Bonus-Erkenntnis aus dem gleichen Incident: Eine Self-Heal-Pipeline die bei Überlast silent skipped, anstatt es sichtbar zu melden, ist gefährlicher als gar keine Self-Heal-Pipeline.

Das Problem

Der Workflow Loki Proactive Error Analysis war so aufgebaut:

Every 15 Minutes
  → Define Jobs to Check  (syslog, docker, nginx, authlog)
  → Count Errors per Job  (Loki query_range)
  → Check Threshold       (errorCount > 200 ?)
  → Above Threshold?      (IF-Node, branches auf Boolean)
     ├─ true:  Get Sample Errors → Format Spike Alert → Send to egoclaude (Webhook)
     └─ false: No Alert Needed   (noOp)

Wenn ich den Threshold live gegen Loki laufen ließ, kam raus:

syslog: 93 errors
docker: 21 errors
nginx: 18 errors
authlog: 0 errors

Alles deutlich unter dem Threshold von 200. Und dann ein Blick in die letzten 10 Executions:

errorCount: 93, threshold: 200, aboveThreshold: false
errorCount: 91, threshold: 200, aboveThreshold: false
errorCount: 91, threshold: 200, aboveThreshold: false

aboveThreshold ist false. Der Workflow-Run ist abgeschlossen als success. Und trotzdem feuert jede Viertelstunde der Send to egoclaude-Node.

Die erste Frage in solchen Fällen: Welche Nodes hat n8n tatsächlich abgearbeitet? Das lässt sich aus der Postgres-Tabelle execution_data ziehen:

ssh root@srvdocker02
docker exec postgres-n8n psql -U n8n -d n8n -tAc \
  "SELECT data FROM execution_data WHERE \"executionId\"=166855;" > /tmp/exec.txt

for name in 'Send to egoclaude' 'Format Spike Alert' 'Get Sample Errors' 'No Alert Needed'; do
  echo "$name: $(grep -c "\"$name\"" /tmp/exec.txt) refs"
done

Ergebnis:

Send to egoclaude: 2 refs
Format Spike Alert: 2 refs
Get Sample Errors: 2 refs
No Alert Needed: 0 refs

Der Workflow ist sauber durch den True-Branch gelaufen, obwohl das Property offensichtlich false war. Der IF-Node lügt nicht direkt — er nimmt einfach den falschen Ausgang.

Ursache

Hier der IF-Node-Parameter im n8n-JSON:

{
  "conditions": {
    "conditions": [{
      "leftValue": "={{ $json.aboveThreshold }}",
      "rightValue": true,
      "operator": { "type": "boolean", "operation": "equals" }
    }]
  }
}

leftValue ist eine n8n-Expression (={{ ... }}) — die rendert ihren Wert als String. Egal ob in der Variable ein Boolean, eine Zahl oder ein Objekt steht: Am Ende steht da "false", "93" oder "[object Object]".

rightValue ist ein echter Boolean true.

Und der Boolean-Operator equals in n8n macht in Version 1 seiner Conditions-Implementation folgendes Coercion: String → Boolean via “ist der String nicht leer?”. Der String "false" ist nicht leer. Also ist er truthy. Also ist "false" == true. Also → True-Branch.

Wenn man es einmal weiß, ist es offensichtlich. Wenn man es nicht weiß, sucht man drei Stunden lang die Loki-Query, den Threshold, das Scheduling — alles was logisch als erstes kaputtgehen könnte — und findet nichts. Der Workflow selbst sieht völlig in Ordnung aus.

Der Fix

Weg von Boolean-Compare auf Expression-Output. Stattdessen die beiden Zahlen direkt vergleichen. Das umgeht das String-Rendering-Problem komplett, weil der neue Number-Operator explizit koerziert:

{
  "conditions": {
    "options": { "typeValidation": "loose" },
    "conditions": [{
      "leftValue": "={{ $json.errorCount }}",
      "rightValue": "={{ $json.threshold }}",
      "operator": { "type": "number", "operation": "gt" }
    }],
    "combinator": "and"
  }
}

Und wichtig: typeVersion: 2 für den IF-Node setzen — die neuere Version hat die Number-Coercion und die typeValidation: "loose" Option.

Live-Update via n8n REST API (nicht per SQL editieren — n8n überschreibt SQL-Changes beim nächsten Shutdown):

import json, urllib.request, ssl

API_URL = "https://flow.example.de/api/v1"
API_KEY = "n8n_api_xxx"
WORKFLOW_ID = "LrWgSLDguHCaDQEu"

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

def api(method, path, body=None):
    req = urllib.request.Request(
        f"{API_URL}{path}",
        data=json.dumps(body).encode() if body else None,
        method=method,
        headers={"X-N8N-API-KEY": API_KEY, "Content-Type": "application/json"},
    )
    return json.loads(urllib.request.urlopen(req, context=ctx).read())

wf = api("GET", f"/workflows/{WORKFLOW_ID}")

for node in wf["nodes"]:
    if node["name"] == "Above Threshold?":
        node["parameters"] = {
            "conditions": {
                "options": {"typeValidation": "loose", "caseSensitive": False, "leftValue": ""},
                "conditions": [{
                    "leftValue": "={{ $json.errorCount }}",
                    "rightValue": "={{ $json.threshold }}",
                    "operator": {"type": "number", "operation": "gt"},
                }],
                "combinator": "and",
            }
        }
        node["typeVersion"] = 2

payload = {
    "name": wf["name"],
    "nodes": wf["nodes"],
    "connections": wf["connections"],
    "settings": wf.get("settings", {}),
    "staticData": wf.get("staticData"),
}
api("PUT", f"/workflows/{WORKFLOW_ID}", payload)

Verifikation

Nach dem Fix auf den nächsten geplanten Run warten (bei mir: 15 Minuten), dann in den Execution-Daten prüfen welcher Branch genommen wurde:

docker exec postgres-n8n psql -U n8n -d n8n -tAc \
  "SELECT data FROM execution_data
   WHERE \"executionId\"=(SELECT id FROM execution_entity
                        WHERE \"workflowId\"='LrWgSLDguHCaDQEu'
                        ORDER BY \"startedAt\" DESC LIMIT 1);" > /tmp/exec.txt

for name in 'Send to egoclaude' 'No Alert Needed'; do
  echo "$name: $(grep -c "\"$name\"" /tmp/exec.txt) refs"
done

Vorher: Send to egoclaude: 2 refs, No Alert Needed: 0 refs Nachher: Send to egoclaude: 0 refs, No Alert Needed: 2 refs

Und auf der Bridge-Seite darf kein neuer Webhook-Call mehr reinkommen:

ssh root@bridge-host
journalctl -u claude-telegram-bridge --since='30 minutes ago' \
  | grep 'Webhook alert.*Loki Proactive'
# Leer. Endlich.

Bonus-Erkenntnis: Die Pipeline hat still versagt

Wenn ich hier aufhören würde, wäre ich an einem reparierten Workflow-Trigger zufrieden. Aber der Incident hatte eine zweite Dimension die erst sichtbar wurde, als ich dem Alert-Spam auf die Spur kam.

Parallel zum Loki-Spam (Severity: Average) fing ein echter Alert an zu feuern: ZFS ARC dnode size > 90% auf meinem Proxmox-Host. Severity: High. Wichtig.

Die Bridge-Logs zeigten Folgendes:

Apr 13 06:45  WARNING MAX_AUTONOMOUS_SESSIONS reached, skipping srvdocker02
Apr 13 07:00  WARNING MAX_AUTONOMOUS_SESSIONS reached, skipping srvdocker02
Apr 13 07:15  WARNING MAX_AUTONOMOUS_SESSIONS reached, skipping srvdocker02
Apr 13 07:30  WARNING MAX_AUTONOMOUS_SESSIONS reached, skipping srvdocker02
Apr 13 07:40  WARNING MAX_AUTONOMOUS_SESSIONS reached, skipping srvhost04  ← ZFS ARC!

Der Loki-Spam hatte meinen globalen MAX_AUTONOMOUS_SESSIONS=3-Counter voll gemacht. Als der echte ZFS-Alert reinkam, landete er in einem silent Fallback: eine normale Telegram-Nachricht ohne Hinweis, dass die Auto-Diagnose gerade übersprungen wurde. Aus User-Sicht völlig ununterscheidbar von jedem anderen Alert.

Drei Lehren aus dem Flow-Design:

1. Limits pro Quelle, nicht global. Ich hatte einen gemeinsamen Counter für Cron-Jobs (Daily Briefings) und Zabbix-Alerts. Das ist sauber modelliert solange die Mengen vergleichbar sind — aber bei einem spammenden Alert-Trigger frisst der eine Counter eine andere Sorte Aufgabe. Fix: Getrennte Slot-Pools pro Quelle (MAX_ZABBIX_SESSIONS, MAX_CRON_SESSIONS).

2. Kritische Severities brauchen reservierte Slots. Selbst mit einem eigenen Zabbix-Pool kann spammendes Average-Rauschen Platz wegnehmen den eine High-Meldung bräuchte. Lösung: Die letzten N Slots darf nur High/Disaster belegen. Das ist eine Zeile Code — cap if severity in HIGH else cap - RESERVED — und fängt genau den Fall ab wo Monitoring am wichtigsten wird: wenn etwas brennt und gleichzeitig die Pipeline überlastet ist.

3. “Silent Fallback” ist schlimmer als “laut skipped”. Die Bridge hat den ZFS-Alert nicht verloren — sie hat ihn als 🔔 Zabbix [High] srvhost04 — ZFS ARC... an Telegram weitergegeben. Aus meiner Sicht: ein Alert wie jeder andere. Ich hätte ihn hingeguckt, niemand hat “bitte reagieren” gesagt, ich bin weitergescrollt. Nach der Nacharbeit gibt es jetzt explizite Prefixes:

PrefixBedeutung
🚨Auto-Diagnose läuft gerade
🔔Standard-Forward (alles nach Plan)
🟡Severity ohne Auto-Diagnose (Warning/Info)
🟠Cooldown aktiv — diagnostiziert kürzlich
⚠️Automatik überlastet — manuell anschauen

Ein Alert mit ⚠️ ist jetzt visuell unterscheidbar von einem Alert der normal durchläuft. Das ist der Unterschied zwischen “die Pipeline weiß nicht was sie tun soll” und “der User weiß nicht dass die Pipeline das nicht wusste”.

Fazit

Zwei Lehren aus einem Incident:

  • Vertraue keinem {{ }} in einer Boolean-Bedingung. Wenn du einen Boolean-Flag vergleichen willst, vergleiche direkt die Zahlen oder Strings die den Flag berechnet haben. Das ist redundanter, aber robust gegen n8n-Expression-Coercion-Macken.
  • Eine Self-Heal-Pipeline darf nicht silent skippen. Jeder Zustand in dem sie nicht das tut was sie sollte — überlastet, in Cooldown, Severity nicht unterstützt — muss für den Menschen am anderen Ende des Alerts sichtbar sein. Sonst ist “dein System hat was gemerkt und nichts getan” und “dein System hat gar nichts gemerkt” aus User-Sicht dasselbe.

Der passendste Moment diese Lücken zu finden ist übrigens nicht während die Pipeline seit Wochen rund läuft, sondern wenn sie gerade ein bisschen kaputt ist. Ein Bug in einer Komponente deckt Design-Schwächen in den nachgelagerten Komponenten auf. Deshalb: Wenn du gerade eine Pipeline debuggst, schau dir auch immer an was passiert wenn der Input kaputt ist. Der Happy-Path ist leicht, der Failure-Path verrät dir wie gut das System wirklich ist.

Referenzen