Warnungen, die einmal kommen, nicht jede Stunde

Christian Drexler

Ich hatte eine stündliche Prüfung, die sich eine Handvoll laufender Dinge ansah und in einen Chatraum schrieb, wenn eines davon nicht gesund war. Sie arbeitete genau wie geschrieben. Ein Eintrag hatte ein echtes Problem, das tagelang niemand behob, also meldete die Prüfung es jede Stunde. Mehrere andere Einträge waren absichtlich abgeschaltet, was die Prüfung ebenfalls für ungesund hielt, ebenfalls jede Stunde.

Nach ein paar Tagen war der Raum voll mit derselben Handvoll Tatsachen, in Wiederholung. Die eine, die einen Menschen brauchte, lag unter denen begraben, die keinen brauchten, und unter sich selbst.

Die Prüfung hatte bei jeder Meldung recht. Nützlich war sie da nicht mehr, denn einen Kanal voller Wiederholungen schalten die Leute stumm.

Der Fehler in einem Satz

Die Prüfung meldete, wann immer eine Bedingung wahr war. Bedingungen, die sich langsam ändern, bleiben lange wahr. Die Zahl der Nachrichten maß also, wie lange ein Problem schon bestand, nicht wie viele Probleme es gab.

Was ich wollte, war eine Nachricht, wenn sich etwas ändert: ein neues Problem, ein Problem, das verschwunden ist, und gelegentlich eine Erinnerung an die, die bleiben.

Ein Fingerabdruck für jeden Befund

Um zu wissen, ob ein Befund neu ist, braucht er eine Identität, die von einem Lauf zum nächsten gleich bleibt. Der naheliegende Kandidat, der Nachrichtentext, taugt nicht, weil im Text Zahlen stehen, die sich bewegen.

checkout page: 3 failed probes, last success 4 h ago
checkout page: 4 failed probes, last success 5 h ago

Dasselbe Problem, eine andere Zeichenkette. Vor dem Hashen wird der Befund deshalb auf die Teile reduziert, die ihn ausmachen: worum es geht und welche Art Problem es ist. Zähler, Dauern und Beträge fliegen heraus.

import hashlib, re

def fingerprint(entity_id: str, kind: str, detail: str = "") -> str:
    # drop everything that drifts between runs: numbers, units, timestamps
    stable = re.sub(r"\d+([.,]\d+)?\s*(h|min|s|%|\$|€)?", "#", detail.lower())
    return hashlib.sha1(f"{entity_id}|{kind}|{stable}".encode()).hexdigest()[:12]

Die Kennung des betroffenen Dings bleibt unangetastet drin. Zwei verschiedene Seiten mit demselben Problem sind zwei Befunde. Eine Seite mit demselben Problem eine Stunde später ist einer.

Beim Herausstreichen lohnt es sich, großzügig zu sein. Ändert sich der Fingerabdruck, obwohl er es nicht sollte, ist man zurück bei einer Meldung pro Stunde. Fallen zwei leicht verschiedene Zustände desselben Problems zu einem zusammen, geht nichts verloren, worauf es ankommt.

Die Zustandsdatei und die drei Regeln

Zwischen den Läufen führt die Prüfung eine kleine JSON-Datei: zu jedem Fingerabdruck, wann er zum ersten Mal gesehen und wann er zuletzt gemeldet wurde.

import json, time, pathlib

STATE = pathlib.Path("~/.cache/healthcheck_state.json").expanduser()
REPEAT_HOURS = 24

def decide(findings: dict[str, str]) -> list[str]:
    """findings: fingerprint -> human text. Returns the lines to post."""
    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    now, out = time.time(), []

    for fp, text in findings.items():
        seen = state.get(fp)
        if seen is None:
            out.append(f"NEW  {text}")
            state[fp] = {"first": now, "posted": now}
        elif now - seen["posted"] >= REPEAT_HOURS * 3600:
            days = int((now - seen["first"]) // 86400)
            out.append(f"STILL OPEN {days}d  {text}")
            seen["posted"] = now

    for fp in [f for f in state if f not in findings]:
        out.append(f"RESOLVED  {state[fp].get('text', fp)}")
        del state[fp]

    for fp, text in findings.items():
        state[fp]["text"] = text
    STATE.write_text(json.dumps(state))
    return out

Das ist der ganze Mechanismus. Die Regeln, die er umsetzt:

  1. Ein Befund, der beim letzten Mal nicht da war, wird einmal gemeldet, markiert als NEW.
  2. Ein Befund, der noch da ist, wird höchstens alle 24 Stunden wiederholt, markiert als STILL OPEN mit seinem Alter. Das Alter ist der nützliche Teil: „still open 3d“ liest sich ganz anders als die erste Meldung.
  3. Ein Befund, der verschwunden ist, wird einmal als RESOLVED gemeldet und vergessen.

Ist die Liste der Zeilen leer, wird nichts geschickt. Die meisten Läufe schicken nichts, und genau das ist das Ziel.

Nicht über Abgeschaltetes berichten

Die zweite Hälfte des Lärms hatte eine andere Ursache. Die Prüfung sah sich alles an, was sie kannte, auch Einträge, die absichtlich pausiert waren. Etwas Pausiertes liefert nicht, fiel also durch den Gesundheitstest und wurde gemeldet.

Auf einen Befund über etwas Abgeschaltetes kann niemand reagieren. Der Filter gehört vor den Fingerabdruck: Geprüft wird überhaupt nur, was laufen soll. Das klingt zu selbstverständlich, um es aufzuschreiben. Es war für die Hälfte der Nachrichten verantwortlich.

Vorbefüllen, damit der erste Lauf still ist

Ein praktisches Detail. An dem Tag, an dem die Entdoppelung in Betrieb geht, ist die Zustandsdatei leer, also gilt alles, was gerade nicht stimmt, als neu und wird in einem Schwall gemeldet. Hat der Raum das alles schon hundertmal gesehen, ist dieser Schwall das Letzte, was jemand will.

Das Skript hat deshalb einen Schalter, der den Zustand aus den aktuellen Befunden füllt, ohne zu melden. Einmal mit dem Schalter laufen lassen, und ab dem nächsten Lauf kommen nur echte Änderungen durch.

Was sich geändert hat

Dieselbe Prüfung, mit denselben Schwellen, ging von einer Nachricht pro Befund pro Stunde auf ein paar Zeilen am Tag zurück, und an einem normalen Tag auf keine. Eine NEW-Zeile wird jetzt gelesen, weil sie die einzige Zeile ist.

Dieselben drei Regeln stecken seitdem in jedem wiederkehrenden Job, der mit einem Chatraum spricht. Sie machen ein Skript etwa dreißig Zeilen länger, und sie entscheiden darüber, ob eine Prüfung gelesen oder stummgeschaltet wird.