Skip to content

Logging Privacy

Diese Leitlinie beschreibt die minimalen Guardrails fuer Backend-Logs und Client-Logs in TargetShot.

Ziel

  • Logs sollen betriebliche Fehlerdiagnose ermoeglichen.
  • Logs sollen keine unnötigen personenbezogenen Daten oder Secrets transportieren.
  • Client-Logs sind ein Admin-Werkzeug und kein frei lesbarer Debug-Stream.

Verbotene Inhalte

Folgende Inhalte duerfen nicht unkontrolliert in Logs oder Client-Log-Payloads landen:

  • Access-Tokens, Refresh-Tokens, JWTs, Passwoerter, Secrets und Authorization-Header
  • rohe Request-Bodies aus Mitglieder-, Auth- oder Admin-Pfaden
  • vollstaendige Mitgliederobjekte oder Beispiel-Responses
  • direkte E-Mail-Adressen, sofern eine maskierte Form fuer den Betriebsfall ausreicht
  • hochaufloesende Browser-Fingerprints oder client-seitig gesetzte Rollenfelder

Client-Logs

  • GET /api/client-logs ist nur fuer Admins vorgesehen.
  • Die Schwaerzung von message und stack (redactSecrets in backend/src/utils/redactSecrets.ts, bytegleiche Kopie im Web-Client unter frontend/apps/web/src/utils/redactSecrets.ts, ein Backend-Test vergleicht beide) ist eine Mustererkennung, keine Zusage, dass nie ein Geheimnis durchkommt. Der Web-Client schwaerzt vor dem Kuerzen auf 500 Zeichen, der Server beim Empfang erneut. Erfasst werden:
  • Schluessel, deren Name eines der Stichwoerter token, secret, password, passwd, pwd, credential, apikey/api_key, privatekey, accesskey oder authorization enthaelt (camelCase, snake_case, kebab-case, mit oder ohne Trenner), in Freitext, Query und JSON — auch als String in JSON verschachtelt, bei abgeschnittenem Wert und ueber Zeilenumbrueche hinweg. Ausgenommen sind Namen mit den Woertern count, length, policy, type, expires, expiry, expiration und has…/is… davor (tokenCount, passwordPolicy, tokenType, hasToken).
  • Cookie, Set-Cookie (der ganze Wert) und Sitzungswerte (session, sessionid, sid, connect.sid).
  • OAuth code, code_verifier, state: als Query-/Formular-Parameter (?code=…, &state=…) immer; als JSON-Schluessel code/state nur, wenn der Wert mindestens 20 Zeichen hat und nur aus URL-sicheren Zeichen (A-Za-z0-9._~-) besteht, oder wenn im selben Text code_verifier, redirect_uri oder grant_type vorkommt. Fehlercodes und Zustaende wie {"code":"ECONNRESET"}, {"state":"loading"} bleiben damit lesbar; ein code_verifier als Schluessel wird immer geschwaerzt. Das Wort „code" im Fliesstext bleibt.
  • Authorization mit beliebigem Schema (Bearer, Basic, ApiKey, Digest samt Parametern): der ganze Wert.
  • Bearer-Werte und JWTs (genau wie im Basisstand: drei nicht leere base64url-Teile, beginnend mit eyJ; JWE mit fuenf Teilen und JWTs mit leerer Signatur sind nicht erfasst), E-Mail-Adressen. Grenzen: Ein Geheimnis ohne erkennbaren Schluessel und ohne JWT-Form (ein nackter opaker Wert im Freitext) wird nicht erkannt; Unicode-Escapes im Schluesselnamen (\u0074oken) und prozentkodierte Schluessel ebenso wenig. Steht der Wert erst in der Folgezeile des Schluessels, wird nur ein Wert in Anfuehrungszeichen geschwaerzt (sonst verschluckt token: am Zeilenende das erste Wort des naechsten Stack-Frames). Ein Objekt oder Array als Wert endet am ersten Komma; E-Mail-Lokalteile ueber 64 Zeichen werden nur mit den letzten 64 geschwaerzt. Ein Wert ohne Anfuehrungszeichen endet an Leerraum, Komma oder Semikolon; ein Wert mit Anfuehrungszeichen am passenden Ende (auch bei maskierten Anfuehrungszeichen und Backslash am Wertende), ein fehlendes Ende schwaerzt die Zeile. Das Backend schwaerzt die Eingabe ohne Kappung; sie ist durch das Body-Limit der Route POST /api/client-logs begrenzt (ausdruecklich 256 KiB, vorher das Fastify-Standardlimit von 1 MiB; ein Batch hat hoechstens 20 Eintraege zu je ~3 KB). Ein groesserer Body wird mit 413 abgelehnt. Der Web-Client kappt die Eingabe vor der Schwaerzung auf 20000 Zeichen und kuerzt danach auf 500; ein angeschnittenes JWT oder Wort am Ende faellt dabei weg, der Server schwaerzt erneut. Das Rate-Limit der Route bleibt (10 Anfragen je Minute und IP).
  • Client-Logs speichern nur reduzierte Betriebsmetadaten:
  • level / type
  • redacted message
  • route
  • status
  • method
  • latencyMs
  • online
  • grob zusammengefasster userAgent
  • Rohe url und client-seitige Rollenwerte werden nicht persistiert.
  • Low-signal console.log- und console.info-Rauschen wird nicht an den Server weitergereicht.

