From 051c3281848b6a3b3391ec93ee2ce420c9f87873 Mon Sep 17 00:00:00 2001 From: Mudassar Date: Fri, 10 Apr 2026 10:26:57 +0500 Subject: [PATCH 1/2] feat: add Pipeline Analytics dashboard with latency metrics Add a new Pipeline Analytics page that provides detailed voice agent performance monitoring with per-turn latency breakdowns: New files: - API route: /api/analytics/pipeline - aggregates STT, LLM TTFT, TTS, and EOU delay metrics from soundflare_metrics_logs - React hook: usePipelineAnalytics - React Query wrapper with caching - Page: /[projectid]/analytics - new route with agent_id query param - Component: PipelineAnalytics - full dashboard with: - KPI cards (total calls, avg/P95 latency, success rate) - Pipeline breakdown cards (STT, LLM TTFT, TTS, EOU delay) - Daily call volume bar chart - Hourly latency trend area chart - Call log table with expandable per-turn metrics - Call detail slide-over panel - Database migration: materialized view for efficient aggregation - Sidebar navigation: added Pipeline Analytics link under Logs group Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- database/add_pipeline_analytics_view.sql | 79 ++ src/app/[projectid]/analytics/page.tsx | 82 ++ src/app/api/analytics/pipeline/route.ts | 209 +++++ .../analytics/PipelineAnalytics.tsx | 809 ++++++++++++++++++ src/components/analytics/index.ts | 1 + src/components/shared/SidebarWrapper.tsx | 8 + src/hooks/usePipelineAnalytics.ts | 92 ++ 7 files changed, 1280 insertions(+) create mode 100644 database/add_pipeline_analytics_view.sql create mode 100644 src/app/[projectid]/analytics/page.tsx create mode 100644 src/app/api/analytics/pipeline/route.ts create mode 100644 src/components/analytics/PipelineAnalytics.tsx create mode 100644 src/components/analytics/index.ts create mode 100644 src/hooks/usePipelineAnalytics.ts diff --git a/database/add_pipeline_analytics_view.sql b/database/add_pipeline_analytics_view.sql new file mode 100644 index 0000000..f7aa6bc --- /dev/null +++ b/database/add_pipeline_analytics_view.sql @@ -0,0 +1,79 @@ +-- Pipeline Analytics: Materialized view for efficient latency aggregation +-- Run this in Supabase SQL Editor after setup-supabase.sql +-- +-- This view pre-aggregates per-turn pipeline latency metrics (STT, LLM TTFT, +-- TTS TTFB, EOU delay) from soundflare_metrics_logs so the analytics dashboard +-- can query summaries without scanning the full table every time. + +-- 1. Create materialized view +CREATE MATERIALIZED VIEW IF NOT EXISTS public.pipeline_analytics_daily AS +SELECT + ml.session_id, + cl.agent_id, + DATE(cl.call_started_at) AS call_date, + EXTRACT(HOUR FROM cl.call_started_at) AS call_hour, + + -- STT duration (seconds) + AVG((ml.stt_metrics->>'duration')::numeric) + FILTER (WHERE ml.stt_metrics->>'duration' IS NOT NULL) + AS avg_stt_duration, + + -- LLM time-to-first-token (seconds) + AVG((ml.llm_metrics->>'ttft')::numeric) + FILTER (WHERE ml.llm_metrics->>'ttft' IS NOT NULL) + AS avg_llm_ttft, + + -- TTS time-to-first-byte (seconds) + AVG((ml.tts_metrics->>'ttfb')::numeric) + FILTER (WHERE ml.tts_metrics->>'ttfb' IS NOT NULL) + AS avg_tts_ttfb, + + -- End-of-utterance delay (seconds) + AVG((ml.eou_metrics->>'end_of_utterance_delay')::numeric) + FILTER (WHERE ml.eou_metrics->>'end_of_utterance_delay' IS NOT NULL) + AS avg_eou_delay, + + -- Turn count for weighting + COUNT(*) AS turn_count + +FROM public.soundflare_metrics_logs ml +JOIN public.soundflare_call_logs cl + ON ml.session_id = cl.id +WHERE cl.call_started_at IS NOT NULL +GROUP BY ml.session_id, cl.agent_id, DATE(cl.call_started_at), EXTRACT(HOUR FROM cl.call_started_at); + +-- 2. Index for fast lookups +CREATE INDEX IF NOT EXISTS idx_pipeline_analytics_agent_date + ON public.pipeline_analytics_daily (agent_id, call_date); + +-- 3. Refresh function (call from cron or API) +CREATE OR REPLACE FUNCTION public.refresh_pipeline_analytics() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY public.pipeline_analytics_daily; +END; +$$; + +-- 4. P95 helper — returns the 95th-percentile latency for a date range +CREATE OR REPLACE FUNCTION public.pipeline_p95_latency( + p_agent_id uuid, + p_from date, + p_to date +) +RETURNS numeric +LANGUAGE sql +STABLE +AS $$ + SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY cl.avg_latency) + FROM public.soundflare_call_logs cl + WHERE cl.agent_id = p_agent_id + AND cl.call_started_at >= p_from + AND cl.call_started_at < (p_to + INTERVAL '1 day') + AND cl.avg_latency IS NOT NULL; +$$; + +-- 5. RLS policy (mirrors existing call_logs policy) +ALTER MATERIALIZED VIEW public.pipeline_analytics_daily OWNER TO postgres; diff --git a/src/app/[projectid]/analytics/page.tsx b/src/app/[projectid]/analytics/page.tsx new file mode 100644 index 0000000..2d63308 --- /dev/null +++ b/src/app/[projectid]/analytics/page.tsx @@ -0,0 +1,82 @@ +// src/app/[projectid]/analytics/page.tsx +'use client' + +import { useParams, useSearchParams } from 'next/navigation' +import { Suspense } from 'react' +import { PipelineAnalytics } from '@/components/analytics/PipelineAnalytics' +import { Skeleton } from '@/components/ui/skeleton' +import { Card, CardContent } from '@/components/ui/card' + +function AnalyticsSkeleton() { + return ( +
+
+ + +
+
+ {[1, 2, 3, 4].map((i) => ( + + +
+ +
+ + +
+
+
+
+ ))} +
+
+ + +
+
+ ) +} + +function AnalyticsContent() { + const params = useParams() + const searchParams = useSearchParams() + + const projectId = Array.isArray(params?.projectid) + ? params.projectid[0] + : (params?.projectid as string) + + // Agent can be specified via query param: ?agent_id=xxx + const agentId = searchParams.get('agent_id') || '' + + if (!agentId) { + return ( +
+
+

Select an Agent

+

+ Navigate to an agent's page and click "Pipeline Analytics" to view + latency and performance metrics. +

+

+ Or append ?agent_id=YOUR_AGENT_ID to + this URL. +

+
+
+ ) + } + + return ( +
+ +
+ ) +} + +export default function AnalyticsPage() { + return ( + }> + + + ) +} diff --git a/src/app/api/analytics/pipeline/route.ts b/src/app/api/analytics/pipeline/route.ts new file mode 100644 index 0000000..29ee6d6 --- /dev/null +++ b/src/app/api/analytics/pipeline/route.ts @@ -0,0 +1,209 @@ +// app/api/analytics/pipeline/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getSupabaseAdmin } from '@/lib/supabase-server' + +/** + * GET /api/analytics/pipeline?agent_id=&from=&to= + * + * Returns pipeline latency analytics: + * - Aggregate KPIs (avg latency, P95, STT/LLM/TTS/EOU averages) + * - Daily call volumes + * - Hourly latency trend + * - Per-call latency breakdown for the call log table + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const agentId = searchParams.get('agent_id') + const from = searchParams.get('from') // YYYY-MM-DD + const to = searchParams.get('to') // YYYY-MM-DD + + if (!agentId) { + return NextResponse.json( + { error: 'agent_id is required' }, + { status: 400 } + ) + } + + const supabase = getSupabaseAdmin() + + // Default date range: last 7 days + const dateTo = to || new Date().toISOString().split('T')[0] + const dateFrom = + from || + new Date(Date.now() - 7 * 86400000).toISOString().split('T')[0] + + // 1. Aggregate KPIs from call_logs + const { data: callLogs, error: callError } = await supabase + .from('soundflare_call_logs') + .select( + 'id, avg_latency, duration_seconds, call_ended_reason, call_started_at, customer_number' + ) + .eq('agent_id', agentId) + .gte('call_started_at', `${dateFrom} 00:00:00`) + .lte('call_started_at', `${dateTo} 23:59:59.999`) + .order('call_started_at', { ascending: true }) + + if (callError) throw callError + + // 2. Per-turn metrics from metrics_logs + const callIds = (callLogs || []).map((c) => c.id) + let metricsLogs: any[] = [] + + if (callIds.length > 0) { + const { data: ml, error: mlError } = await supabase + .from('soundflare_metrics_logs') + .select( + 'session_id, turn_id, stt_metrics, llm_metrics, tts_metrics, eou_metrics, user_transcript, agent_response, created_at' + ) + .in('session_id', callIds) + .order('created_at', { ascending: true }) + + if (mlError) throw mlError + metricsLogs = ml || [] + } + + // --- Compute aggregates --- + const logs = callLogs || [] + const totalCalls = logs.length + const successCalls = logs.filter( + (c) => c.call_ended_reason === 'customer_ended_call' || c.call_ended_reason === 'agent_ended_call' + ).length + const totalMinutes = logs.reduce( + (s, c) => s + (c.duration_seconds || 0), + 0 + ) / 60 + const latencies = logs + .map((c) => c.avg_latency) + .filter((l): l is number => l != null && l > 0) + const avgLatency = + latencies.length > 0 + ? latencies.reduce((a, b) => a + b, 0) / latencies.length + : null + const p95Latency = + latencies.length > 0 + ? latencies.sort((a, b) => a - b)[ + Math.floor(latencies.length * 0.95) + ] + : null + const successRate = + totalCalls > 0 ? Math.round((successCalls / totalCalls) * 100) : 0 + + // Pipeline averages from turn-level data + const sttDurations: number[] = [] + const llmTtfts: number[] = [] + const ttsTtfbs: number[] = [] + const eouDelays: number[] = [] + + for (const m of metricsLogs) { + const stt = m.stt_metrics?.duration + const llm = m.llm_metrics?.ttft + const tts = m.tts_metrics?.ttfb + const eou = m.eou_metrics?.end_of_utterance_delay + if (typeof stt === 'number') sttDurations.push(stt) + if (typeof llm === 'number') llmTtfts.push(llm) + if (typeof tts === 'number') ttsTtfbs.push(tts) + if (typeof eou === 'number') eouDelays.push(eou) + } + + const avg = (arr: number[]) => + arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : null + + // Estimated TTS: when direct TTS isn't available, approximate from latency + const avgStt = avg(sttDurations) + const avgLlm = avg(llmTtfts) + const avgTts = avg(ttsTtfbs) + const estimatedTts = + avgTts != null + ? avgTts + : avgLatency != null && avgStt != null && avgLlm != null + ? Math.max(0, avgLatency - avgStt - avgLlm) + : null + + // --- Daily volumes --- + const dailyMap = new Map() + for (const c of logs) { + const day = c.call_started_at?.split('T')[0] || 'unknown' + const entry = dailyMap.get(day) || { calls: 0, minutes: 0 } + entry.calls++ + entry.minutes += (c.duration_seconds || 0) / 60 + dailyMap.set(day, entry) + } + const dailyVolumes = Array.from(dailyMap.entries()) + .map(([date, v]) => ({ date, ...v })) + .sort((a, b) => a.date.localeCompare(b.date)) + + // --- Hourly latency --- + const hourlyMap = new Map< + number, + { totalLat: number; count: number } + >() + for (const c of logs) { + if (c.avg_latency == null) continue + const hour = new Date(c.call_started_at).getHours() + const entry = hourlyMap.get(hour) || { totalLat: 0, count: 0 } + entry.totalLat += c.avg_latency + entry.count++ + hourlyMap.set(hour, entry) + } + const hourlyLatency = Array.from(hourlyMap.entries()) + .map(([hour, v]) => ({ + hour: `${hour}:00`, + avg_latency: v.count > 0 ? v.totalLat / v.count : 0, + })) + .sort((a, b) => parseInt(a.hour) - parseInt(b.hour)) + + // --- Per-call breakdown for table --- + const turnsBySession = new Map() + for (const m of metricsLogs) { + const sid = m.session_id + if (!turnsBySession.has(sid)) turnsBySession.set(sid, []) + turnsBySession.get(sid)!.push(m) + } + + const callBreakdown = logs.map((c) => ({ + id: c.id, + started_at: c.call_started_at, + duration_seconds: c.duration_seconds, + avg_latency: c.avg_latency, + ended_reason: c.call_ended_reason, + customer_number: c.customer_number, + turn_count: turnsBySession.get(c.id)?.length || 0, + turns: (turnsBySession.get(c.id) || []).map((t) => ({ + turn_id: t.turn_id, + user_transcript: t.user_transcript, + agent_response: t.agent_response, + stt_duration: t.stt_metrics?.duration ?? null, + llm_ttft: t.llm_metrics?.ttft ?? null, + tts_ttfb: t.tts_metrics?.ttfb ?? null, + eou_delay: t.eou_metrics?.end_of_utterance_delay ?? null, + created_at: t.created_at, + })), + })) + + return NextResponse.json({ + summary: { + total_calls: totalCalls, + total_minutes: Math.round(totalMinutes * 10) / 10, + avg_latency: avgLatency != null ? Math.round(avgLatency * 1000) / 1000 : null, + p95_latency: p95Latency != null ? Math.round(p95Latency * 1000) / 1000 : null, + success_rate: successRate, + avg_stt_duration: avg(sttDurations), + avg_llm_ttft: avg(llmTtfts), + avg_tts_ttfb: avg(ttsTtfbs), + avg_eou_delay: avg(eouDelays), + estimated_tts: estimatedTts, + }, + daily_volumes: dailyVolumes, + hourly_latency: hourlyLatency, + calls: callBreakdown, + date_range: { from: dateFrom, to: dateTo }, + }) + } catch (err: any) { + console.error('[pipeline-analytics]', err) + return NextResponse.json( + { error: err.message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/components/analytics/PipelineAnalytics.tsx b/src/components/analytics/PipelineAnalytics.tsx new file mode 100644 index 0000000..392bcd8 --- /dev/null +++ b/src/components/analytics/PipelineAnalytics.tsx @@ -0,0 +1,809 @@ +// components/analytics/PipelineAnalytics.tsx +'use client' + +import React, { useState, useMemo } from 'react' +import { + Phone, + Clock, + CheckCircle, + Lightning, + Microphone, + Brain, + SpeakerHigh, + Waveform, + ChartBar, + CaretDown, + CaretUp, + X, +} from 'phosphor-react' +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Area, + AreaChart, +} from 'recharts' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + usePipelineAnalytics, + type PipelineSummary, + type CallBreakdown, + type TurnBreakdown, +} from '@/hooks/usePipelineAnalytics' +import { useMobile } from '@/hooks/use-mobile' + +// ─── Helpers ──────────────────────────────────────────────────── + +function fmtMs(seconds: number | null | undefined): string { + if (seconds == null) return '—' + return `${(seconds * 1000).toFixed(0)}ms` +} + +function fmtLatency(seconds: number | null | undefined): string { + if (seconds == null) return '—' + return seconds >= 1 ? `${seconds.toFixed(2)}s` : `${(seconds * 1000).toFixed(0)}ms` +} + +function fmtDuration(seconds: number | null | undefined): string { + if (seconds == null) return '—' + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + return m > 0 ? `${m}m ${s}s` : `${s}s` +} + +function fmtPhone(phone: string): string { + if (!phone) return '—' + if (phone.length > 7) return `***${phone.slice(-4)}` + return phone +} + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }) +} + +// ─── Date Range Selector ──────────────────────────────────────── + +const DATE_RANGES = [ + { label: 'Last 24h', value: '1d' }, + { label: 'Last 7 days', value: '7d' }, + { label: 'Last 30 days', value: '30d' }, + { label: 'Last 90 days', value: '90d' }, +] as const + +function getDateRange(period: string): { from: string; to: string } { + const to = new Date() + const from = new Date() + const days = period === '1d' ? 1 : period === '7d' ? 7 : period === '30d' ? 30 : 90 + from.setDate(from.getDate() - days) + return { + from: from.toISOString().split('T')[0], + to: to.toISOString().split('T')[0], + } +} + +function PeriodSelector({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) { + return ( +
+ {DATE_RANGES.map((r) => ( + + ))} +
+ ) +} + +// ─── KPI Card ─────────────────────────────────────────────────── + +function KpiCard({ + title, + value, + subtitle, + icon: Icon, + iconClassName, +}: { + title: string + value: string | number + subtitle?: string + icon: React.ElementType + iconClassName?: string +}) { + return ( + + +
+
+ +
+
+

+ {title} +

+

{value}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+
+
+ ) +} + +// ─── Pipeline Latency Card ────────────────────────────────────── + +function PipelineCard({ + title, + value, + subtitle, + icon: Icon, + color, +}: { + title: string + value: string + subtitle: string + icon: React.ElementType + color: string +}) { + const colorMap: Record = { + blue: 'text-blue-500', + purple: 'text-purple-500', + emerald: 'text-emerald-500', + amber: 'text-amber-500', + } + + return ( + + +
+ + + {title} + +
+

{value}

+

{subtitle}

+
+
+ ) +} + +// ─── Call Detail Slide-over ───────────────────────────────────── + +function CallDetailPanel({ + call, + onClose, +}: { + call: CallBreakdown + onClose: () => void +}) { + return ( +
+
+
+
+

Call Details

+ +
+ +
+ {/* Call summary */} +
+
+

+ Duration +

+

+ {fmtDuration(call.duration_seconds)} +

+
+
+

+ Avg Latency +

+

+ {fmtLatency(call.avg_latency)} +

+
+
+ + {/* Per-turn breakdown */} +
+

+ Turn-by-turn metrics ({call.turns.length}) +

+
+ {call.turns.map((turn) => ( + + ))} + {call.turns.length === 0 && ( +

+ No turn data available +

+ )} +
+
+
+
+
+ ) +} + +function TurnCard({ + turn, + callAvgLatency, +}: { + turn: TurnBreakdown + callAvgLatency: number | null +}) { + const hasMetrics = turn.stt_duration != null || turn.llm_ttft != null + // Estimate TTS when direct measurement isn't available + const estTts = + turn.stt_duration != null && turn.llm_ttft != null && callAvgLatency != null + ? Math.max(0, callAvgLatency - turn.stt_duration - turn.llm_ttft) + : null + + return ( +
+
+ + {turn.turn_id.replace(/_/g, ' ').toUpperCase()} + + + {fmtTime(turn.created_at)} + +
+ + {turn.user_transcript && ( +

+ User: {turn.user_transcript} +

+ )} + {turn.agent_response && ( +

+ Agent:{' '} + {turn.agent_response.length > 120 + ? turn.agent_response.slice(0, 120) + '…' + : turn.agent_response} +

+ )} + + {hasMetrics && ( +
+ {turn.stt_duration != null && ( + + )} + {turn.llm_ttft != null && ( + + )} + {turn.eou_delay != null && ( + + )} + {turn.tts_ttfb != null && ( + + )} + {turn.tts_ttfb == null && estTts != null && estTts > 0 && ( + + )} +
+ )} +
+ ) +} + +function MetricBadge({ + icon: Icon, + label, + value, + color, +}: { + icon: React.ElementType + label: string + value: string + color: string +}) { + return ( +
+ + {label}: + {value} +
+ ) +} + +// ─── Chart Tooltip ────────────────────────────────────────────── + +function ChartTooltipContent({ active, payload, label }: any) { + if (!active || !payload?.length) return null + return ( +
+

{label}

+ {payload.map((p: any) => ( +

+ + {p.name}: + + {typeof p.value === 'number' ? p.value.toFixed(2) : p.value} + +

+ ))} +
+ ) +} + +// ─── Skeleton Loaders ─────────────────────────────────────────── + +function KpiSkeleton() { + return ( + + +
+ +
+ + +
+
+
+
+ ) +} + +function ChartSkeleton() { + return ( + + + + + + + ) +} + +// ─── Main Component ───────────────────────────────────────────── + +interface PipelineAnalyticsProps { + agentId: string + agentName?: string +} + +export function PipelineAnalytics({ agentId, agentName }: PipelineAnalyticsProps) { + const [period, setPeriod] = useState('7d') + const [selectedCall, setSelectedCall] = useState(null) + const [expandedCallId, setExpandedCallId] = useState(null) + const { isMobile } = useMobile() + + const { from, to } = useMemo(() => getDateRange(period), [period]) + + const { data, isLoading, isError } = usePipelineAnalytics({ + agentId, + from, + to, + }) + + const summary = data?.summary + const dailyVolumes = data?.daily_volumes || [] + const hourlyLatency = data?.hourly_latency || [] + const calls = data?.calls || [] + + return ( +
+ {/* Header */} +
+
+

+ Pipeline Analytics +

