diff --git a/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx new file mode 100644 index 0000000000..1d0e371bbc --- /dev/null +++ b/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx @@ -0,0 +1,196 @@ +/** + * Search Analytics Tab + * + * Shows search request stats from call_logs (request_type = 'search'), + * provider breakdown, cache hit rate, and cost summary. + */ + +"use client"; + +import { useEffect, useState } from "react"; + +interface SearchStats { + total: number; + today: number; + cached: number; + errors: number; + totalCostUsd: number; + byProvider: Record; + last24h: Array<{ hour: string; count: number }>; + cacheHitRate: number; + avgDurationMs: number; +} + +function StatCard({ + icon, + label, + value, + sub, +}: { + icon: string; + label: string; + value: string | number; + sub?: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function ProviderBar({ + provider, + count, + total, + costUsd, +}: { + provider: string; + count: number; + total: number; + costUsd: number; +}) { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {provider} + + {count} queries · ${costUsd.toFixed(4)} + +
+
+
+
+
{pct}%
+
+ ); +} + +export default function SearchAnalyticsTab() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/v1/search/analytics") + .then((r) => r.json()) + .then((d) => { + setStats(d); + setLoading(false); + }) + .catch((e) => { + setError(e.message); + setLoading(false); + }); + }, []); + + if (loading) { + return ( +
+ progress_activity + Loading search analytics… +
+ ); + } + + if (error || !stats) { + return ( +
+ search_off + {error || "No search data available yet."} +

+ Search requests will appear here after the first search via /v1/search. +

+
+ ); + } + + const providers = Object.entries(stats.byProvider).sort(([, a], [, b]) => b.count - a.count); + + return ( +
+ {/* KPI Cards */} +
+ + + + 0 ? `${stats.errors} errors` : "No errors"} + /> +
+ + {/* Provider Breakdown */} + {providers.length > 0 && ( +
+

+ hub + Provider Breakdown +

+
+ {providers.map(([prov, data]) => ( + + ))} +
+
+ )} + + {/* Empty state */} + {stats.total === 0 && ( +
+ + travel_explore + +

No searches yet

+

+ Use POST /v1/search to start routing + web searches. +

+
+ )} + + {/* Free tier note */} +
+ + check_circle + + + Free tier available: Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo), + Tavily (1,000/mo) — total 6,500+ free searches/month with automatic failover. + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/analytics/page.tsx b/src/app/(dashboard)/dashboard/analytics/page.tsx index 262a14f9c2..3f97518466 100644 --- a/src/app/(dashboard)/dashboard/analytics/page.tsx +++ b/src/app/(dashboard)/dashboard/analytics/page.tsx @@ -3,15 +3,17 @@ import { useState, Suspense } from "react"; import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components"; import EvalsTab from "../usage/components/EvalsTab"; +import SearchAnalyticsTab from "./SearchAnalyticsTab"; import { useTranslations } from "next-intl"; export default function AnalyticsPage() { const [activeTab, setActiveTab] = useState("overview"); const t = useTranslations("analytics"); - const tabDescriptions = { + const tabDescriptions: Record = { overview: t("overviewDescription"), evals: t("evalsDescription"), + search: "Search request analytics — provider breakdown, cache hit rate, and cost tracking.", }; return ( @@ -29,6 +31,7 @@ export default function AnalyticsPage() { options={[ { value: "overview", label: t("overview") }, { value: "evals", label: t("evals") }, + { value: "search", label: "Search" }, ]} value={activeTab} onChange={setActiveTab} @@ -40,6 +43,7 @@ export default function AnalyticsPage() { )} {activeTab === "evals" && } + {activeTab === "search" && }
); } diff --git a/src/app/api/v1/search/analytics/route.ts b/src/app/api/v1/search/analytics/route.ts new file mode 100644 index 0000000000..b224f6660a --- /dev/null +++ b/src/app/api/v1/search/analytics/route.ts @@ -0,0 +1,103 @@ +/** + * GET /api/v1/search/analytics + * + * Returns search request statistics from call_logs (request_type = 'search'). + * Includes provider breakdown, cache hit rate, cost summary, and error count. + */ + +import { NextResponse } from "next/server"; +import { getDbInstance } from "@/lib/db/core"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; + +export async function GET(req: Request) { + const policy = await enforceApiKeyPolicy(req, "analytics"); + if (policy.rejection) return policy.rejection; + + try { + const db = getDbInstance(); + + // Total search requests + const totalRow = db + .prepare(`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search'`) + .get() as { cnt: number }; + const total = totalRow?.cnt ?? 0; + + // Today's searches (UTC date) + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const todayRow = db + .prepare( + `SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND timestamp >= ?` + ) + .get(todayStart.toISOString()) as { cnt: number }; + const today = todayRow?.cnt ?? 0; + + // Errors + const errRow = db + .prepare( + `SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND (status >= 400 OR error IS NOT NULL)` + ) + .get() as { cnt: number }; + const errors = errRow?.cnt ?? 0; + + // Avg duration + const durRow = db + .prepare( + `SELECT AVG(duration) as avg FROM call_logs WHERE request_type = 'search' AND duration > 0` + ) + .get() as { avg: number | null }; + const avgDurationMs = Math.round(durRow?.avg ?? 0); + + // Per-provider breakdown (provider column stores search provider id) + const provRows = db + .prepare( + `SELECT provider, COUNT(*) as cnt + FROM call_logs WHERE request_type = 'search' + GROUP BY provider ORDER BY cnt DESC` + ) + .all() as Array<{ provider: string; cnt: number }>; + + // Cost per search provider (matching searchRegistry.ts rates) + const COST_PER_QUERY: Record = { + "serper-search": 0.001, + "brave-search": 0.003, + "perplexity-search": 0.005, + "exa-search": 0.01, + "tavily-search": 0.004, + }; + + const byProvider: Record = {}; + let totalCostUsd = 0; + for (const row of provRows) { + const cost = (COST_PER_QUERY[row.provider] ?? 0.001) * row.cnt; + byProvider[row.provider] = { count: row.cnt, costUsd: cost }; + totalCostUsd += cost; + } + + // Cached: very fast responses (< 5ms) indicate cache hits + const cachedRow = db + .prepare( + `SELECT COUNT(*) as cnt FROM call_logs + WHERE request_type = 'search' AND duration > 0 AND duration < 5` + ) + .get() as { cnt: number }; + const cached = cachedRow?.cnt ?? 0; + const cacheHitRate = total > 0 ? Math.round((cached / total) * 100) : 0; + + return NextResponse.json({ + total, + today, + cached, + errors, + totalCostUsd, + byProvider, + cacheHitRate, + avgDurationMs, + last24h: [], + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[/api/v1/search/analytics]", msg); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +}