Skip to content

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:

SchrittWo erfasst?Wie heute eingesehen?
1. Mic-AufnahmeBrowser-Consolenicht serverseitig sichtbar
2. Upload zu SynapseSynapse access logdocker logs synapse-{slug} | grep media/v3/upload
3. m.audio-EventSynapse events-TabelleDB-Query, selten gemacht
4. Persistiertditodito
5. Connector-HookConnector-INFO-Logdocker logs synapse-{slug} | grep "Triggering voice"
6. Backend-ReceiveBackend pm2 access-loggrep transcribe-voice /home/lee/.pm2/logs/backend-api-out.log
7. Media-DownloadBackend logger.warn/errordito, nach media download non-2xx
8. Whisper-CallBackend + Whisper-Serverdito, nach WhisperService
9. Reply-PostBackend logger.infodito, nach transcript posted
10. RenderBrowser DOMunsichtbar

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

prisma
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).

ts
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

IconBedeutung
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 transcribeVoiceMessage mit 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-failure

Phasen + Aufwand

PhaseInhaltAufwand
1. BackboneDB-Migration, transcribe.service umbauen mit Attempt-Tracking, beibehaltenes Logging1 Tag
2. Read-API + ListeGET-Endpoints, React-Page mit Filtern + Liste1 Tag
3. Detail-TimelineDetail-Panel mit Schritt-Icons + Latenzen0.5 Tag
4. Audio-ReplayStream-Endpoint mit Auth, HTML5-Player im Panel0.5 Tag
5. Retry-ButtonPOST /retry, neues Attempt im UI verlinken0.5 Tag
6. Aggregat-DashboardHeader-Kacheln, evtl. Sparkline0.5 Tag
7. Pro-aktive AlertsSidebar-Badge wenn ein Tenant 3 Failures in Folge, optional Mail an Operator0.5 Tag
8. Retention-Cron90-Tage-Purge, 30-Tage-Hash für Text0.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

DatenAufbewahrungWer sieht es?
Metadaten (Sender, Zeitstempel, Status)bis Space/Tenant-Delete oder manuelle LoeschungTenant-Admin + Plattform-Admin
Transkript-Volltextbis Space/Tenant-Delete oder manuelle LoeschungTenant-Admin + Plattform-Admin
Audio-Datei (mxc-Referenz)bleibt in Synapse, separater LifecycleRaum-Mitglieder via Web-Client
Audio-Replay im Admin-Portalnur Plattform-Admin, Audit-Log-Eintrag bei jedem ZugriffPlattform-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)

  1. 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.

  2. Audio-Replaynur 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 mit admin_user_id, attempt_id, timestamp.

  3. Retry kostet Whisper-Sekunden — kostenlos, da Diagnose-Pfad. Wenn das je spürbar wird, im Pro-Plan tracken.

  4. Browser-Tracing (Schritte 1–4)ja, hinzunehmen. Web-Client schickt drei Heartbeats an POST /platform/v1/flurfunk/heartbeat mit einem client_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 über sourceEventId — 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.