+

+ + Voice agent performance & latency metrics + {agentName && ( + — {agentName} + )} +

+
+ +
+ + {/* Error banner */} + {isError && ( +
+

+ Failed to load analytics data. Please refresh the page. +

+
+ )} + + {/* KPI Cards */} + {isLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : ( +
+ + + + +
+ )} + + {/* Pipeline Latency Breakdown */} + {!isLoading && summary && ( +
+ + + + +
+ )} + + {/* Charts */} +
+ {isLoading ? ( + <> + + + + ) : ( + <> + {/* Daily Call Volume */} + + + + + Daily Call Volume + + + + {dailyVolumes.length > 0 ? ( + + + + fmtDate(d)} + className="text-xs" + tick={{ fontSize: 11 }} + /> + + } /> + + + + ) : ( +
+ No call data for this period +
+ )} +
+
+ + {/* Hourly Latency Trend */} + + + + + Hourly Latency Trend + + + + {hourlyLatency.length > 0 ? ( + + + + + + + + + + + `${v.toFixed(1)}s`} + tick={{ fontSize: 11 }} + /> + } /> + + + + ) : ( +
+ No latency data for this period +
+ )} +
+
+ + )} +
+ + {/* Call Log Table */} + {!isLoading && ( + + + + + Call Log ({calls.length}) + + + +
+ + + + + + + + + + + + + {calls.map((call) => ( + + + setExpandedCallId( + expandedCallId === call.id ? null : call.id + ) + } + > + + + + + + + + + {/* Expanded turn metrics */} + {expandedCallId === call.id && call.turns.length > 0 && ( + + + + )} + + ))} + {calls.length === 0 && ( + + + + )} + +
TimePhoneDurationAvg LatencyTurnsStatus +
+ {fmtDate(call.started_at)}{' '} + + {fmtTime(call.started_at)} + + + {fmtPhone(call.customer_number)} + + {fmtDuration(call.duration_seconds)} + + {fmtLatency(call.avg_latency)} + {call.turn_count} + + {call.ended_reason?.replace(/_/g, ' ') || 'unknown'} + + +
+ + {expandedCallId === call.id ? ( + + ) : ( + + )} +
+
+
+ {call.turns.map((turn) => ( +
+ + {turn.turn_id} + + {turn.stt_duration != null && ( + + + {fmtMs(turn.stt_duration)} + + )} + {turn.llm_ttft != null && ( + + + {fmtMs(turn.llm_ttft)} + + )} + {turn.tts_ttfb != null && ( + + + {fmtMs(turn.tts_ttfb)} + + )} + {turn.eou_delay != null && ( + + + {fmtMs(turn.eou_delay)} + + )} + + {turn.user_transcript?.slice(0, 60)} + +
+ ))} +
+
+ No calls found for this period +
+
+
+
+ )} + + {/* Call Detail Slide-over */} + {selectedCall && ( + setSelectedCall(null)} + /> + )} +
+ ) +} diff --git a/src/components/analytics/index.ts b/src/components/analytics/index.ts new file mode 100644 index 0000000..90adf64 --- /dev/null +++ b/src/components/analytics/index.ts @@ -0,0 +1 @@ +export { PipelineAnalytics } from './PipelineAnalytics' diff --git a/src/components/shared/SidebarWrapper.tsx b/src/components/shared/SidebarWrapper.tsx index 84be87a..e9c2a18 100644 --- a/src/components/shared/SidebarWrapper.tsx +++ b/src/components/shared/SidebarWrapper.tsx @@ -243,6 +243,7 @@ const sidebarRoutes: SidebarRoute[] = [ { pattern: '/:projectId/agents/:agentId/phone-call-config' }, { pattern: '/:projectId/agents/:agentId/evaluations' }, { pattern: '/:projectId/agents/:agentId/evaluations/:evaluationId' }, + { pattern: '/:projectId/analytics' }, ], getSidebarConfig: (params, context) => { const { projectId, agentId } = params @@ -286,6 +287,13 @@ const sidebarRoutes: SidebarRoute[] = [ icon: 'BarChart', path: `/${projectId}/agents/${agentId}/evaluations`, group: 'Logs' + }, + { + id: 'pipeline-analytics', + name: 'Pipeline Analytics', + icon: 'Activity', + path: `/${projectId}/analytics?agent_id=${agentId}`, + group: 'Logs' } ] diff --git a/src/hooks/usePipelineAnalytics.ts b/src/hooks/usePipelineAnalytics.ts new file mode 100644 index 0000000..b16f897 --- /dev/null +++ b/src/hooks/usePipelineAnalytics.ts @@ -0,0 +1,92 @@ +// hooks/usePipelineAnalytics.ts +'use client' + +import { useQuery } from '@tanstack/react-query' + +export interface PipelineSummary { + total_calls: number + total_minutes: number + avg_latency: number | null + p95_latency: number | null + success_rate: number + avg_stt_duration: number | null + avg_llm_ttft: number | null + avg_tts_ttfb: number | null + avg_eou_delay: number | null + estimated_tts: number | null +} + +export interface DailyVolume { + date: string + calls: number + minutes: number +} + +export interface HourlyLatency { + hour: string + avg_latency: number +} + +export interface TurnBreakdown { + turn_id: string + user_transcript: string | null + agent_response: string | null + stt_duration: number | null + llm_ttft: number | null + tts_ttfb: number | null + eou_delay: number | null + created_at: string +} + +export interface CallBreakdown { + id: string + started_at: string + duration_seconds: number + avg_latency: number | null + ended_reason: string + customer_number: string + turn_count: number + turns: TurnBreakdown[] +} + +export interface PipelineAnalyticsData { + summary: PipelineSummary + daily_volumes: DailyVolume[] + hourly_latency: HourlyLatency[] + calls: CallBreakdown[] + date_range: { from: string; to: string } +} + +interface UsePipelineAnalyticsProps { + agentId: string | undefined + from: string + to: string + enabled?: boolean +} + +export const usePipelineAnalytics = ({ + agentId, + from, + to, + enabled = true, +}: UsePipelineAnalyticsProps) => { + return useQuery({ + queryKey: ['pipeline-analytics', agentId, from, to], + queryFn: async () => { + const params = new URLSearchParams({ + agent_id: agentId!, + from, + to, + }) + const res = await fetch(`/api/analytics/pipeline?${params}`) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `HTTP ${res.status}`) + } + return res.json() + }, + enabled: enabled && !!agentId, + staleTime: 60_000, + refetchInterval: 120_000, + }) +} From f2cf8eb9e44f5c4439b8e25e44252d589518ac31 Mon Sep 17 00:00:00 2001 From: mudassar531 Date: Fri, 10 Apr 2026 11:10:44 +0500 Subject: [PATCH 2/2] fix: production-harden pipeline analytics API route: - Add UUID validation for agent_id - Add date format validation (YYYY-MM-DD regex) - Batch Supabase .in() queries (200 per batch) to avoid URL length limits - Add server-side pagination (page/limit params, default 50, max 100) - Mask phone numbers server-side (not just frontend) - Add isFinite() check on metrics to prevent NaN propagation - Round all metric values to 4 decimal places for consistent precision - Return success_count, failed_count, turn_count in summary Component: - Remove unused imports (LineChart, Line, PipelineSummary) - Add pagination controls (prev/next with showing X-Y of Z) - Reset page on period change to avoid stale state - Add Escape key handler for slide-over panel - Add aria-label and aria-hidden for accessibility Hook: - Add Pagination type interface - Pass page/limit params through to API Database migration: - Add UNIQUE index on (session_id, call_date, call_hour) required for CONCURRENTLY refresh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- database/add_pipeline_analytics_view.sql | 6 +- src/app/api/analytics/pipeline/route.ts | 175 ++++++++++++------ .../analytics/PipelineAnalytics.tsx | 71 ++++++- src/hooks/usePipelineAnalytics.ts | 19 +- 4 files changed, 202 insertions(+), 69 deletions(-) diff --git a/database/add_pipeline_analytics_view.sql b/database/add_pipeline_analytics_view.sql index f7aa6bc..50f5be2 100644 --- a/database/add_pipeline_analytics_view.sql +++ b/database/add_pipeline_analytics_view.sql @@ -42,7 +42,11 @@ JOIN public.soundflare_call_logs cl WHERE cl.call_started_at IS NOT NULL GROUP BY ml.session_id, cl.agent_id, DATE(cl.call_started_at), EXTRACT(HOUR FROM cl.call_started_at); --- 2. Index for fast lookups +-- 2. Unique index (required for CONCURRENTLY refresh) +CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_analytics_unique + ON public.pipeline_analytics_daily (session_id, call_date, call_hour); + +-- 3. Lookup index for fast agent + date queries CREATE INDEX IF NOT EXISTS idx_pipeline_analytics_agent_date ON public.pipeline_analytics_daily (agent_id, call_date); diff --git a/src/app/api/analytics/pipeline/route.ts b/src/app/api/analytics/pipeline/route.ts index 29ee6d6..25e8e0d 100644 --- a/src/app/api/analytics/pipeline/route.ts +++ b/src/app/api/analytics/pipeline/route.ts @@ -2,38 +2,58 @@ import { NextRequest, NextResponse } from 'next/server' import { getSupabaseAdmin } from '@/lib/supabase-server' +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const MAX_CALLS_PER_PAGE = 100 +const SUPABASE_IN_BATCH = 200 // Supabase .in() safe limit + +function isValidUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s) +} + +function maskPhone(phone: string | null): string { + if (!phone) return '' + if (phone.length > 7) return `***${phone.slice(-4)}` + return phone +} + /** - * GET /api/analytics/pipeline?agent_id=&from=&to= + * GET /api/analytics/pipeline?agent_id=&from=&to=&page=&limit= * * Returns pipeline latency analytics: * - Aggregate KPIs (avg latency, P95, STT/LLM/TTS/EOU averages) * - Daily call volumes * - Hourly latency trend - * - Per-call latency breakdown for the call log table + * - Per-call latency breakdown for the call log table (paginated) */ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) const agentId = searchParams.get('agent_id') - const from = searchParams.get('from') // YYYY-MM-DD - const to = searchParams.get('to') // YYYY-MM-DD + const from = searchParams.get('from') + const to = searchParams.get('to') + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1) + const limit = Math.min( + MAX_CALLS_PER_PAGE, + Math.max(1, parseInt(searchParams.get('limit') || '50', 10) || 50) + ) - if (!agentId) { + if (!agentId || !isValidUUID(agentId)) { return NextResponse.json( - { error: 'agent_id is required' }, + { error: 'A valid agent_id (UUID) is required' }, { status: 400 } ) } - const supabase = getSupabaseAdmin() - - // Default date range: last 7 days - const dateTo = to || new Date().toISOString().split('T')[0] + // Validate date format + const dateTo = to && DATE_RE.test(to) ? to : new Date().toISOString().split('T')[0] const dateFrom = - from || - new Date(Date.now() - 7 * 86400000).toISOString().split('T')[0] + from && DATE_RE.test(from) + ? from + : new Date(Date.now() - 7 * 86400000).toISOString().split('T')[0] - // 1. Aggregate KPIs from call_logs + const supabase = getSupabaseAdmin() + + // 1. Fetch all call logs for the period (for aggregate KPIs + charts) const { data: callLogs, error: callError } = await supabase .from('soundflare_call_logs') .select( @@ -42,37 +62,59 @@ export async function GET(request: NextRequest) { .eq('agent_id', agentId) .gte('call_started_at', `${dateFrom} 00:00:00`) .lte('call_started_at', `${dateTo} 23:59:59.999`) - .order('call_started_at', { ascending: true }) + .order('call_started_at', { ascending: false }) if (callError) throw callError - // 2. Per-turn metrics from metrics_logs - const callIds = (callLogs || []).map((c) => c.id) + const logs = callLogs || [] + const totalCalls = logs.length + + // 2. Paginate calls for the table + const offset = (page - 1) * limit + const paginatedCalls = logs.slice(offset, offset + limit) + const paginatedIds = paginatedCalls.map((c) => c.id) + + // 3. Fetch turn-level metrics only for the current page (batched .in()) let metricsLogs: any[] = [] + if (paginatedIds.length > 0) { + for (let i = 0; i < paginatedIds.length; i += SUPABASE_IN_BATCH) { + const batch = paginatedIds.slice(i, i + SUPABASE_IN_BATCH) + const { data: ml, error: mlError } = await supabase + .from('soundflare_metrics_logs') + .select( + 'session_id, turn_id, stt_metrics, llm_metrics, tts_metrics, eou_metrics, user_transcript, agent_response, created_at' + ) + .in('session_id', batch) + .order('created_at', { ascending: true }) + + if (mlError) throw mlError + if (ml) metricsLogs.push(...ml) + } + } - if (callIds.length > 0) { + // 4. Also fetch turn metrics for ALL calls (summary-only fields) + // but only the numeric columns needed for averages + let allTurnMetrics: any[] = [] + const allCallIds = logs.map((c) => c.id) + for (let i = 0; i < allCallIds.length; i += SUPABASE_IN_BATCH) { + const batch = allCallIds.slice(i, i + SUPABASE_IN_BATCH) const { data: ml, error: mlError } = await supabase .from('soundflare_metrics_logs') - .select( - 'session_id, turn_id, stt_metrics, llm_metrics, tts_metrics, eou_metrics, user_transcript, agent_response, created_at' - ) - .in('session_id', callIds) - .order('created_at', { ascending: true }) + .select('stt_metrics, llm_metrics, tts_metrics, eou_metrics') + .in('session_id', batch) if (mlError) throw mlError - metricsLogs = ml || [] + if (ml) allTurnMetrics.push(...ml) } - // --- Compute aggregates --- - const logs = callLogs || [] - const totalCalls = logs.length + // --- Compute aggregates from ALL logs --- const successCalls = logs.filter( - (c) => c.call_ended_reason === 'customer_ended_call' || c.call_ended_reason === 'agent_ended_call' + (c) => + c.call_ended_reason === 'customer_ended_call' || + c.call_ended_reason === 'agent_ended_call' ).length - const totalMinutes = logs.reduce( - (s, c) => s + (c.duration_seconds || 0), - 0 - ) / 60 + const totalMinutes = + logs.reduce((s, c) => s + (c.duration_seconds || 0), 0) / 60 const latencies = logs .map((c) => c.avg_latency) .filter((l): l is number => l != null && l > 0) @@ -82,34 +124,31 @@ export async function GET(request: NextRequest) { : null const p95Latency = latencies.length > 0 - ? latencies.sort((a, b) => a - b)[ - Math.floor(latencies.length * 0.95) - ] + ? latencies.sort((a, b) => a - b)[Math.floor(latencies.length * 0.95)] : null const successRate = totalCalls > 0 ? Math.round((successCalls / totalCalls) * 100) : 0 - // Pipeline averages from turn-level data + // Pipeline averages from ALL turn-level data const sttDurations: number[] = [] const llmTtfts: number[] = [] const ttsTtfbs: number[] = [] const eouDelays: number[] = [] - for (const m of metricsLogs) { + for (const m of allTurnMetrics) { const stt = m.stt_metrics?.duration const llm = m.llm_metrics?.ttft const tts = m.tts_metrics?.ttfb const eou = m.eou_metrics?.end_of_utterance_delay - if (typeof stt === 'number') sttDurations.push(stt) - if (typeof llm === 'number') llmTtfts.push(llm) - if (typeof tts === 'number') ttsTtfbs.push(tts) - if (typeof eou === 'number') eouDelays.push(eou) + if (typeof stt === 'number' && isFinite(stt)) sttDurations.push(stt) + if (typeof llm === 'number' && isFinite(llm)) llmTtfts.push(llm) + if (typeof tts === 'number' && isFinite(tts)) ttsTtfbs.push(tts) + if (typeof eou === 'number' && isFinite(eou)) eouDelays.push(eou) } const avg = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : null - // Estimated TTS: when direct TTS isn't available, approximate from latency const avgStt = avg(sttDurations) const avgLlm = avg(llmTtfts) const avgTts = avg(ttsTtfbs) @@ -130,14 +169,15 @@ export async function GET(request: NextRequest) { dailyMap.set(day, entry) } const dailyVolumes = Array.from(dailyMap.entries()) - .map(([date, v]) => ({ date, ...v })) + .map(([date, v]) => ({ + date, + calls: v.calls, + minutes: Math.round(v.minutes * 10) / 10, + })) .sort((a, b) => a.date.localeCompare(b.date)) // --- Hourly latency --- - const hourlyMap = new Map< - number, - { totalLat: number; count: number } - >() + const hourlyMap = new Map() for (const c of logs) { if (c.avg_latency == null) continue const hour = new Date(c.call_started_at).getHours() @@ -148,12 +188,15 @@ export async function GET(request: NextRequest) { } const hourlyLatency = Array.from(hourlyMap.entries()) .map(([hour, v]) => ({ - hour: `${hour}:00`, - avg_latency: v.count > 0 ? v.totalLat / v.count : 0, + hour: `${String(hour).padStart(2, '0')}:00`, + avg_latency: + v.count > 0 + ? Math.round((v.totalLat / v.count) * 1000) / 1000 + : 0, })) - .sort((a, b) => parseInt(a.hour) - parseInt(b.hour)) + .sort((a, b) => a.hour.localeCompare(b.hour)) - // --- Per-call breakdown for table --- + // --- Per-call breakdown (paginated, phones masked) --- const turnsBySession = new Map() for (const m of metricsLogs) { const sid = m.session_id @@ -161,13 +204,13 @@ export async function GET(request: NextRequest) { turnsBySession.get(sid)!.push(m) } - const callBreakdown = logs.map((c) => ({ + const callBreakdown = paginatedCalls.map((c) => ({ id: c.id, started_at: c.call_started_at, duration_seconds: c.duration_seconds, avg_latency: c.avg_latency, ended_reason: c.call_ended_reason, - customer_number: c.customer_number, + customer_number: maskPhone(c.customer_number), turn_count: turnsBySession.get(c.id)?.length || 0, turns: (turnsBySession.get(c.id) || []).map((t) => ({ turn_id: t.turn_id, @@ -185,18 +228,32 @@ export async function GET(request: NextRequest) { summary: { total_calls: totalCalls, total_minutes: Math.round(totalMinutes * 10) / 10, - avg_latency: avgLatency != null ? Math.round(avgLatency * 1000) / 1000 : null, - p95_latency: p95Latency != null ? Math.round(p95Latency * 1000) / 1000 : null, + success_count: successCalls, + failed_count: totalCalls - successCalls, + avg_latency: + avgLatency != null ? Math.round(avgLatency * 1000) / 1000 : null, + p95_latency: + p95Latency != null ? Math.round(p95Latency * 1000) / 1000 : null, success_rate: successRate, - avg_stt_duration: avg(sttDurations), - avg_llm_ttft: avg(llmTtfts), - avg_tts_ttfb: avg(ttsTtfbs), - avg_eou_delay: avg(eouDelays), - estimated_tts: estimatedTts, + avg_stt_duration: avgStt != null ? Math.round(avgStt * 10000) / 10000 : null, + avg_llm_ttft: avgLlm != null ? Math.round(avgLlm * 10000) / 10000 : null, + avg_tts_ttfb: avgTts != null ? Math.round(avgTts * 10000) / 10000 : null, + avg_eou_delay: avg(eouDelays) != null ? Math.round(avg(eouDelays)! * 10000) / 10000 : null, + estimated_tts: + estimatedTts != null + ? Math.round(estimatedTts * 10000) / 10000 + : null, + turn_count: allTurnMetrics.length, }, daily_volumes: dailyVolumes, hourly_latency: hourlyLatency, calls: callBreakdown, + pagination: { + page, + limit, + total: totalCalls, + total_pages: Math.ceil(totalCalls / limit), + }, date_range: { from: dateFrom, to: dateTo }, }) } catch (err: any) { diff --git a/src/components/analytics/PipelineAnalytics.tsx b/src/components/analytics/PipelineAnalytics.tsx index 392bcd8..44c9323 100644 --- a/src/components/analytics/PipelineAnalytics.tsx +++ b/src/components/analytics/PipelineAnalytics.tsx @@ -1,7 +1,7 @@ // components/analytics/PipelineAnalytics.tsx 'use client' -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo, useEffect, useCallback } from 'react' import { Phone, Clock, @@ -14,11 +14,11 @@ import { ChartBar, CaretDown, CaretUp, + CaretLeft, + CaretRight, X, } from 'phosphor-react' import { - LineChart, - Line, BarChart, Bar, XAxis, @@ -34,7 +34,6 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { usePipelineAnalytics, - type PipelineSummary, type CallBreakdown, type TurnBreakdown, } from '@/hooks/usePipelineAnalytics' @@ -213,9 +212,17 @@ function CallDetailPanel({ call: CallBreakdown onClose: () => void }) { + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [onClose]) + return ( -
-
+
+