Stack: eine benannte Ausnahme fuer die Mobile-App (seit 01.09.2026)

  • Aus dem Web wird stack weiterhin nicht persistiert.
  • Aus der App (source: 'mobile') wird stack persistiert — redigiert wie die message und hart begrenzt auf 40 Zeilen bzw. 2000 Zeichen.
  • Betriebsnutzen: In einem Store-Build gibt es weder Konsole noch Sourcemaps im Zugriff. Ohne Stack ist eine Meldung wie „undefined is not an object" nicht verortbar, und der Fehler bleibt unauffindbar. Im Browser ist der Stack verzichtbar, weil der Tester die Konsole hat.
  • Datenschutzbedarf: Ein Hermes-Stack traegt Bundle-Positionen (index.bundle:1:2), keine Nutzerdaten und keine URLs mit Parametern — anders als ein Browser-Stack. Er laeuft zusaetzlich durch dieselbe Redaktion (Bearer-Tokens, JWTs, Secrets, E-Mail-Adressen).
  • Der Stack ist ueber die Admin-Lesepfade sichtbar; die Aufbewahrung von 30 Tagen und die Admin-Beschraenkung gelten unveraendert.

Weitere Felder aus der App

  • source, platform und appVersion sind additiv: Web-Clients senden sie nicht, das alte Format bleibt gueltig.
  • platform und appVersion sind Kennzeichen, keine Freitexte — sie werden auf A-Z a-z 0-9 . _ ( ) - reduziert und ersetzen bei App-Meldungen den nutzlosen Request-User-Agent (okhttp/…, CFNetwork/…) durch TargetShot 1.0.1 (42) / ios 18.0.
  • Als route meldet die App das Routen-Muster (/duelle/[id]), nicht den aufgeloesten Pfad — eine fremde Kennung im Pfad wuerde sonst mitgehen.

Retention und Rate

  • Lokale Browser-Logs werden derzeit fuer maximal 7 Tage im Local Storage gehalten.
  • Die App haelt ihren Fehlerpuffer nur im Speicher (max. 50 Eintraege, endet mit dem Prozess) und sendet je Sitzung hoechstens 60 Eintraege in Buendeln zu 20; liegengebliebene Buendel (Drossel, Fehlschlag, Rest eines Bursts) schiebt ein Weckruf-Timer nach, ohne auf einen neuen Fehler zu warten.
  • Das Rate-Limit der Route (10 Requests/Minute) gilt je IP — hinter einer Vereins-IP teilen sich alle Geraete plus Web EIN Budget. Die App wertet 429 deshalb nicht als Fehlschlag Richtung Aufgeben, sondern wartet 75–105 s (mit Streuung) und versucht es erneut.
  • Persistierte Backend-Client-Logs werden auf eine operative Retention von 30 Tagen begrenzt.
  • Ingest-Payloads werden pro Request hart begrenzt und auf allowlist-basierte Typen reduziert.

