Pinned Post
Wenn die App ihre eigenen Fehler diagnostiziert: eine Self-Healing-Pipeline in Elixir
- Link abrufen
- X
- Andere Apps
Bei Fiatbitcoin laufen rund zwei Dutzend kleine Agenten im Hintergrund. Sie holen Kurse, lesen die Mempool, bewerten Nachrichten, rechnen Steuerfristen aus. Wo viel passiert, passieren auch Fehler. Bisher hat ein Sensor diese Fehler erkannt und mir eine Telegram-Nachricht geschickt. Praktisch, aber die eigentliche Arbeit fing damit erst an: Stacktrace lesen, Datei suchen, verstehen, was schiefging.
Die Frage war, ob das System einen Schritt weitergehen kann. Nicht nur melden, dass etwas kaputt ist, sondern den Fehler analysieren und mir eine fertige Diagnose hinlegen, an der ich direkt weiterarbeiten kann. Vorweg, damit keine falschen Erwartungen entstehen: Das System schreibt keinen Code und merged nichts von allein. Es erkennt, analysiert und legt ein GitLab-Issue an. Den Rest mache ich.
Der Ausgangspunkt: ein Sensor, der Fehler sieht
Die Basis gab es schon. Ein ErrorTrackerSensor pollt die Tabelle des error_tracker und schickt für jeden neuen Vorfall ein Signal auf einen PubSub-Topic. Das Signal trägt alles Wichtige bei sich:
data: %{
occurrence_id: occurrence.id,
error_id: occurrence.error_id,
error_kind: occurrence.error.kind,
error_reason: occurrence.error.reason,
error_fingerprint: occurrence.error.fingerprint,
context: occurrence.context,
captured_at: occurrence.inserted_at
}
Auf diesem Signal sitzt bereits ein Telegram-Consumer. Der neue Mechanismus hängt sich einfach daneben, ohne den bestehenden anzufassen. Ein zweiter Zuhörer auf demselben Topic, der die Pipeline startet.
Eine Pipeline mit Bremsen
Mein erster Gedanke war ein einzelner Agent, der alles erledigt. Das habe ich schnell verworfen. Die Schritte unterscheiden sich zu stark in ihrem Risiko, um sie in einen Topf zu werfen.
Triage liest nur. Die Analyse liest auch nur, lässt aber ein Sprachmodell mitreden. Und der Schritt, der irgendwann einmal echten Code erzeugen soll, ist der gefährlichste von allen. Diese Schritte verdienen unterschiedliche Behandlung.
Also habe ich sie hintereinandergeschaltet, klar voneinander getrennt. Jede Stufe darf abbrechen, und wenn sie das tut, startet die nächste gar nicht erst. In Elixir ist das ein with, das sich fast von selbst liest:
def heal(data, context \\ %{}) do
occurrence = Map.get(context, :occurrence) || load_occurrence(data.occurrence_id)
params = %{data: data, occurrence: occurrence}
with {:ok, %{decision: :proceed}} <- Triage.run(params, context),
{:ok, analysis} <- Analyze.run(params, context),
{:ok, result} <- ReportIssue.run(%{data: data, analysis: analysis}, context) do
{:ok, result}
else
{:ok, %{decision: :skip, reason: reason}} -> {:ok, {:skipped, reason}}
{:error, reason} -> {:error, reason}
end
end
Triage, Analyse, Bericht. Wer in der Mitte aussteigt, kommt sauber zurück, ohne dass weiter unten teure oder riskante Dinge passieren. Das ist die wichtigste Eigenschaft der ganzen Konstruktion.
Triage: die meisten Fehler sollen draußen bleiben
Die erste Stufe ist bewusst stur und ohne jede Magie. Ihre Aufgabe ist es, fast alles abzuweisen. Ein automatischer Diagnoselauf für jeden einzelnen Vorfall wäre schnell mehr Lärm als Nutzen.
def run(%{data: data} = params, _context) do
occurrence = Map.get(params, :occurrence)
cond do
not supported_kind?(data.error_kind) -> skip(:unsupported_kind)
not actionable_source?(occurrence) -> skip(:no_actionable_source)
already_reported?(data.error_fingerprint) -> skip(:already_reported)
true -> {:ok, %{decision: :proceed}}
end
end
Drei Filter, in dieser Reihenfolge. Erst die Fehlerklasse gegen eine kleine Whitelist analysierbarer Elixir-Fehler. Dann die Frage, ob der Stacktrace überhaupt auf eigenen Code zeigt und nicht in eine Dependency. Zuletzt eine Prüfung gegen GitLab, ob für denselben Fingerprint schon ein offenes Issue existiert. Ohne diesen letzten Filter würde ein Fehler, der im Sekundentakt feuert, eine Lawine identischer Issues auslösen.
Ein Skip ist hier kein Fehlerfall, sondern ein normales Ergebnis. Das Modul gibt {:ok, %{decision: :skip, reason: ...}} zurück, und die Pipeline endet ruhig.
Analyse: lesen, nicht raten
Kommt ein Fehler durch die Triage, sammelt die zweite Stufe Kontext. Sie zieht den Stacktrace, schaut nach, in welcher Datei und Funktion es knallte, und liest, wenn möglich, den betroffenen Codeabschnitt. Zusätzlich fragt sie das Gedächtnis des Systems, ob dieser Fingerprint früher schon einmal auftauchte. Erst dann geht das gesammelte Material an ein Sprachmodell, das eine Ursachenhypothese formulieren soll.
Wichtig war mir, dass diese Stufe auch ohne Sprachmodell funktioniert. Wenn kein API-Schlüssel gesetzt ist oder der Aufruf scheitert, soll die Pipeline nicht einfach stehen bleiben. Ein Stacktrace plus Fehlerort ist auch ohne Prosa wertvoll:
hypothesis =
case llm.generate_text(build_prompt(diagnostic), @client_name, max_tokens: 800) do
{:ok, text} ->
String.trim(text)
{:error, reason} ->
Logger.info("Self-healing analysis without LLM (#{inspect(reason)})")
nil
end
Fällt das Modell weg, ist die Hypothese eben nil, und das Issue trägt einen ehrlichen Hinweis darauf. Die Diagnose wird dadurch dünner, aber sie kommt trotzdem an.
Der Ausgang: ein Issue, kein Merge
Hier musste ich eine Entscheidung treffen, die zunächst gegen die ursprüngliche Idee lief. Ich wollte am Ende einen Merge Request, den ich nur noch durchwinke. Nur: Ein Merge Request braucht einen Branch mit einer Änderung. Solange das System keinen Code erzeugt, gibt es nichts zu mergen.
Also legt die letzte Stufe im aktuellen Stand ein Issue an, kein Merge Request. Das Issue enthält Fingerprint, Stacktrace, die Hypothese und den Hinweis, dass es manuell zu prüfen ist. Der Client dahinter ist ein dünner Wrapper über die GitLab-REST-API auf Basis von Req. Fehlt die Konfiguration, gibt er sauber auf, statt die Pipeline mitzureißen:
defp config do
cfg = Application.get_env(:integration_gateway, :gitlab, [])
token = cfg[:token]
project_id = cfg[:project_id]
if is_binary(token) and token != "" and not is_nil(project_id) do
{:ok, %{token: token, project_id: project_id, base_url: cfg[:base_url] || "https://gitlab.com"}}
else
{:error, :not_configured}
end
end
Sobald die eigentliche Reparaturstufe dazukommt, wird aus dem Issue ein Merge Request. Die Funktion dafür liegt im Client bereits bereit. Den ersten Merge Request für dieses Feature habe ich übrigens mit genau dieser Funktion erstellt, das war ein netter kleiner Test in eigener Sache.
Warum bewusst kein Auto-Merge
Es wäre verlockend, die Schleife zu schließen: erkennen, fixen, mergen, fertig. Ich halte das für einen Fehler, zumindest an dieser Stelle und in dieser Reife.
Code, den ein Sprachmodell erzeugt, ist eine Vermutung. Bevor so etwas in einen Branch wandert, gehört eine harte Prüfung davor, die deterministisch und ohne Modell entscheidet: kompiliert das, laufen die Tests grün. Erst was diese Prüfung besteht, darf überhaupt einen Merge Request bekommen. Und der Mensch am Ende der Kette bleibt. Nicht als Notlösung, sondern als Feature. Der geplante nächste Schritt lässt die Reparatur in einem isolierten Git-Worktree laufen, prüft sie gegen Compiler und Testlauf, und legt nur bei grünem Ergebnis einen Merge Request an. Auto-Merge steht bewusst nicht auf der Liste.
Lessons Learned
Drei Dinge aus diesem Bau, eines davon etwas, bei dem ich nachschärfen würde.
1. Abbruchpunkte schlagen Cleverness.
Die ganze Pipeline lebt davon, dass sie früh und oft abbricht. Kein cleverer Alleskönner, sondern vier Stationen, von denen jede Nein sagen darf. Das macht das Verhalten vorhersehbar und sorgt dafür, dass die teuren Schritte nur dann laufen, wenn die billigen sie durchgewunken haben.
2. Eine gute Diagnose ist schon der halbe Fix.
Ich war anfangs enttäuscht, dass der erste Ausbau nur ein Issue produziert und keinen Patch. In der Praxis ist die Diagnose genau das, was Zeit frisst. Wenn auf dem Issue Fingerprint, Stacktrace, betroffene Funktion und eine plausible Ursache stehen, ist der eigentliche Fix oft eine Sache von Minuten. Der Schritt vom Issue zum Merge Request ist kleiner, als er aussieht.
3. Was ich nachschärfen würde.
Die Deduplizierung läuft aktuell nur über offene Issues. Ein eigener zeitbasierter Cooldown pro Fingerprint fehlt noch, ebenso die echte Reparaturstufe mit ihrer Prüfung durch Compiler und Testlauf. Beides ist im Konzept beschrieben, aber noch nicht gebaut. Lieber ein ehrlicher kleiner Schritt, der sauber funktioniert, als ein großes Versprechen, das im Betrieb auseinanderfällt.
Wenn euch eine Stelle genauer interessiert, das Routing über das Fehlersignal, die Triage-Filter oder die geplante Prüfung im Worktree, schreibt es in die Kommentare. Ich gehe gerne tiefer.
- Link abrufen
- X
- Andere Apps

Kommentare