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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DRqLCVxW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CWAZq_3Z.css">
<script type="module" crossorigin src="/assets/index-Bcmk5MTW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bwnys6u-.css">
</head>
<body>
<div id="root"></div>
@@ -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 */}