Wenn der Workflow zu scheitern lernt — Error Handling und Observability in n8n

Wenn der Workflow zu scheitern lernt — Error Handling und Observability in n8n

Artikel 7 · Serie: Einstieg in n8n

Der Classifier aus Artikel 6 ordnet Tickets mit einem lokalen Sprachmodell ein, routet kritische Fälle an einen Alarm und läuft ohne Cloud-Abhängigkeit. Was fehlt, ist Robustheit im Betrieb. Was passiert, wenn der Modellserver nicht antwortet, ein Lauf mitten in der Verarbeitung abbricht oder eine neue Workflow-Version eine alte ablöst, während gerade Executions laufen. Dieser Artikel macht den Workflow produktionsreif: ein globaler Error-Workflow, ein Observability-Stack aus Prometheus, Loki und Grafana, und am Ende ein Fehlerfall, der besonders tückisch ist, weil ihn niemand bemerkt.

Der Code zu diesem Artikel liegt auf Codeberg, Tag v0.7: codeberg.org/rotecodefraktion/n8n-einstieg.

Drei Schichten, die n8n schon mitbringt

Fehlerbehandlung in n8n ist kein einzelnes Feature, sondern fügt sich aus mehreren Ebenen zusammen. Drei davon liefert n8n out of the box:

  1. Node-Retry. Jeder Node hat unter Settings ein Retry On Fail mit Max Tries und Wait Between Tries. Das greift innerhalb einer Execution mit festem Delay, bevor der Workflow überhaupt als gescheitert gilt. Sinnvoll für HTTP- und Chat-Model-Nodes, die gegen flüchtige Netzwerkfehler laufen.
  2. Error-Workflow. Ein eigener Workflow, der automatisch startet, wenn ein anderer fehlschlägt. Das ist die Schicht, die diesen Artikel trägt: zentrale Klassifikation, Logging und Alerting an einer Stelle, für beliebig viele produktive Workflows.
  3. REST-getriebener Re-Run. POST /executions/{id}/retry startet eine fehlgeschlagene Execution erneut, dieselbe API, die die UI hinter dem Retry-Button nutzt.

Eine vierte, produktive Schicht fehlt hier bewusst: persistente Queue mit Worker und automatischem Backoff. Die liefert der Queue-Mode mit Redis, und der bekommt einen eigenen Artikel (Artikel 11). Eine Postgres-Dead-Letter-Tabelle mit Schedule-Trigger und Eigenbau-Backoff wäre an dieser Stelle baubar, dupliziert aber Funktionalität, die der Queue-Mode mitbringt. Artikel 7 bleibt bei den drei Schichten, die n8n ohne Zusatzinfrastruktur bietet.

Der globale Error-Workflow

Ein Workflow, dessen erster Node ein Error Trigger ist, lässt sich in den Settings jedes anderen Workflows als dessen Error Workflow eintragen. Er feuert dann automatisch bei einem Fehlschlag. Ein Error-Workflow kann für viele Workflows gleichzeitig zuständig sein.

Der v0.7-error-handler hat drei Nodes in Reihe: Error Trigger, einen Code-Node zur Klassifikation, einen Code-Node für den strukturierten Log. Der Error Trigger bekommt bei einem Workflow-Fehler ein Objekt mit execution und workflow. Bei einem Aktivierungsfehler eines Triggers fehlt execution, stattdessen steht dort trigger.error. Der Klassifikations-Node fängt beide Fälle ab und leitet aus dem Fehlernamen eine Schwere ab:

// Mode: Run Once for Each Item
const input = $input.item.json;
const isActivationError = !input.execution;
const errorObj = isActivationError
  ? (input.trigger?.error ?? {})
  : (input.execution?.error ?? {});
const errorName = errorObj.name ?? 'UnknownError';

let severity = 'info';
if (errorName === 'NodeApiError') severity = 'critical';
else if (errorName === 'NodeOperationError') severity = 'warning';
else if (errorName === 'WorkflowOperationError') severity = 'critical';
else if (isActivationError) severity = 'critical';

return { json: {
  marker: 'n8n-error-workflow',
  severity, errorName,
  errorMessage: errorObj.message ?? '',
  workflowName: input.workflow?.name,
  executionId: input.execution?.id ?? null,
  timestamp: new Date().toISOString(),
} };

Der Mode Run Once for Each Item ist hier Pflicht. $input.item ist nur in diesem Modus definiert, in Run Once for All Items läuft der Code gegen ein nicht vorhandenes item.