Members- und Admin-nahe Server-Logs

  • Member-Fehlerpfade loggen keine kompletten Sample-Responses.
  • Reconcile-/Sync-Fehler loggen keine rohen Bodies.
  • Direkte Kontaktinformationen werden fuer Logs wenn moeglich maskiert.
  • Club-Kontext soll in Logs bevorzugt als Count oder Scope-Meta statt als volle Liste auftauchen.

Fehlertext in Antworten und gespeicherten Feldern (seit 02.10.2026)

  • Ausdrueckliche HTTP-500-Antworten der Mitglieder-Routen (neun code(500).send in backend/src/routes/members.ts) tragen nur einen festen Fehlercode und eine Fehlerkennung (errorId, Form E- plus 10 Hexzeichen, backend/src/utils/serverFehler.ts), nie den Fehlertext (detail). Dieselbe Kennung steht in der Logzeile des Fehlers; der Vorstand nennt sie dem Support, der Support findet damit die Zeile. Ein Waechtertest in backend/test/members.test.ts verbietet detail in jeder dieser 500-Antworten. Grenze: ein Wurf ausserhalb dieser try-Bloecke geht an den globalen Fehlerhandler (backend/src/index.ts), der den Fehlertext weiterreicht; das ist eine eigene, breitere Aufgabe (fester Code plus Kennung bei jedem 5xx), nicht Teil dieser Umstellung.
  • Anzeige der Kennung: Die Web-App haengt sie an die Fehlermeldung der Mitglieder-Dialoge (pages/membersApiError.ts), die Mobile-App an die Meldung in ClubMembersScreen. Wo die Web-App einen festen Text zeigt (z. B. Vereinsliste laden) oder nur eine Benachrichtigung, steht die Kennung im Fehlerprotokoll des Clients, nicht auf dem Bildschirm.
  • Stripe-Fehler (backend/src/routes/stripeBilling.ts, utils/fehlerAntwort.ts): jede 502-Antwort der acht Stripe-Aufrufe nennt nur den festen Code und eine errorId, nie den Stripe-Text (er traegt Kennungen wie No such customer: 'cus_…'). Ausnahme: ein Kartenfehler (StripeCardError/card_error) traegt zusaetzlich stripeCode, declineCode und die Kundenmeldung von Stripe in detail (von Stripe fuer Kunden geschrieben, auf 200 Zeichen gekappt); die Web-App zeigt dafuer eigenes Deutsch (pages/billingApiError.ts). Im Protokoll stehen Name, Code, Stripe-Typ, -Code, HTTP-Status und die Stripe-Anfragekennung (req_…), nie die Meldung.
  • Logzeilen aus Diensten und Routen (Klasse A, seit 02.10.2026): rund 60 Zeilen, die vorher err: String(err), err: x?.message, eine gekuerzte Meldung oder einen Antworttext schrieben (Kontoloeschung, Benachrichtigungen, Push, Vertragsbestaetigung, Lexware, Store-Meldung, Wochenziele, Sitzungs- benachrichtigung, Duell-Benachrichtigungen, ICS-Kalender, Widerrufs-Purge, Logo-/Foto-Upload, Client-Log-Ingest, Blob-Speicher, Stripe-Abgleich, Azure-Function-Ingest) fuehren jetzt fehler: fehlerKennungMitUrsache(err) (utils/fehlerAntwort.ts, wirft nie): Name, Code (bei fetch-Fehlern auch cause.code) und eine feste Ursache — der Anfang kennung:status (keycloak_set_enabled_failed:403), eine Lexware-Antwort lexware … -> 422 als lexware_request_failed:422 (ohne Methode, URL mit Kundenadresse und Text), eine reine Code-Meldung, oder kennung: Text als Kennung. Nie der Rest. Felder, die einen String tragen (Blob-Ergebnis), nutzen fehlerText. Postmark: Status und ErrorCode, nie der Antworttext (er nennt die Empfaengeradresse); ICS-Feed: nur der Host, in allen vier Zeilen (ics_feed_failed, ics_feed_not_a_calendar, ics_feed_partially_unparsed, ics_event_without_uid). Geraete-Push-Token (Registrierung/Abmeldung): fehler. FCM-Tokentausch: Status und der feste OAuth-Fehlercode (oauthFehler, z. B. invalid_grant), nie der Antworttext. Avatar- und Profilfehler in der ClientLog-Tabelle (avatar_upload_failed, avatar_delete_failed, profile_me_failed): Name:Code (fehlerText), nie der Fehlertext. Der Waechtertest test/logHygieneKlasseA.test.ts verbietet in backend/src die Muster err: String(, err: x?.message, err: (x as any).message (Cast), String(err).slice(, err: \${,.message.slice(0, n),body: body.slice(und einen gekuerzten Antworttext, der erst in eine Variable gelesen wird (const detail = (await res.text()).slice(). **Fuer KQL-Abfragen:** die feste Ursache steht je nach Zeile unter zwei Schluesseln:fehler.ursache(alle Zeilen ausfehlerKennungMitUrsache, z. B.account_purge_failed,lexware_invoice_failed) undfehler.keycloak(nuridentity_deletion_pendinginaccountDeletion.ts, ausloeschFehlerKennung; dasselbe Format steht inPendingIdentityDeletion.lastError). Eine Abfrage ueber beide nimmtcoalesce(fehler.ursache, fehler.keycloak). **Volles Fehlerobjekt in Logzeilen (S2, seit 04.10.2026):** keine Logzeile inbackend/srcschreibt mehr{ err }/err: error(der Pino-Standard schrieb Meldung und Stack mit): rund 150 Stellen fuehren jetztfehler: fehlerKennungMitUrsache(err)(Name, Code, feste Ursache), auchstripeBilling.ts,personalBilling.ts,contractCancellation.ts,keycloakAdmin.ts(die Ursachekeycloak_…_failed:bleibt, der Antworttext nicht),auth.ts,members.ts,profile.ts,ingest/index.ts. Fehlerobjekte als erstes Argument (app.log.error(e)) sind ersetzt. Der Waechtertesttest/logHygieneVollesErr.test.ts(Syntaxbaum) findet in jedem Argument eines Protokollaufrufs (Logger undconsole.) ein Fehlerobjekt oder dessen Text (err,err.message/.stack,String(err), Vorlagentext, verschachtelt); bekannte Grenzen stehen im Kopfkommentar des Tests (u. a. Aufrufe ueber eine Variable wie inscheduler.ts). **Ausnahmen** (mit Begruendung im Test, tot gewordene Ausnahmen lassen ihn fehlschlagen): Betriebsskriptesrc/scripts/und die Backfill-Laeufesrc/ingest/backfill.ts(einmalige Laeufe von Hand, die Meldung ist die Diagnose) undmain()inindex.ts(Startfehler vor jedem Anfragebezug). Der Smoke-Test der Provider (providerReadiness.ts, nur Plattform-Admins) nennt indetailnur Name/Code/Ursache. **Admin-Fehlercodes (Positivliste):**antwortAdminFehlergibt nur Codes ausADMIN_FEHLERCODES(utils/fehlerAntwort.ts) unveraendert an den Client; eine Meldung, die nur wie ein Code aussieht, wird durch den Ersatzcode und eine Kennung ersetzt. Ein Waechtertest haelt Liste und Wurfstellen (ClientCodeErrorinadminClubs.ts, feste Meldungen des Keycloak-Dienstes) zusammen. **Weiter offen:**scheduler.tsbuildSchedulerErrorMeta(Betriebsdiagnose mit Meldung und Stack, eigene Entscheidung),tsConnect.ts(Fehlertext in 4xx-Antworten, eigene Aufgabe),members.ts(member_sync_failed: die Meldung eines Fehlers mit Status unter 500 oder 503 geht alserroran den Client) unddataFreshness.ts(Fehlertext in der Admin-Diagnose). **Erledigt (#449, seit 04.10.2026):**health.ts,/healthz/consent-deliverabilitysamt Alias,/healthz/store-reportingund die Admin-Diagnose geben bei Datenbank- oder Netzfehlern keinen Fehlertext mehr aus, sondern den festen Codedb_unavailable(Datenbank nicht erreichbar, auch Pool-Engpass P2024/P2037) oderquery_failed(Datenbank antwortet, Abfrage scheitert); ohneerrorId` (Health-Endpunkte tragen keine Kennung), die Zuordnung laeuft ueber die Anfragekennung im Protokoll.
  • Lizenzrouten (backend/src/routes/clubLicense.ts): license/validate und license/provision schicken die Meldung des Lizenzdienstes (Keygen) nicht an den Club-Admin, sondern { error, errorId }; auch die Benachrichtigung an den Club nennt nur die Fehlernummer. Auch die gespeicherte Lizenzzeile (licenseValidationMessage, providerMeta.keygen.lastSyncError; sichtbar ueber GET /api/club/license und in den Stripe-Benachrichtigungen) haelt nach einem Fehlschlag nur Name, Code und die feste Ursache (keygen_request_failed:404), nie die Meldung. Ein unbekannter Lizenzschluessel (Keygen 404) ist 404 license_key_not_found mit eigenem Satz. Ein Policy-Konflikt der Pruefung antwortet mit dem festen Code license_policy_mismatch; erwartete Policy und Variante des Schluessels stehen nur im Protokoll — weder in der Pruefantwort (validation.variantId/variantName entfallen) noch in der Lizenzzeile; ebenso Kunden-E-Mail und Ablauf des fremden Schluessels. Die bisher gespeicherten Werte (Variante, E-Mail, Ablauf) bleiben stehen. In der Web-App erscheint dazu ein eigener Satz. Die Validierungsmeldung einer erfolgreichen Pruefung („Lizenz abgelaufen“ u. ae.) ist Fachinhalt, kein Fehlertext, und bleibt. Die Web-App zeigt dafuer einen eigenen Satz mit der Fehlernummer.
  • 409-Antworten der Stripe-Routen (stripeBilling.ts) nennen keine fremden oder internen Kennungen mehr (Kunden-, Abo-, Vereins-IDs in detail); der Fehlercode genuegt dem Client, die Kennungen stehen im Protokoll. Ausgenommen: detail: subscription status … (ein Stripe-Statuswort, keine Kennung).
  • Admin-Vereinsrouten (backend/src/routes/adminClubs.ts): ein Fehler mit maschinenlesbarer Meldung (^[a-z][a-z0-9_]{2,79}$, z. B. club_not_found) bleibt der Fehlercode; jede andere Meldung (Datenbank-, Keycloak- Antworttexte) wird durch den Ersatzcode der Route und eine errorId ersetzt. Von einer Keycloak-Meldung bleibt nur der Anfang aus fester Kennung und HTTP-Status (keycloak_execute_actions_email_failed:500) als detail; so sieht der Betreiber, WAS scheiterte, nie den Antworttext. Der Admin-Portal-Proxy reicht errorId noch nicht durch (anderes Repo).
  • Gespeicherte Fehler (PendingIdentityDeletion.lastError) halten nur Name, Code und bei Keycloak-Fehlern die feste Kennung mit HTTP-Status (Error:keycloak_delete_user_failed:403, TypeError:ECONNREFUSED), nie den Antworttext (accountDeletion.ts, loeschFehlerKennung).
  • Logzeilen mit Fehlern aus Datenbank, Anmeldedienst oder Apple-Meldeweg nutzen fehler: fehlerKennung(error) (backend/src/utils/fehlerKennung.ts: Name und Code, nie message). Die uebrigen Dienste (Klasse A) sind auf fehlerKennungMitUrsache umgestellt, siehe den naechsten Abschnitt; offen sind nur die dort genannten Reste.
  • Bewusst ausgenommen: die Scheduler-Logzeilen (buildSchedulerErrorMeta) fuehren Name, Meldung und Stack fuer den Betrieb; sie sind eine eigene Entscheidung.

Review-Regel

Bei neuen Logging-Pfaden gilt:

  1. Nur loggen, was fuer Betrieb oder Fehlerdiagnose wirklich noetig ist.
  2. Freie Textfelder zuerst redigieren oder auf reduzierte Meta-Informationen abbilden.
  3. Admin-/Support-Lesepfade explizit absichern.
  4. Neue Client-Log-Felder nur einfuehren, wenn ihr Betriebsnutzen und Datenschutzbedarf zusammen dokumentiert sind.

Globaler Fehlerhandler (seit 02.10.2026)

backend/src/errorHandler.ts (buildGlobalErrorHandler, gesetzt in backend/src/index.ts) beantwortet, was ein Handler wirft, ohne es selbst abzufangen:

  • 5xx: { "error": "internal_error", "errorId": "E-…" } mit dem urspruenglichen Statuscode (auch aus status oder einer Zeichenfolge, samt headers des Fehlers wie retry-after), nie der Fehlertext. Dieselbe Kennung steht (1) in der Logzeile unhandled_request_error (Name, Code, Status, Stack-Zeilen ohne den Fehlertext) und (2) in der ClientLog-Zeile, die der onError-Hook schreibt (unhandled_server_error E-…, geschwaerzt). Nur (2) bleibt in Produktion erhalten: die Anwendungsprotokolle auf dem Dateisystem sind aus. Der Support findet den Fall also in den Client-Logs (Plattform-Admin). Grenze: Die Clients zeigen die Kennung nicht ueberall an (Web: Mitglieder-Dialoge; Mobile: Mitglieder-Meldung); die Mobile-App behandelt internal_error wie vorher den Statusnamen und zeigt „Der Server antwortet gerade nicht.“
  • Unveraendert bleiben: alles unter 500 (Validierung 400, 404, 413 bodyLimit, 429 rateLimit, ausdruecklich gesetztes 4xx — auch ein 4xx, das vor einem Wurf mit reply.code() gesetzt wurde), IdentityLookupUnavailableError (503 identity_unavailable) und ein ClientCodeError (backend/src/utils/clientCodeError.ts) mit Status >= 500 und fester Kennung als Meldung. Nur dieser Fehlertyp darf einen 5xx-Code an den Client schicken; ein anderer Fehler mit Status >= 500 und code-aehnlicher Meldung (Object.assign(new Error('x_y'), { statusCode: 503 })) wird intern beantwortet (seit 04.10.2026, Codex-Befund zu #444: das Meldungsmuster allein unterschied feste Codes nicht von Nutzernamen). Die Wurfstellen sind eine geschlossene Liste, ein Waechtertest (globalErrorHandler.test.ts) haelt sie fest: keycloak_admin_unavailable (503, zweimal), invitation_state_missing, invite_user_id_missing, club_profile_settings_unavailable (alle 500) in adminClubs.ts und identity_unavailable (503) in members.ts. Heute werden sie alle in ihren Routen abgefangen. Der onError-Hook nimmt den Status aus derselben Einstufung (stufeFehlerEin) wie der Handler, schreibt den Fehlertext hoechstens 300 Zeichen lang in die ClientLog-Zeile (fehlerTextGekappt: erst Leerraum falten, dann kappen (ein abgeschnittenes Wortende faellt weg) und schwaerzen — in dieser Reihenfolge, sonst bliebe ein Bruchstueck eines Tokens oder einer Adresse stehen; ein einzelnes Wort ohne Leerraum ueber 300 Zeichen faellt ganz weg). Die Zeile ist fuer Plattform-Admins lesbar und geht im Datenexport an den Betroffenen (dataExport.ts); Aufbewahrung 30 Tage (CLIENT_LOG_RETENTION_DAYS), gelesen wird nur innerhalb der Frist, physisch geloescht wird beim naechsten Eingang von Client-Logs, kein Job, damit die Kennung in jedem Fall in der gespeicherten Zeile steht. Der Web-Mitglieder-Mapper (membersApiError.ts) haengt die Kennung an internal_error an; Mobile folgt (S2).
  • Nicht Teil dieser Umstellung: adminClubs.ts und stripeBilling.ts beantworten Fehler in ihren catch-Bloecken selbst mit dem Fehlertext (error: message, detail: error.message); die Fastify-Standardzeile bei reply.send(error) und die Zeile identity lookup unavailable loggen weiter err samt Meldung.