Observability 1a: Per-Turn-Latenz-Trace + Latenz-Karte

voice_metrics.py: neben den rollenden Stats jetzt ein echter Per-Turn-Trace
(TurnTrace + Ringpuffer der letzten 60 Turns). Balken-Stufen zeitlich disjunkt
(stt · vision · hirn · gen); hirn misst ab mark_brain_start() VOR dem Hermes-
Request, damit die Vision-Zeit nicht doppelt gezaehlt wird. STT (davor) und
Mem0-Retrieve (Rueckruf waehrend) werden per park()/_take_* best-effort dem Turn
zugeordnet (Ein-Nutzer-Geraet, kein Turn-ID noetig). Mem0 = Unter-Detail INNERHALB
hirn (nicht addieren) -> ehrlich, kein Doppelzaehlen.

voice.py: TurnTrace in voice_chat's gen() (commit garantiert 1x via finally, auch
bei Fehler/Abbruch). STT-Endpoint parkt seine Dauer. NEU: GET /api/voice/metrics
(schliesst die Luecke - selbstkritik-feed.sh curlte das, existierte nie -> 404) +
GET /api/voice/trace?limit=N.

memory.py: GET /api/memory?q=... (= Hermes' Mem0-Prefetch) misst + parkt die
Retrieve-Zeit.

Frontend: LatencyCard (gestapelte Balken je Turn, "Taeter" = groesste Stufe,
Fehler-Turns rot, Tooltip mit "davon Mem0 X s"), Query useVoiceTrace, in der
Zentrale unter "Stack & Telemetrie". Lokal gegen Seed-Server verifiziert: 30,3-s-
Haenger -> Hirn-Balken 94% + "davon Mem0 26,1 s".

Grenzen (ehrlich, als Fussnote in der Karte): Tool-Runden im Hermes-LLM-Loop haben
keinen Callback an MC2 -> stecken in "Antwort". Reine Telegram-Text-Turns laufen an
MC2 vorbei und erscheinen hier nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-04 13:21:52 +02:00
parent 58021331e1
commit 78448220b2
12 changed files with 552 additions and 207 deletions
@@ -0,0 +1,136 @@
import { Gauge } from "lucide-react"
import { useVoiceTrace } from "@/lib/queries"
import type { VoiceTurn } from "@/lib/api"
// Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Mem0 ist ein Unter-Detail
// INNERHALB von „Hirn" und wird NICHT als eigenes Segment addiert (sonst Doppelzählung).
const SEGMENTS = [
{ key: "stt_ms", label: "STT", color: "#f59e0b" },
{ key: "vision_ms", label: "Sehen", color: "#a78bfa" },
{ key: "hirn_ms", label: "Hirn", color: "#2dd4bf" },
{ key: "gen_ms", label: "Antwort", color: "#60a5fa" },
] as const
const fmt = (ms: number | null | undefined): string =>
ms == null ? "" : `${(ms / 1000).toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`
function ago(ts: number): string {
const s = Math.max(0, Date.now() / 1000 - ts)
if (s < 60) return `vor ${Math.round(s)} s`
if (s < 3600) return `vor ${Math.round(s / 60)} min`
return `vor ${Math.round(s / 3600)} h`
}
/** Summe der sichtbaren Balken-Stufen (STT gehört zur gefühlten Latenz dazu; total_ms des Backends
* misst nur den Chat-Request ohne STT). So stimmen Balkenbreite und angezeigte Zahl immer überein. */
function rowTotal(t: VoiceTurn): number {
return SEGMENTS.reduce((sum, s) => sum + ((t[s.key] as number | null) ?? 0), 0)
}
/** Dominante Stufe eines Turns = der „Täter". */
function culprit(t: VoiceTurn): { label: string; color: string; ms: number } | null {
let best: { label: string; color: string; ms: number } | null = null
for (const s of SEGMENTS) {
const ms = t[s.key] as number | null
if (ms != null && (best == null || ms > best.ms)) best = { label: s.label, color: s.color, ms }
}
return best
}
function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) {
const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ")
+ (t.mem0_ms != null ? ` (davon Mem0 ${fmt(t.mem0_ms)})` : "")
return (
<div className="flex items-center gap-2.5">
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span>
<div className="relative h-4 flex-1 overflow-hidden rounded-sm bg-muted/25" title={title}>
<div className="absolute inset-0 flex">
{SEGMENTS.map((s) => {
const ms = t[s.key] as number | null
if (!ms || ms <= 0) return null
return (
<div
key={s.key}
style={{ width: `${(ms / scale) * 100}%`, background: s.color }}
className="h-full first:rounded-l-sm"
/>
)
})}
</div>
{t.error && (
<div className="absolute inset-0 flex items-center justify-center bg-rose-500/15 text-[10px] font-semibold text-rose-300">
Fehler
</div>
)}
</div>
<span className="w-12 shrink-0 text-right font-mono text-[11px] font-semibold tabular-nums text-foreground">
{t.error ? "" : fmt(rowTotal(t))}
</span>
</div>
)
}
export function LatencyCard() {
const { data: turns } = useVoiceTrace(12)
const list = turns ?? []
const scale = Math.max(1, ...list.map((t) => rowTotal(t)))
const latest = list[0]
const lead = latest ? culprit(latest) : null
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Gauge className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Latenz je Turn</h2>
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
</div>
{latest ? (
<div className="mt-2 flex items-baseline gap-2">
<span className={`font-space text-3xl font-bold tracking-tight tabular-nums ${latest.error ? "text-rose-400" : "text-foreground"}`}>
{latest.error ? "Fehler" : fmt(rowTotal(latest))}
</span>
<span className="text-xs text-muted-foreground">
letzter Turn{!latest.error && lead && <> · Täter: <span style={{ color: lead.color }} className="font-semibold">{lead.label}</span> {fmt(lead.ms)}</>}
</span>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground">Noch kein Voice-Turn aufgezeichnet.</div>
)}
{latest && !latest.error && (
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
{SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")}
{latest.mem0_ms != null && <span className="text-muted-foreground/50"> · davon Mem0 {fmt(latest.mem0_ms)}</span>}
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1 pt-1">
{SEGMENTS.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
</div>
))}
</div>
</div>
{list.length > 0 ? (
<div className="space-y-1.5">
{list.map((t) => <TurnRow key={t.id} t={t} scale={scale} />)}
</div>
) : (
<div className="flex h-[120px] items-center justify-center px-6 text-center text-xs text-muted-foreground">
Sprich einmal mit Lucy dann erscheint hier der Zeit-Wasserfall pro Turn, damit man den einen Hänger sofort sieht.
</div>
)}
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
Hirn" = Zeit bis zum ersten Wort (Agent + Mem0-Suche + Modell); Tool-Runden stecken in „Antwort". Reine
Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht.
</div>
</div>
)
}
+21
View File
@@ -413,6 +413,27 @@ export interface TokenStats {
pricing?: Record<string, { in: number; out: number }>
}
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
// stt · vision · hirn · gen. mem0 = Unter-Detail INNERHALB von hirn (nicht zum Balken addieren).
export interface VoiceTurn {
id: string
ts: number // Epoch-Sekunden
session: string
kind: string
had_images: boolean
stt_ms: number | null
vision_ms: number | null
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Mem0 + LLM-TTFT)
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort
total_ms: number
error: string | null
}
export interface VoiceTraceResp {
turns: VoiceTurn[]
}
export interface ModelsResp {
models: ModelInfo[]
running?: string[]
+11
View File
@@ -23,6 +23,7 @@ import {
type SystemStatus,
type TokenStats,
type UpdatesResp,
type VoiceTraceResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
@@ -45,8 +46,18 @@ export const qk = {
connectHealth: ["connect-health"] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const,
}
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
useQuery({
queryKey: qk.voiceTrace,
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
refetchInterval,
select: (d) => d.turns ?? [],
})
export const useMemoryGraph = (enabled = true) =>
useQuery({
queryKey: qk.memoryGraph,
+5 -1
View File
@@ -4,6 +4,7 @@ import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { ServicesCard } from "@/components/dashboard/ServicesCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard"
import { ModelsCard } from "@/components/dashboard/ModelsCard"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
@@ -34,7 +35,10 @@ export function DashboardView() {
{/* Stack & Telemetrie */}
<section>
<ZoneLabel>Stack &amp; Telemetrie</ZoneLabel>
<ServicesCard />
<div className="grid gap-6 lg:grid-cols-2">
<ServicesCard />
<LatencyCard />
</div>
</section>
{/* Betrieb & Wissen */}