Der zweite Code-Node schreibt das Ergebnis als eine JSON-Zeile auf die Standardausgabe:

const payload = $input.item.json;
console.log(JSON.stringify(payload));
return { json: payload };

Hier lauert die erste Falle. Seit n8n 1.15.x landet console.log aus einem Code-Node standardmäßig nicht mehr auf der Container-Standardausgabe. Die Umgebungsvariable CODE_ENABLE_STDOUT=true schaltet das wieder ein, aber nur für Production-Executions. Manuelle Läufe aus der UI gehen ausschließlich an die Browser-Konsole. Der Marker, den wir gleich in Loki suchen, braucht also einen echten Production-Trigger, kein Step-Execute im Editor.

Severity-Routing und ein Telegram-Alert

Bei kritischen Fehlern soll nicht nur geloggt, sondern alarmiert werden. Zwischen Klassifikation und Log kommt dafür ein Switch-Node, der auf severity routet: eine Regel {{ $json.severity }} is equal to critical führt in den Alert-Zweig, ein Fallback-Output fängt info und warning. Beide Zweige münden wieder im Log-Node.

Im critical-Zweig sitzt ein Telegram-Node. Telegram ist als Alert-Kanal pragmatisch: kostenlos, kein Vertrag, in einer Self-Hosting-Umgebung schnell aufgesetzt. Der Bot-Token kommt über @BotFather, die Chat-ID über einen einmaligen getUpdates-Aufruf. Wichtig: Der Token gehört in eine n8n-Credential, nie in den Node oder den exportierten Workflow. Der Nachrichtentext wird als Expression aus der Payload gebaut, ohne Parse Mode. Markdown oder HTML würde bei Sonderzeichen in einer Fehlermeldung ein 400 Bad Request provozieren; plain text ist robuster.

Drei Stolpersteine sind uns dabei begegnet, alle drei gehören in den Hinterkopf jedes n8n-2.0-Setups:

  • Der Error-Workflow muss published sein. Im Dropdown „Error Workflow" eines anderen Workflows erscheint ein nur gespeicherter Draft ausgegraut. n8n 2.0 trennt Save von Publish, und Production-Executions laufen immer gegen die published Version. Ohne Publish ist der Error-Workflow nicht auswählbar.
  • Node-Referenzen exakt schreiben. Ein Code-Node, der per $('Classification') auf einen anderen Node zugreift, braucht den Namen zeichengenau. Stimmt er nicht, meldet n8n Referenced node doesn't exist, und das besonders tückisch: Der Fehler bricht den Error-Handler ab, nachdem die Telegram-Nachricht schon raus ist. Der Alert kommt an, der Log fehlt, und der Workflow gilt als gescheitert.
  • Kein führendes = selbst tippen. n8n markiert ein Feld intern mit einem = als Expression. Wer es selbst eintippt, bekommt es als sichtbares Zeichen in die Nachricht.

Save und Publish

Das Save-vs-Publish-Modell aus n8n 2.0 ist mehr als ein Detail. Save speichert eine Draft-Version, Publish macht eine Version produktiv. Production-Executions laufen immer gegen die zuletzt published Version. Das adressiert ein reales Risiko: Wer live an einem aktiven Workflow editiert, riskiert inkonsistente Läufe.

Für den Betrieb folgen daraus zwei Konsequenzen, die leicht übersehen werden. Erstens muss ein Workflow published sein, um überhaupt in Production zu laufen, und um als Error-Workflow auswählbar zu sein. Zweitens, und das hat uns einen Debug-Umweg gekostet: Eine Änderung am Code eines Nodes wird nicht automatisch re-published. Reine Settings-Änderungen schon, eine Code-Änderung braucht ein explizites Publish (Shift + P). Bei einem Error-Workflow fällt das besonders spät auf, weil er nur im Fehlerfall läuft. Eine gespeicherte, aber nicht published Korrektur bedeutet: In Production läuft weiter die alte, kaputte Version.

Der Executions-Verlauf und seine Grenzen

n8n speichert jeden Lauf unter Executions in Postgres. Für einfaches Debugging reicht das: Status, Dauer, der fehlgeschlagene Node, seine Eingangsdaten. Failed Executions werden allerdings nur persistiert, wenn Save Failed Executions aktiv ist, sonst fehlen execution.id und execution.url im Error-Workflow.

Was der eingebaute Verlauf nicht leistet: langfristige Trendanalyse, Korrelation mit anderen System-Events, Alerting auf Anomalien. Genau dort beginnt der externe Stack.

