Flurfunk-Diagnose — Umsetzungskonzept
Ein Admin-Tool, das den Flurfunk-Datenfluss vom Mikrofon-Klick bis zur Transkript-Bubble nachvollziehbar macht — pro Tenant, pro Versuch, in Sekunden statt SSH-Sessions.
Status (2026-05-12)
Alle 8 Phasen LIVE. Erreichbar im Admin-Portal unter /flurfunk.
Was du dort tun kannst:
- Liste aller Versuche pro Tenant filtern (24h / 7d / 30d / 90d)
- Detail-Timeline mit 6 Schritt-Icons + Latenzen
- Audio-Replay (Plattform-Admin, audit-logged)
- „Erneut ausfuehren"-Button
- Sidebar-Badge bei 3+ Failures in Folge
Web-Client schickt Heartbeats fuer recording_started und recording_stopped. Der Detail-Pfad ist damit auch sichtbar wenn der Connector-Trigger gar nicht ankommt — der haeufigste „blinde Fleck".
Motivation
Heute (12.05.2026) ist Flurfunk-Diagnose ein manueller Drei-Server-grep: Synapse-Container-Logs auf Shared-Host, Backend-pm2-Logs auf api.prilog.chat, Whisper-Logs auf prilog-wisper. Bei 3 Tenants akzeptabel, bei 500 nicht. Ziel: ein Operator klickt auf einen Versuch im Admin-Portal und sieht in 5 Sekunden, wo es klemmt.
Der Flow — 9 Schritte
┌────────────────────────────────────────────────────────────────────┐
│ Browser (Web-Client) │
│ │
│ 1. Mic-Aufnahme → MediaRecorder, Audio-Blob │
│ 2. Upload zu Synapse → POST /_matrix/media/v3/upload → mxc:// │
│ 3. m.audio posten → PUT /_matrix/.../send/m.room.message │
│ { msgtype: 'm.audio', url: 'mxc://...' } │
└──────────────────────────┬─────────────────────────────────────────┘
│ (HTTP zu Synapse-Container)
▼
┌────────────────────────────────────────────────────────────────────┐
│ Synapse-Container (synapse-{slug}) │
│ │
│ 4. Persistiert → events-DB, an Clients verteilt │
│ 5. Connector-Hook → on_new_event filtert msgtype=m.audio, │
│ ruft policy_client.transcribe_voice() │
└──────────────────────────┬─────────────────────────────────────────┘
│ (HTTPS api.prilog.chat)
▼
┌────────────────────────────────────────────────────────────────────┐
│ Backend (api.prilog.chat) │
│ │
│ 6. transcribeVoiceMessage() │
│ ├─ Tenant + ServerOrder lookup │
│ ├─ transcriptionEnabled? │
│ ├─ Dauer-Check (durationSec ≤ maxRecordingSeconds+5) │
│ └─ Sender-Permission (Admin-Bypass oder UserType) │
│ │
│ 7. downloadSynapseMedia() │
│ → GET /_matrix/client/v1/media/download/... mit Admin-Token │
│ │
│ 8. transcribeAudio() (whisper.service) │
│ → POST {WHISPER_BASE_URL}/v1/audio/transcriptions │
│ │
│ 9. postTranscriptToRoom() │
│ → PUT /_matrix/.../send/m.room.message als Reply │
│ mit Custom-Property org.prilog.transcript_for │
└──────────────────────────┬─────────────────────────────────────────┘
│ (HTTP zu Synapse-Container)
▼
┌────────────────────────────────────────────────────────────────────┐
│ Browser (Web-Client) │
│ │
│ 10. Render → m.text-Event mit transcript_for-Custom │
│ wird als Inline-Transkript unter der │
│ Audio-Bubble dargestellt │
└────────────────────────────────────────────────────────────────────┘Heutige Sichtbarkeit pro Schritt:
| Schritt | Wo erfasst? | Wie heute eingesehen? |
|---|---|---|
| 1. Mic-Aufnahme | Browser-Console | nicht serverseitig sichtbar |
| 2. Upload zu Synapse | Synapse access log | docker logs synapse-{slug} | grep media/v3/upload |
| 3. m.audio-Event | Synapse events-Tabelle | DB-Query, selten gemacht |
| 4. Persistiert | dito | dito |
| 5. Connector-Hook | Connector-INFO-Log | docker logs synapse-{slug} | grep "Triggering voice" |
| 6. Backend-Receive | Backend pm2 access-log | grep transcribe-voice /home/lee/.pm2/logs/backend-api-out.log |
| 7. Media-Download | Backend logger.warn/error | dito, nach media download non-2xx |
| 8. Whisper-Call | Backend + Whisper-Server | dito, nach WhisperService |
| 9. Reply-Post | Backend logger.info | dito, nach transcript posted |
| 10. Render | Browser DOM | unsichtbar |
→ Bei jedem Bug-Report heute: drei SSH-Sessions, mindestens. Eingrenzung dauert 5–15 Minuten.
Lösung: strukturiertes Tracing in der DB
Eine neue Tabelle flurfunk_attempt mit einem Eintrag pro Audio-Aufnahme. Pro Schritt ein Timestamp + Status-Feld. Admin-UI listet die Versuche und zeigt im Detail-Panel die Timeline.
Datenmodell
model FlurfunkAttempt {
id String @id @default(cuid())
tenantId String @map("tenant_id")
roomId String @map("room_id") @db.VarChar(255)
sourceEventId String @map("source_event_id") @db.VarChar(255)
sender String @db.VarChar(255)
// Audio-Metadaten (vom Connector geliefert)
audioMxcUri String @map("audio_mxc_uri") @db.VarChar(500)
audioMimetype String? @map("audio_mimetype") @db.VarChar(100)
audioDurationSec Float? @map("audio_duration_sec")
audioFilename String? @map("audio_filename") @db.VarChar(255)
// Browser-Heartbeats (Schritte 1-4)
clientAttemptId String? @map("client_attempt_id") @db.VarChar(50) @unique
recordingStartedAt DateTime? @map("recording_started_at") // Schritt 1
recordingStoppedAt DateTime? @map("recording_stopped_at") // (Aufnahme-Ende)
synapseUploadStartedAt DateTime? @map("synapse_upload_started_at") // Schritt 2 begin
synapseUploadDoneAt DateTime? @map("synapse_upload_done_at") // Schritt 2 done
matrixEventPostedAt DateTime? @map("matrix_event_posted_at") // Schritt 3 (vom Client beobachtet)
// Space-Bindung (fuer Retention via Cascade)
spaceId String? @map("space_id") @db.VarChar(50)
// Schritt 6 — Backend-Receive (Connector hat angerufen)
backendReceivedAt DateTime? @map("backend_received_at")
// Schritt 7 — Media-Download
mediaDownloadAt DateTime? @map("media_download_at")
mediaDownloadStatusCode Int? @map("media_download_status_code")
mediaDownloadBytes Int? @map("media_download_bytes")
mediaDownloadError String? @map("media_download_error") @db.Text
// Schritt 8 — Whisper-Call
whisperCallStartedAt DateTime? @map("whisper_call_started_at")
whisperCallFinishedAt DateTime? @map("whisper_call_finished_at")
whisperStatusCode Int? @map("whisper_status_code")
whisperLatencyMs Int? @map("whisper_latency_ms")
whisperTextLength Int? @map("whisper_text_length")
whisperText String? @map("whisper_text") @db.Text
whisperError String? @map("whisper_error") @db.Text
// Schritt 9 — Reply-Post
transcriptPostedAt DateTime? @map("transcript_posted_at")
transcriptEventId String? @map("transcript_event_id") @db.VarChar(255)
transcriptPostStatusCode Int? @map("transcript_post_status_code")
transcriptPostError String? @map("transcript_post_error") @db.Text
// Outcome
finalStatus String @map("final_status") @db.VarChar(50)
// Werte: 'success' | 'skipped_disabled' | 'skipped_too_long' |
// 'skipped_no_permission' | 'failed_download' |
// 'failed_whisper' | 'failed_post' | 'failed_other'
finalReason String? @map("final_reason") @db.Text
finalStatusAt DateTime @map("final_status_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
@@index([tenantId, finalStatusAt(sort: Desc)])
@@index([tenantId, finalStatus])
@@index([finalStatus, finalStatusAt(sort: Desc)])
@@map("flurfunk_attempts")
}Retention (Lee 12.05.2026): Kein Zeit-Cron. Attempt-Records bleiben so lange wie Space + Tenant existieren — onDelete: Cascade auf beiden Beziehungen. Manuelle Loeschung pro Attempt im Admin-UI moeglich. Aufbewahrung folgt damit der DSGVO-Lebensdauer des Spaces, nicht einem starren Zeit-Cut.
Code-Integration
transcribe.service.ts wird so umgebaut, dass pro Schritt ein DB-Update geschrieben wird. Das logger.info/warn/error bleibt zusätzlich (für strukturiertes Pino-Suchen + Sentry-ähnliche Tools).
export async function transcribeVoiceMessage(req: TranscribeVoiceRequest): Promise<void> {
const attempt = await prisma.flurfunkAttempt.create({
data: {
tenantId: '<resolved-below>', // erst nach Tenant-Lookup gefüllt
roomId: req.roomId,
sourceEventId: req.eventId,
sender: req.sender,
audioMxcUri: req.mxcUri,
audioMimetype: req.mimetype,
audioDurationSec: req.durationSec,
audioFilename: req.filename,
backendReceivedAt: new Date(),
finalStatus: 'pending',
finalStatusAt: new Date(),
},
});
// Bei jedem Skip / Fehler / Erfolg: prisma.flurfunkAttempt.update({ where: { id: attempt.id }, data: { ... } })
// ...
}Reduziert die heute verstreuten logger.warn-Stellen auf ein einheitliches Pattern.
Admin-UI
Neue Sidebar-Seite /flurfunk-diagnose (oder als Tab auf der bestehenden /whisper-server-Seite).
Layout
┌─────────────────────────────────────────────────────────────────┐
│ Flurfunk-Diagnose │
│ [Tenant ▾] [Status ▾] [Letzte 24h ▾] [⟳ Refresh] │
├─────────────────────────────────────────────────────────────────┤
│ Übersicht (letzte 7 Tage) │
│ ┌───────────┬──────────┬────────────┬──────────────┐ │
│ │ Versuche │ Erfolge │ Avg-Latenz │ Top-Fehler │ │
│ │ 1.247 │ 94 % │ 3.2 s │ too_long (4) │ │
│ └───────────┴──────────┴────────────┴──────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────┬─────────────────────────┐ │
│ │ Letzte Versuche │ Detail │ │
│ │ ─────────────────────────────── │ ─────────────────────── │ │
│ │ 14:23 leander L.Seyffer 4.1s │ Tenant: leander │ │
│ │ ✅ success 2.8s ➢ │ Sender: @adminleander… │ │
│ │ 14:18 leander L.Seyffer 12.3s │ Audio: 4.1 s, audio/mp4 │ │
│ │ ⚠ too_long ➢ │ MIME: audio/mp4 │ │
│ │ 13:55 weser Lehrer-A 3.8s │ │ │
│ │ ✅ success 2.5s │ ─── Timeline ─────── │ │
│ │ 13:40 demo test-acc N/A │ │ │
│ │ ❌ failed_download ➢ │ ✅ 14:23:02.341 │ │
│ │ … │ Backend-Receive │ │
│ │ │ │ │
│ │ [≪ ‹ 1/87 › ≫] │ ✅ 14:23:02.512 (+171ms)│ │
│ │ │ Media-Download │ │
│ │ │ HTTP 200 · 38.2 KB │ │
│ │ │ │ │
│ │ │ ✅ 14:23:05.108 (+2.7s) │ │
│ │ │ Whisper-Call │ │
│ │ │ HTTP 200 · 2596 ms │ │
│ │ │ Text-Länge: 87 │ │
│ │ │ │ │
│ │ │ ✅ 14:23:05.291 (+183ms)│ │
│ │ │ Reply gepostet │ │
│ │ │ Event: $abc123… │ │
│ │ │ │ │
│ │ │ ─── Audio ──────────── │ │
│ │ │ ▶ [audio player] │ │
│ │ │ │ │
│ │ │ ─── Transkript ─────── │ │
│ │ │ "Bitte denken Sie an…" │ │
│ │ │ │ │
│ │ │ [↻ Erneut ausführen] │ │
│ │ │ [Roh-Payload (JSON)] │ │
│ └─────────────────────────────────┴─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘Schritt-Status-Icons
| Icon | Bedeutung |
|---|---|
| ✅ | Schritt erfolgreich, Timestamp + Latenz |
| ⚠ | Schritt absichtlich gestoppt (zu lang, keine Permission) — Reason im Tooltip |
| ❌ | Schritt fehlgeschlagen — Statuscode + Error-Snippet |
| ⏸ | Schritt nicht erreicht (vorheriger Schritt failed) |
„Erneut ausführen"-Button
- Trigger:
POST /api/admin/flurfunk/attempts/:id/retry - Backend ruft
transcribeVoiceMessagemit denselben Parametern aus dem Attempt-Record - Neuer Attempt-Eintrag wird erzeugt, der originale bleibt unverändert
- Verbraucht Whisper-Sekunden — Hinweis im UI
Admin-API
GET /api/admin/flurfunk/attempts
?tenantId=...&status=...&from=...&to=...&limit=50
→ { attempts: [...], total, aggregations: {...} }
GET /api/admin/flurfunk/attempts/:id
→ { attempt: { ...alle Felder... } }
GET /api/admin/flurfunk/attempts/:id/audio
→ Streamt das Audio aus Synapse (proxy + Auth)
→ DSGVO-relevant: nur Plattform-Admin, audit-logged
POST /api/admin/flurfunk/attempts/:id/retry
→ triggert transcribeVoiceMessage erneut, gibt neue attempt-ID zurück
GET /api/admin/flurfunk/aggregations
?period=24h|7d|30d
→ Pro-Tenant + global: count, success-rate, avg-latency, top-failurePhasen + Aufwand
| Phase | Inhalt | Aufwand |
|---|---|---|
| 1. Backbone | DB-Migration, transcribe.service umbauen mit Attempt-Tracking, beibehaltenes Logging | 1 Tag |
| 2. Read-API + Liste | GET-Endpoints, React-Page mit Filtern + Liste | 1 Tag |
| 3. Detail-Timeline | Detail-Panel mit Schritt-Icons + Latenzen | 0.5 Tag |
| 4. Audio-Replay | Stream-Endpoint mit Auth, HTML5-Player im Panel | 0.5 Tag |
| 5. Retry-Button | POST /retry, neues Attempt im UI verlinken | 0.5 Tag |
| 6. Aggregat-Dashboard | Header-Kacheln, evtl. Sparkline | 0.5 Tag |
| 7. Pro-aktive Alerts | Sidebar-Badge wenn ein Tenant 3 Failures in Folge, optional Mail an Operator | 0.5 Tag |
| 8. Retention-Cron | 90-Tage-Purge, 30-Tage-Hash für Text | 0.25 Tag |
Total: ~4–5 Tage, sauber in 2–3 Schritten merge-bar (1+2+3 als erstes nutzbares Feature, 4+5+6 als Polish, 7+8 als Hardening).
Trade-offs + Alternativen
Alternative 1: nur strukturiertes Logging + ELK/Loki.
- Pro: keine Schema-Migration, vorhandene Tools
- Con: externer Service (Kosten, Komplexität), Audio-Replay + Retry nicht möglich
Alternative 2: Sentry-ähnliches Tracing.
- Pro: out-of-the-box Visualisierung
- Con: Drittanbieter, DSGVO-Audit, kein Audio-Replay
Begründung für eigene Lösung: Die Daten gehören zum Tenant-State (Audit, DSGVO-relevant), Audio-Replay + Retry brauchen sowieso Backend-Logik. Externe Tools sind eher Add-on (Pino + structured fields können später Loki füttern, ohne dass dieses Modell sich ändert).
DSGVO-Erwägungen
| Daten | Aufbewahrung | Wer sieht es? |
|---|---|---|
| Metadaten (Sender, Zeitstempel, Status) | bis Space/Tenant-Delete oder manuelle Loeschung | Tenant-Admin + Plattform-Admin |
| Transkript-Volltext | bis Space/Tenant-Delete oder manuelle Loeschung | Tenant-Admin + Plattform-Admin |
| Audio-Datei (mxc-Referenz) | bleibt in Synapse, separater Lifecycle | Raum-Mitglieder via Web-Client |
| Audio-Replay im Admin-Portal | nur Plattform-Admin, Audit-Log-Eintrag bei jedem Zugriff | Plattform-Admin (nicht Tenant-Admin) |
Begruendung: Mitarbeiter-Stimmaufnahme ist personenbezogenes Datum. Im Lebenszyklus des Spaces ist sie aber genauso lange relevant wie die zugehoerige Audio-Nachricht — die bleibt ja auch in Synapse-Media-Storage bis zum Space-Delete. Wenn Space/Tenant geloescht wird, wird die Attempt-Record automatisch mit-geloescht (Prisma onDelete: Cascade). Audio-Replay ist nur fuer Plattform-Admin offen, weil das Streamen ein gesonderter Zugriffs-Pfad ist, der DSGVO-relevant einzeln auditiert wird.
Beziehung zur bestehenden Spec/Reconcile/Smoke-Architektur
Das Tracing-Modul ersetzt nicht Smoke/Reconcile, es ergänzt sie:
- Smoke sagt: „kann die Box transkribieren? Ja/Nein"
- Reconcile sagt: „ist die Box korrekt provisioniert? Ja/Nein"
- Attempt-Tracing sagt: „warum ist der konkrete Versuch fehlgeschlagen?"
Smoke-Probe könnte erweitert werden um: „letzter Versuch erfolgreich?" — eine Aggregation aus der flurfunk_attempts-Tabelle. So fließt Live-Erfahrung zurück in die Health-Bewertung.
Entschieden (Lee, 12.05.2026)
Transkript-Volltext-Aufbewahrung — an den Lifecycle des Space/Tenants gekoppelt. Solange der Space/Tenant existiert, bleibt der Volltext. Beim Space-Delete oder Tenant-Delete: Cascade. Plus manuelle Löschung pro Attempt via Admin-UI-Button. Kein Zeit-Cron.
Audio-Replay — nur Plattform-Admin (du). Tenant-Admin sieht Metadaten + Status + Transkript-Text (DSGVO ist seine Domäne), aber der Audio-Stream selbst ist hinter Plattform-Admin-Auth. Jeder Audio-Zugriff erzeugt einen
audit_log-Eintrag mitadmin_user_id,attempt_id,timestamp.Retry kostet Whisper-Sekunden — kostenlos, da Diagnose-Pfad. Wenn das je spürbar wird, im Pro-Plan tracken.
Browser-Tracing (Schritte 1–4) — ja, hinzunehmen. Web-Client schickt drei Heartbeats an
POST /platform/v1/flurfunk/heartbeatmit einemclient_attempt_id(vor dem Upload generiert):recording_started(Klick auf Mic)recording_stopped(Klick auf Stop / Auto-Stop nach 30s)synapse_upload_done(mxc-URI von Synapse zurück) Der Connector matchet beim Trigger übersourceEventId— wenn der Event-ID-Match nicht klappt, korreliert das Backend per (sender + audioMxcUri + ±30s-Fenster). Aufwand: +1 Tag.
Erweiterbarkeit
Das Pattern Attempt-Tabelle pro Datenfluss ist generisch. Spätere Anwendungen:
- Office-Konverter (DOCX/XLSX → PDF) hat denselben asynchronen Charakter
- Whisper-Batch-Jobs (z.B. Sitzungsprotokolle)
- AVV-Generierung + Mail-Versand
Wenn das Konzept hier sauber wird, ist es die Vorlage für jede async-Pipeline mit Operator-Diagnose-Bedarf.