"use client"; import { useState, useEffect, useMemo, useRef } from "react"; import { Message } from "./useConversation"; // ---- Types ---- export interface ToolCallEntry { id: string; name: string; startedAt: number; // Date.now() duration: number | null; // ms, null if still running status: "started" | "completed" | "error"; } export interface ServerMetrics { connected_clients: number; session_id: string | null; server_start_time: string; uptime_seconds: number; total_events_broadcast: number; } export interface CostMetrics { input_text: number; input_audio: number; output_text: number; output_audio: number; cached_text: number; cached_audio: number; total_cost: number; response_count: number; cache_hit_rate: number; } export interface ObservabilityData { // Message counts userMessageCount: number; assistantMessageCount: number; toolMessageCount: number; // Tool timeline toolCalls: ToolCallEntry[]; // Events per second (rolling 10s window) eventsPerSecond: number; // GenUI activity genuiComponentCount: number; genuiComponentNames: string[]; // Session uptime (seconds, null if no session) sessionUptimeSeconds: number | null; // Server metrics (polled) serverMetrics: ServerMetrics | null; // Cost metrics (polled from /api/stream/cost-summary) costMetrics: CostMetrics | null; // Total WebSocket events received this session totalWsEvents: number; } // ---- Hook ---- interface UseObservabilityOptions { messages: Message[]; isSessionActive: boolean; apiBaseUrl?: string; } export function useObservability({ messages, isSessionActive, apiBaseUrl = "http://localhost:7860", }: UseObservabilityOptions): ObservabilityData { // ---- Message counts (derived from messages) ---- const userMessageCount = messages.filter((m) => m.role === "user" && !m.isPartial).length; const assistantMessageCount = messages.filter((m) => m.role === "assistant").length; const toolMessageCount = messages.filter((m) => m.role === "tool").length; // ---- GenUI activity ---- const genuiMessages = messages.filter((m) => m.component); const genuiComponentCount = genuiMessages.length; const genuiComponentNames = [...new Set(genuiMessages.map((m) => m.component!.name))]; // ---- Tool call timeline ---- const toolCalls = useMemo(() => { const toolMessages = messages.filter((m) => m.role === "tool" && m.tool); return toolMessages.map((m) => ({ id: m.id, name: m.tool!.name, startedAt: new Date(m.timestamp).getTime(), duration: null, // Real duration not available from message data status: m.tool!.status, })).slice(-10) as ToolCallEntry[]; }, [messages]); // ---- Events per second (rolling window) ---- const eventTimestampsRef = useRef([]); const [eventsPerSecond, setEventsPerSecond] = useState(0); const [totalWsEvents, setTotalWsEvents] = useState(0); // Count every message change as events const prevMessageCountRef = useRef(0); useEffect(() => { const currentCount = messages.length; if (currentCount > prevMessageCountRef.current) { const newEvents = currentCount - prevMessageCountRef.current; const now = Date.now(); for (let i = 0; i < newEvents; i++) { eventTimestampsRef.current.push(now); } setTotalWsEvents((prev) => prev + newEvents); } prevMessageCountRef.current = currentCount; }, [messages]); // Update events/sec every second useEffect(() => { const interval = setInterval(() => { const now = Date.now(); const windowMs = 10_000; // 10-second rolling window eventTimestampsRef.current = eventTimestampsRef.current.filter( (t) => now - t < windowMs ); setEventsPerSecond( Math.round((eventTimestampsRef.current.length / (windowMs / 1000)) * 10) / 10 ); }, 1000); return () => clearInterval(interval); }, []); // ---- Session uptime ---- const sessionStartRef = useRef(null); const [sessionUptimeSeconds, setSessionUptimeSeconds] = useState(null); useEffect(() => { if (isSessionActive) { if (!sessionStartRef.current) { sessionStartRef.current = Date.now(); } } else { sessionStartRef.current = null; // Reset is handled via the tick interval below } }, [isSessionActive]); // Tick session uptime every second useEffect(() => { if (!isSessionActive) return; const interval = setInterval(() => { if (sessionStartRef.current) { setSessionUptimeSeconds( Math.floor((Date.now() - sessionStartRef.current) / 1000) ); } }, 1000); return () => clearInterval(interval); }, [isSessionActive]); // ---- Server metrics (via WebSocket, fallback initial fetch) ---- const [serverMetrics, setServerMetrics] = useState(null); useEffect(() => { // Initial fetch for data before WebSocket connects const fetchMetrics = async () => { try { const res = await fetch(`${apiBaseUrl}/api/stream/observability`); if (res.ok) { const data = await res.json(); setServerMetrics(data); } } catch { // Silently ignore — server may be down } }; fetchMetrics(); // Listen for WS-pushed observability snapshots const handleMetrics = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail) { setServerMetrics({ connected_clients: detail.connected_clients, session_id: detail.session_id, server_start_time: detail.server_start_time, uptime_seconds: detail.uptime_seconds, total_events_broadcast: detail.total_events_broadcast, }); } }; window.addEventListener("observability-metrics", handleMetrics); return () => window.removeEventListener("observability-metrics", handleMetrics); }, [apiBaseUrl]); // ---- Cost metrics (via WebSocket, fallback initial fetch) ---- const [costMetrics, setCostMetrics] = useState(null); useEffect(() => { // Initial fetch for data before WebSocket connects const fetchCost = async () => { try { const res = await fetch(`${apiBaseUrl}/api/stream/cost-summary`); if (res.ok) { const data = await res.json(); setCostMetrics(data); } } catch { // Silently ignore — server may be down } }; fetchCost(); // Listen for WS-pushed cost updates with aggregated summary const handleCost = (e: Event) => { const detail = (e as CustomEvent).detail; const summary = detail?.summary; if (summary) { setCostMetrics({ input_text: summary.input_text ?? 0, input_audio: summary.input_audio ?? 0, output_text: summary.output_text ?? 0, output_audio: summary.output_audio ?? 0, cached_text: summary.cached_text ?? 0, cached_audio: summary.cached_audio ?? 0, total_cost: summary.total_cost ?? 0, response_count: summary.response_count ?? 0, cache_hit_rate: summary.cache_hit_rate ?? 0, }); } }; window.addEventListener("cost-updated", handleCost); return () => window.removeEventListener("cost-updated", handleCost); }, [apiBaseUrl]); return { userMessageCount, assistantMessageCount, toolMessageCount, toolCalls, eventsPerSecond, genuiComponentCount, genuiComponentNames, sessionUptimeSeconds, serverMetrics, costMetrics, totalWsEvents, }; }