Metriken mit Prometheus und Grafana

Der Observability-Stack kommt als separates Compose-Override (docker-compose.observability.yml), das den n8n-Service um N8N_METRICS=true plus Label-Toggles erweitert und vier Services hinzufügt: Prometheus, Loki, Grafana und einen Log-Collector.

Den Observability-Stack mit Docker Compose aufsetzen

Der Stack erweitert das Setup aus Artikel 2, statt es zu ersetzen: eine zweite Compose-Datei plus Konfigurationen unter docker/, alle im Repo unter Tag v0.7:

docker/docker-compose.observability.yml   # n8n + N8N_METRICS, vier neue Services
docker/prometheus/prometheus.yml          # Scrape-Config für /metrics
docker/loki/loki-config.yml               # Loki, 7 Tage Retention
docker/alloy/config.alloy                 # Alloy: Container-stdout → Loki
docker/grafana/provisioning/             # Datasources + Dashboard vorkonfiguriert
docker/grafana/dashboards/n8n.json        # das Dashboard

Das Override hängt an den n8n-Service N8N_METRICS=true (plus CODE_ENABLE_STDOUT=true) und fügt Prometheus, Loki, Grafana und Grafana Alloy als Log-Collector hinzu. Im Caddyfile kommt ein Block für grafana.localhost dazu. Beide Compose-Dateien werden zusammen hochgezogen:

cd docker
docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d

n8n und Postgres werden dabei neu erstellt, weil das Override deren Environment ändert. Die benannten Volumes überleben das, keine Datenverluste. Nur Grafana wird über Caddy nach außen gereicht, Prometheus, Loki und Alloy bleiben intern.

Prüfen, dass alles läuft:

curl -sk https://localhost/metrics | head -5            # n8n-Metriken
curl -sk https://grafana.localhost/api/health           # {"database":"ok",...}
docker exec docker-loki-1 wget -qO- \
  'http://localhost:3100/loki/api/v1/label/service/values'   # {"data":["n8n","postgres"]}

Grafana öffnet unter https://grafana.localhost, erster Login admin/admin mit Passwort-Wechsel. Das Dashboard liegt im Folder n8n. Runterfahren mit docker compose -f docker-compose.yml -f docker-compose.observability.yml down; mit down -v werden auch die Metrik- und Dashboard-Volumes gelöscht.

Nach dem Hochziehen exponiert n8n GET /metrics im Prometheus-Format:

curl -sk https://localhost/metrics | grep '^n8n_workflow_'

Die für den Betrieb relevanten Workflow-Counter sind out of the box dabei:

MetrikBedeutung
n8n_workflow_started_totalgestartete Läufe, je workflow_id
n8n_workflow_success_totalerfolgreiche Läufe
n8n_workflow_failed_totalfehlgeschlagene Läufe
n8n_workflow_execution_duration_secondsHistogramm der Laufzeit

Das Label workflow_id kommt von N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL=true. Das provisionierte Grafana-Dashboard „n8n Self-Hosted Overview" zeigt Version, aktive Workflows, Execution-Rate, Latenz-Perzentile und Laufzeit-Metriken. Failed und Success liegen dort bewusst in zwei getrennten Panels mit eigener Skala. Ein gemeinsames Panel marginalisiert die kleinere Reihe: Bei vielen erfolgreichen und wenigen gescheiterten Läufen verschwindet die Fehlerlinie an der Nullachse, genau das Signal, das man sehen will.

Logs mit Loki und Alloy

n8n hat keinen nativen Loki-Output. Das eingebaute Log-Streaming kennt nur webhook und sentry als Ziel. Die naheliegende Lösung, ein Docker-Log-Driver-Plugin, kann den Docker-Daemon bei Loki-Ausfall blockieren, davon rät selbst der Grafana-Maintainer ab. Der klassische Weg, Promtail, ist seit dem 2. März 2026 End-of-Life. Der Nachfolger ist Grafana Alloy mit loki.source.docker, der die Container-Standardausgabe über den Docker-Socket abgreift und an Loki schickt.

Damit wird die JSON-Zeile aus dem Error-Workflow suchbar:

{service="n8n"} |= "n8n-error-workflow"

Hier zahlt sich CODE_ENABLE_STDOUT=true aus: Ohne diese Variable schreibt der Code-Node nichts auf die Standardausgabe, und Alloy hätte nichts zu lesen.

Der stille Modell-Ausfall

Jetzt zum tückischsten Fehlerfall, und der gehört nicht in den globalen Error-Workflow. Der AI-Classifier aus Artikel 6 hat am Basic LLM Chain ein On Error auf Continue (using error output) stehen. Fällt das Modell aus, etwa weil das Gateway nicht antwortet, läuft der Workflow nicht in den Fehler, sondern in einen Fallback-Zweig, der das Ticket als sonstiges einsortiert und weiterreicht. Das ist gewollt: Der Ticket-Fluss bricht nicht ab, nur weil ein Modell mal weg ist.

Der Preis dieser graceful degradation ist ein blinder Fleck. Der Workflow scheitert nicht, also feuert der globale Error-Workflow nicht, also kommt kein Alert. Bei einem Gateway-Ausfall landen schlicht alle Tickets in sonstiges, und niemand erfährt davon. Wir haben das im Test reproduziert: Gateway gestoppt, Ticket geschickt, der Webhook antwortet mit HTTP 200 und category: sonstiges. Sauber durchgelaufen, inhaltlich falsch.

Die Lösung ist, die Degradation beobachtbar zu machen, ohne den Fluss zu brechen. In den Fallback-Zweig kommt ein Code-Node, der einen eigenen Marker auf die Standardausgabe schreibt:

// Mode: Run Once for Each Item
console.log(JSON.stringify({
  marker: 'n8n-ai-fallback',
  backend: 'mlx',
  ticketId: $('Normalize Input').item.json.id || null,
  workflowName: $workflow.name,
  reason: 'model unreachable or schema-invalid',
  timestamp: new Date().toISOString(),
}));
return $input.item;

Die Ticket-ID holen wir bewusst von Normalize Input und nicht vom aktuellen Item: Der Fallback-Set-Node verwirft die nicht gesetzten Felder, die ID wäre an dieser Stelle weg. Und nicht vom Webhook-Node, denn der läuft im Chat-Trigger-Pfad nicht, die Referenz würde dort brechen.

Über Alloy landet auch dieser Marker in Loki und bekommt zwei eigene Dashboard-Panels: eine Fallback-Rate als Balken (sum(count_over_time({service="n8n"} |= "n8n-ai-fallback" [5m]))) und ein Event-Log mit der Ticket-ID. Der stille Ausfall ist damit sichtbar, der Ticket-Fluss bleibt unangetastet. Echtes Failover, bei dem ein zweites Backend übernimmt, ist der nächste Schritt und ein Thema für sich (Artikel 9).

Healthcheck

Für externe Monitoring-Systeme ist GET /healthz immer aktiv und liefert {"status":"ok"}. Ein Uptime-Check zeigt damit auf einen Blick, ob die Instanz erreichbar ist. GET /healthz/readiness ist nur im Queue-Mode relevant.

Verifikation: der Fehler-Smoke-Test

Der Error-Workflow feuert nur bei echten Production-Fehlern, ein Manual-Run im Editor löst ihn nicht aus. Zum Testen dient ein kleiner Workflow mit Webhook-Trigger, der absichtlich scheitert, und mit v0.7-error-handler als Error Workflow. Zwei Varianten zeigen beide Schweregrade:

# info: Code-Node mit throw new Error('smoke test') → UnknownError → severity info
curl -sk https://localhost/webhook/error-smoketest

# critical: HTTP-Request auf einen toten Port → NodeApiError → severity critical → Telegram
curl -sk https://localhost/webhook/critical-smoketest

# Marker auf stdout und der inkrementierte Counter
docker logs --since 1m docker-n8n-1 | grep n8n-error-workflow
curl -sk https://localhost/metrics | grep 'n8n_workflow_failed_total'

Ein nacktes throw new Error() ergibt UnknownError und damit severity: info. Der critical-Pfad mit Telegram-Alert braucht einen NodeApiError oder NodeOperationError, etwa einen HTTP-Request auf eine nicht erreichbare Adresse. Der Error-Workflow selbst meldet dabei success, denn er hat den Fehler erfolgreich verarbeitet. Gezählt wird der auslösende Workflow.

Was als Nächstes kommt

Der Workflow scheitert jetzt kontrolliert, alarmiert bei kritischen Fehlern und ist über Metriken und Logs beobachtbar, inklusive des stillen Fallbacks. Was bisher zwei getrennte Pfade sind, der regelbasierte Eingang und die AI-Klassifikation, führt der nächste Artikel zu einer Pipeline zusammen (Artikel 8). Die vierte Retry-Schicht, persistente Queue mit automatischem Backoff, folgt mit dem Queue-Mode in Artikel 11.