From 9485366765e10d37b32fa30d7a80487838aeede0 Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Sat, 11 Apr 2026 08:57:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E6=B7=BB=E5=8A=A0=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=83=85=E5=86=B5=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 usage-logger.ts 文件,包含使用情况日志记录的逻辑 - 在多个代理中添加 trackingState 参数以跟踪请求状态 - 记录成功和失败的使用情况事件 - 更新相关类型定义以支持新的日志记录功能 - 配置 wrangler.jsonc 以启用使用情况分析数据集 --- README.md | 2 +- frontend/src/App.tsx | 21 +- frontend/src/api/client.ts | 59 +- .../analytics/BreakdownChartCard.tsx | 110 ++ .../components/analytics/TrendBarChart.tsx | 96 ++ frontend/src/components/layout/AppLayout.tsx | 2 +- frontend/src/components/layout/Sidebar.tsx | 4 + frontend/src/pages/Analytics.tsx | 278 +++++ frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/UsageLogs.tsx | 538 +++++++++ frontend/src/types/index.ts | 135 +++ src/admin/analytics_api.ts | 205 ++++ src/admin/index.ts | 14 + src/admin/token_utils.ts | 78 +- src/analytics/query.ts | 1004 +++++++++++++++++ src/analytics/usage-logger.ts | 275 +++++ src/providers/azure-openai-proxy.ts | 2 + src/providers/azure-openai-responses-proxy.ts | 2 + src/providers/claude-proxy.ts | 2 + src/providers/claude-to-openai-proxy.ts | 2 + src/providers/index.ts | 12 +- src/providers/openai-proxy.ts | 2 + src/providers/openai-responses-proxy.ts | 2 + src/providers/shared/channel-resolver.ts | 64 +- src/providers/shared/provider-registry.ts | 3 +- src/providers/shared/upstream-retry.ts | 17 + type.d.ts | 21 + wrangler.jsonc | 10 +- 28 files changed, 2938 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/analytics/BreakdownChartCard.tsx create mode 100644 frontend/src/components/analytics/TrendBarChart.tsx create mode 100644 frontend/src/pages/Analytics.tsx create mode 100644 frontend/src/pages/UsageLogs.tsx create mode 100644 src/admin/analytics_api.ts create mode 100644 src/analytics/query.ts create mode 100644 src/analytics/usage-logger.ts diff --git a/README.md b/README.md index ab91888..18f604a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 🚀 **基于 Cloudflare Workers**:全球分布式网络、低延迟、高性能 - 🔐 **多渠道**:支持 OpenAI、Gemini、Azure OpenAI、Claude 等多种 AI 服务商 - 🎫 **负载均衡**:支持多KEY/渠道间分配请求,自动重试+自动轮换,确保高可用性 -- 📊 **用量统计***:Analytics Engine 实时统计,精确成本核算与日志回溯 +- 📊 **用量统计**:Analytics Engine 实时统计,支持日志回溯便于成本分析 - 💰 **定价管理**:灵活的模型定价配置 - 🎨 **Web 管理界面**:直观的 Web 界面进行配置管理 - 🧪 **API 测试工具**:内置 API 测试功能,支持实时调试 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index db413f6..082463b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import { AppLayout } from "./components/layout/AppLayout"; import { Toaster } from "./components/ui/toaster"; import { useAuthStore } from "./store/auth"; import { Dashboard } from "./pages/Dashboard"; +import { Analytics } from "./pages/Analytics"; +import { UsageLogs } from "./pages/UsageLogs"; import { Channels } from "./pages/Channels"; import { Tokens } from "./pages/Tokens"; import { Pricing } from "./pages/Pricing"; @@ -42,7 +44,7 @@ function HomeRoute() { } if (isAuthenticated) { - return ; + return ; } return ; @@ -61,6 +63,7 @@ function App() { } /> + } /> } /> + + + + } + /> + + + + } + /> ): string { + const searchParams = new URLSearchParams() + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + searchParams.set(key, String(value)) + } + }) + + const query = searchParams.toString() + return query ? `?${query}` : '' +} + // 统一请求方法 async function request(endpoint: string, options: RequestInit = {}): Promise { const url = `${BASE_URL}${endpoint}` @@ -194,6 +219,38 @@ export const apiClient = { body: JSON.stringify(config), }), + // Analytics APIs + getAnalyticsOverview: (range: AnalyticsRange) => + request>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }), + + getAnalyticsTrend: (range: AnalyticsRange) => + request>(`/api/admin/analytics/trend?range=${encodeURIComponent(range)}`, { method: 'GET' }), + + getAnalyticsBreakdown: (range: AnalyticsRange, dimension: AnalyticsBreakdownDimension) => + request>( + `/api/admin/analytics/breakdown?range=${encodeURIComponent(range)}&dimension=${encodeURIComponent(dimension)}`, + { method: 'GET' } + ), + + getAnalyticsEvents: (range: AnalyticsRange, limit = 40) => + request>( + `/api/admin/analytics/events?range=${encodeURIComponent(range)}&limit=${encodeURIComponent(String(limit))}`, + { method: 'GET' } + ), + + getUsageLogs: (filters: UsageLogFilters) => + request>( + `/api/admin/usage-logs${buildQueryString({ + start: filters.start, + end: filters.end, + dimension: filters.dimension, + keyword: filters.keyword, + result: filters.result, + page: filters.page, + })}`, + { method: 'GET' } + ), + // API Test - 使用自定义 token,不走通用拦截器 testApi: async (endpoint: string, token: string, body: unknown): Promise => { const url = `${BASE_URL}${endpoint}` diff --git a/frontend/src/components/analytics/BreakdownChartCard.tsx b/frontend/src/components/analytics/BreakdownChartCard.tsx new file mode 100644 index 0000000..dd04c8a --- /dev/null +++ b/frontend/src/components/analytics/BreakdownChartCard.tsx @@ -0,0 +1,110 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { AnalyticsBreakdownItem } from "@/types"; +import { cn, formatCurrency } from "@/lib/utils"; + +const formatCompactNumber = (value: number): string => { + return new Intl.NumberFormat("zh-CN", { + notation: value >= 1000 ? "compact" : "standard", + maximumFractionDigits: value >= 1000 ? 1 : 0, + }).format(value); +}; + +const formatPercent = (value: number): string => `${(value * 100).toFixed(1)}%`; + +type BreakdownChartCardProps = { + title: string; + description: string; + items?: AnalyticsBreakdownItem[]; + isLoading?: boolean; + isError?: boolean; + barClassName?: string; + badgeClassName?: string; +}; + +export function BreakdownChartCard({ + title, + description, + items = [], + isLoading, + isError, + barClassName, + badgeClassName, +}: BreakdownChartCardProps) { + const visibleItems = items.slice(0, 6); + const maxCost = Math.max(...visibleItems.map((item) => item.totalCost), 0); + const useCostScale = maxCost > 0; + const maxScaleValue = useCostScale ? Math.max(maxCost, 1) : Math.max(...visibleItems.map((item) => item.requests), 1); + + return ( + + + {title} + {description} + + + {isLoading ? ( + Array.from({ length: 4 }, (_, index) => ( +
+
+
+
+ )) + ) : isError ? ( +
+ 排行数据加载失败 +
+ ) : visibleItems.length === 0 ? ( +
+ 当前时间范围暂无维度排行数据 +
+ ) : ( + visibleItems.map((item, index) => { + const scaleValue = useCostScale ? item.totalCost : item.requests; + const width = scaleValue > 0 ? Math.max((scaleValue / maxScaleValue) * 100, 6) : 0; + + return ( +
+
+
+
+ + {index + 1} + +
+ {item.label} + +
+ 请求 {formatCompactNumber(item.requests)} + 成功率 {formatPercent(item.successRate)} + 输出 {formatCompactNumber(item.completionTokens)} +
+
+
+
+
+
+ {useCostScale ? formatCurrency(item.totalCost) : formatCompactNumber(item.requests)} +
+
{Math.round(item.avgLatencyMs)} ms
+
+
+ +
+
+
+
+ ); + }) + )} + + + ); +} diff --git a/frontend/src/components/analytics/TrendBarChart.tsx b/frontend/src/components/analytics/TrendBarChart.tsx new file mode 100644 index 0000000..5f1f6ab --- /dev/null +++ b/frontend/src/components/analytics/TrendBarChart.tsx @@ -0,0 +1,96 @@ +import { AnalyticsRange, AnalyticsTrendPoint } from "@/types"; +import { formatCurrency } from "@/lib/utils"; + +const formatAxisLabel = (value: string, range: AnalyticsRange): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + if (range === "24h") { + return date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + } + + return date.toLocaleDateString("zh-CN", { + month: "2-digit", + day: "2-digit", + }); +}; + +const formatCompactNumber = (value: number): string => { + return new Intl.NumberFormat("zh-CN", { + notation: value >= 1000 ? "compact" : "standard", + maximumFractionDigits: value >= 1000 ? 1 : 0, + }).format(value); +}; + +export function TrendBarChart({ points, range }: { points: AnalyticsTrendPoint[]; range: AnalyticsRange }) { + const totalRequests = points.reduce((sum, point) => sum + point.requests, 0); + const totalFailures = points.reduce((sum, point) => sum + point.failures, 0); + const totalCost = points.reduce((sum, point) => sum + point.totalCost, 0); + const peakRequests = Math.max(...points.map((point) => point.requests), 1); + const minColumnWidth = range === "90d" ? 28 : range === "30d" ? 34 : range === "24h" ? 42 : 56; + const chartMinWidth = Math.max(points.length * minColumnWidth, 720); + const yTicks = Array.from({ length: 5 }, (_, index) => { + const value = (peakRequests / 4) * (4 - index); + + return { + value, + top: `${index * 25}%`, + }; + }); + + return ( +
+
+
+
+
+ {yTicks.map((tick) => ( +
+ + {formatCompactNumber(Math.round(tick.value))} + + {/*
*/} +
+ ))} +
+ +
+ {points.map((point) => { + const label = formatAxisLabel(point.timestamp, range); + const barHeight = Math.max((point.requests / peakRequests) * 100, point.requests > 0 ? 6 : 0); + + return ( +
+
+
+
+
{label}
+
{formatCompactNumber(point.requests)}
+
+ {formatCurrency(point.totalCost)} +
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index c4add3a..9fa7484 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -41,7 +41,7 @@ export function AppLayout({ children }: AppLayoutProps) { setAdminToken('') setIsMobileNavOpen(false) addToast('登录成功', 'success') - navigate('/channels', { replace: true }) + navigate('/dashboard', { replace: true }) } catch (error) { setAuthError(error instanceof Error ? error.message : '管理员令牌无效') } finally { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 971d86a..5c09a8d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -4,6 +4,8 @@ import { Link as LinkIcon, Key, DollarSign, + BarChart3, + FileText, TestTube2, GitBranch, Moon, @@ -24,6 +26,8 @@ interface NavItem { } const navItems: NavItem[] = [ + { title: "总览看板", href: "/dashboard", icon: BarChart3 }, + { title: "使用日志", href: "/usage-logs", icon: FileText }, { title: "渠道管理", href: "/channels", icon: LinkIcon }, { title: "令牌管理", href: "/tokens", icon: Key }, { title: "定价管理", href: "/pricing", icon: DollarSign }, diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx new file mode 100644 index 0000000..96e844c --- /dev/null +++ b/frontend/src/pages/Analytics.tsx @@ -0,0 +1,278 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/api/client"; +import { + AnalyticsOverviewData, + AnalyticsTrendData, + AnalyticsBreakdownData, + AnalyticsRange, + AnalyticsBreakdownDimension, +} from "@/types"; +import { BreakdownChartCard } from "@/components/analytics/BreakdownChartCard"; +import { TrendBarChart } from "@/components/analytics/TrendBarChart"; +import { PageContainer } from "@/components/ui/page-container"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Select } from "@/components/ui/select"; +import { cn, formatCurrency } from "@/lib/utils"; +import { Activity, CircleDollarSign, Clock3, DatabaseZap, RefreshCw, ShieldCheck } from "lucide-react"; +import { useState } from "react"; + +const RANGE_OPTIONS: Array<{ value: AnalyticsRange; label: string }> = [ + { value: "24h", label: "24 小时" }, + { value: "7d", label: "7 天" }, + { value: "30d", label: "30 天" }, + { value: "90d", label: "90 天" }, +]; + +const BREAKDOWN_CHARTS: Array<{ + dimension: AnalyticsBreakdownDimension; + title: string; + description: string; + barClassName: string; + badgeClassName: string; +}> = [ + { + dimension: "token", + title: "令牌排行", + description: "按成本优先排序,识别最活跃的访问凭证。", + barClassName: "from-sky-500 to-cyan-400", + badgeClassName: "bg-sky-500/12 text-sky-600 dark:text-sky-400", + }, + { + dimension: "channel", + title: "渠道排行", + description: "对比不同渠道承载的请求与成本消耗。", + barClassName: "from-emerald-500 to-teal-400", + badgeClassName: "bg-emerald-500/12 text-emerald-600 dark:text-emerald-400", + }, + { + dimension: "model", + title: "模型排行", + description: "看清哪些模型是主要成本与流量入口。", + barClassName: "from-amber-500 to-orange-400", + badgeClassName: "bg-amber-500/12 text-amber-700 dark:text-amber-400", + }, + { + dimension: "provider", + title: "服务商排行", + description: "衡量各上游服务商的占比与稳定性表现。", + barClassName: "from-rose-500 to-pink-400", + badgeClassName: "bg-rose-500/12 text-rose-600 dark:text-rose-400", + }, +]; + +const formatCompactNumber = (value: number): string => { + return new Intl.NumberFormat("zh-CN", { + notation: value >= 1000 ? "compact" : "standard", + maximumFractionDigits: value >= 1000 ? 1 : 0, + }).format(value); +}; + +const formatPercent = (value: number): string => `${(value * 100).toFixed(1)}%`; + +const formatDuration = (value: number): string => { + if (value >= 1000) { + return `${(value / 1000).toFixed(1)} s`; + } + return `${Math.round(value)} ms`; +}; + +const EmptyState = ({ title, description }: { title: string; description: string }) => { + return ( +
+

{title}

+

{description}

+
+ ); +}; + +export function Analytics() { + const [range, setRange] = useState("7d"); + + const overviewQuery = useQuery({ + queryKey: ["analytics", "overview", range], + queryFn: async () => { + const response = await apiClient.getAnalyticsOverview(range); + return response.data as AnalyticsOverviewData; + }, + }); + + const trendQuery = useQuery({ + queryKey: ["analytics", "trend", range], + queryFn: async () => { + const response = await apiClient.getAnalyticsTrend(range); + return response.data as AnalyticsTrendData; + }, + }); + + const tokenBreakdownQuery = useQuery({ + queryKey: ["analytics", "breakdown", range, "token"], + queryFn: async () => { + const response = await apiClient.getAnalyticsBreakdown(range, "token"); + return response.data as AnalyticsBreakdownData; + }, + }); + + const channelBreakdownQuery = useQuery({ + queryKey: ["analytics", "breakdown", range, "channel"], + queryFn: async () => { + const response = await apiClient.getAnalyticsBreakdown(range, "channel"); + return response.data as AnalyticsBreakdownData; + }, + }); + + const modelBreakdownQuery = useQuery({ + queryKey: ["analytics", "breakdown", range, "model"], + queryFn: async () => { + const response = await apiClient.getAnalyticsBreakdown(range, "model"); + return response.data as AnalyticsBreakdownData; + }, + }); + + const providerBreakdownQuery = useQuery({ + queryKey: ["analytics", "breakdown", range, "provider"], + queryFn: async () => { + const response = await apiClient.getAnalyticsBreakdown(range, "provider"); + return response.data as AnalyticsBreakdownData; + }, + }); + + const breakdownDataMap: Record = { + token: tokenBreakdownQuery.data, + channel: channelBreakdownQuery.data, + model: modelBreakdownQuery.data, + provider: providerBreakdownQuery.data, + }; + + const breakdownStateMap: Record = { + token: { isLoading: tokenBreakdownQuery.isLoading, isError: tokenBreakdownQuery.isError }, + channel: { isLoading: channelBreakdownQuery.isLoading, isError: channelBreakdownQuery.isError }, + model: { isLoading: modelBreakdownQuery.isLoading, isError: modelBreakdownQuery.isError }, + provider: { isLoading: providerBreakdownQuery.isLoading, isError: providerBreakdownQuery.isError }, + }; + + const isFetching = + overviewQuery.isFetching || + trendQuery.isFetching || + tokenBreakdownQuery.isFetching || + channelBreakdownQuery.isFetching || + modelBreakdownQuery.isFetching || + providerBreakdownQuery.isFetching; + + const overview = overviewQuery.data; + const trend = trendQuery.data; + + const handleRefresh = async () => { + await Promise.all([ + overviewQuery.refetch(), + trendQuery.refetch(), + tokenBreakdownQuery.refetch(), + channelBreakdownQuery.refetch(), + modelBreakdownQuery.refetch(), + providerBreakdownQuery.refetch(), + ]); + }; + + return ( + +
+ +
+ +
+ } + > +
+
+ + + 请求总量 + {formatCompactNumber(overview?.totals.requests || 0)} + + + 成功 {formatCompactNumber(overview?.totals.successes || 0)} + + + + + + + 成功率 + {formatPercent(overview?.totals.successRate || 0)} + + + 失败 {formatCompactNumber(overview?.totals.failures || 0)} + + + + + + + 输入 Tokens + {formatCompactNumber(overview?.totals.promptTokens || 0)} + + + 总计 {formatCompactNumber(overview?.totals.totalTokens || 0)} + + + + + + + 输出 Tokens + {formatCompactNumber(overview?.totals.completionTokens || 0)} + + + {formatCurrency(overview?.totals.totalCost || 0)} + + + + + + + 平均延迟 + {formatDuration(overview?.totals.avgLatencyMs || 0)} + + + {RANGE_OPTIONS.find((option) => option.value === range)?.label} + + + +
+ + {!trend || trend.points.length === 0 ? ( + + ) : ( + + )} + +
+ {BREAKDOWN_CHARTS.map((chart) => ( + + ))} +
+
+ + ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 506011f..94fd708 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -24,7 +24,7 @@ const features = [ { icon: BarChart3, title: "用量追踪", - description: "Analytics Engine 实时统计,精确成本核算与日志回溯。", + description: "Analytics Engine 实时统计,支持日志回溯便于成本分析。", gradient: "from-amber-500/10 to-orange-500/10", iconBg: "from-amber-500 to-orange-500", delay: "100ms", diff --git a/frontend/src/pages/UsageLogs.tsx b/frontend/src/pages/UsageLogs.tsx new file mode 100644 index 0000000..4bac98b --- /dev/null +++ b/frontend/src/pages/UsageLogs.tsx @@ -0,0 +1,538 @@ +import { startTransition, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/api/client"; +import { AnalyticsEventItem, UsageLogFilterDimension, UsageLogFilters, UsageLogSearchData } from "@/types"; +import { PageContainer } from "@/components/ui/page-container"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; +import { cn, formatCurrency } from "@/lib/utils"; +import { Eye, RefreshCw, RotateCcw, Search } from "lucide-react"; + +type UsageLogFilterState = { + start: string; + end: string; + dimension: UsageLogFilterDimension; + keyword: string; + result: "all" | "success" | "failure"; +}; + +const FILTER_DIMENSIONS: Array<{ value: UsageLogFilterDimension; label: string }> = [ + { value: "token", label: "令牌名称" }, + { value: "channel", label: "渠道标识" }, + { value: "model", label: "请求模型" }, + { value: "provider", label: "服务商类型" }, + { value: "route", label: "接口路由" }, + { value: "requestId", label: "Request ID" }, + { value: "traceId", label: "Trace ID" }, + { value: "clientIp", label: "请求 IP" }, + { value: "userAgent", label: "User-Agent" }, + { value: "country", label: "国家地区" }, + { value: "region", label: "省州 / Region" }, + { value: "city", label: "城市 / City" }, + { value: "colo", label: "边缘节点 / Colo" }, + { value: "timezone", label: "时区 / Timezone" }, + { value: "result", label: "结果" }, + { value: "errorCode", label: "错误码" }, + { value: "errorSummary", label: "错误摘要" }, +]; + +const RESULT_OPTIONS: Array<{ value: "all" | "success" | "failure"; label: string }> = [ + { value: "all", label: "全部结果" }, + { value: "success", label: "仅成功" }, + { value: "failure", label: "仅失败" }, +]; + +const PAGINATION_WINDOW_SIZE = 5; + +const toDateTimeLocalValue = (date: Date): string => { + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 16); +}; + +const createFilterPreset = (hoursBack: number): UsageLogFilterState => { + const end = new Date(); + const start = new Date(end.getTime() - hoursBack * 60 * 60 * 1000); + + return { + start: toDateTimeLocalValue(start), + end: toDateTimeLocalValue(end), + dimension: "token", + keyword: "", + result: "all", + }; +}; + +const formatDateTime = (value: string): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +const formatDuration = (value: number): string => { + if (value >= 1000) { + return `${(value / 1000).toFixed(1)} s`; + } + + return `${Math.round(value)} ms`; +}; + +const formatCompactNumber = (value: number): string => { + return new Intl.NumberFormat("zh-CN", { + notation: value >= 1000 ? "compact" : "standard", + maximumFractionDigits: value >= 1000 ? 1 : 0, + }).format(value); +}; + +const buildSearchParams = (filters: UsageLogFilterState, page: number): UsageLogFilters => { + return { + start: filters.start ? new Date(filters.start).toISOString() : undefined, + end: filters.end ? new Date(filters.end).toISOString() : undefined, + dimension: filters.dimension, + keyword: filters.keyword.trim() || undefined, + result: filters.result, + page, + }; +}; + +const ClientSummary = ({ item }: { item: AnalyticsEventItem }) => { + const locationParts = [item.country, item.region, item.city].filter(Boolean); + + return ( +
+
{item.clientIp || "--"}
+
+ {locationParts.length > 0 ? locationParts.join(" / ") : "未知位置"} +
+
+ {item.colo || "--"} {item.timezone ? `· ${item.timezone}` : ""} +
+
+ ); +}; + +const DetailField = ({ label, value, mono = false }: { label: string; value: string | number; mono?: boolean }) => { + const text = String(value || "--"); + + return ( +
+
{label}
+
{text}
+
+ ); +}; + +export function UsageLogs() { + const defaultFilters = useMemo(() => createFilterPreset(24), []); + const [draftFilters, setDraftFilters] = useState(defaultFilters); + const [appliedFilters, setAppliedFilters] = useState(defaultFilters); + const [currentPage, setCurrentPage] = useState(1); + const [selectedItem, setSelectedItem] = useState(null); + + const logsQuery = useQuery({ + queryKey: ["usage-logs", appliedFilters, currentPage], + queryFn: async () => { + const response = await apiClient.getUsageLogs(buildSearchParams(appliedFilters, currentPage)); + return response.data as UsageLogSearchData; + }, + }); + + const activePage = logsQuery.data?.page ?? currentPage; + const totalPages = logsQuery.data?.totalPages ?? 0; + const totalItems = logsQuery.data?.total ?? 0; + const pageSize = logsQuery.data?.pageSize ?? 50; + const currentStart = totalItems > 0 ? (activePage - 1) * pageSize + 1 : 0; + const currentEnd = totalItems > 0 ? currentStart + (logsQuery.data?.count ?? 0) - 1 : 0; + const visiblePages = useMemo(() => { + if (totalPages <= 1) { + return totalPages === 1 ? [1] : []; + } + + const halfWindow = Math.floor(PAGINATION_WINDOW_SIZE / 2); + let start = Math.max(1, activePage - halfWindow); + let end = Math.min(totalPages, start + PAGINATION_WINDOW_SIZE - 1); + + start = Math.max(1, end - PAGINATION_WINDOW_SIZE + 1); + + return Array.from({ length: end - start + 1 }, (_, index) => start + index); + }, [activePage, totalPages]); + + const applyPreset = (hoursBack: number) => { + const preset = createFilterPreset(hoursBack); + setDraftFilters((current) => ({ + ...preset, + dimension: current.dimension, + keyword: current.keyword, + result: current.result, + })); + }; + + const handleSearch = () => { + startTransition(() => { + setCurrentPage(1); + setAppliedFilters({ ...draftFilters }); + }); + }; + + const handleReset = () => { + const preset = createFilterPreset(24); + setDraftFilters(preset); + startTransition(() => { + setCurrentPage(1); + setAppliedFilters(preset); + }); + }; + + const handlePageChange = (page: number) => { + if (page < 1 || page === activePage || (totalPages > 0 && page > totalPages)) { + return; + } + + startTransition(() => { + setCurrentPage(page); + }); + }; + + return ( + + +
+ } + > +
+ {/* + +
+
+
+ + Diagnostics +
+

面向排障的请求日志视图

+

+ 记录 request id、trace id、错误摘要、IP、UA 与地理位置,适合定位某一批异常设备或单个失败链路。 +

+
+
+ + + +
+
+
+
*/} + + + + {logsQuery.data?.compatibilityWarning && ( + + {logsQuery.data.compatibilityWarning} + + )} +
+
+ + setDraftFilters((current) => ({ ...current, start: event.target.value }))} + /> +
+
+ + setDraftFilters((current) => ({ ...current, end: event.target.value }))} + /> +
+
+ + +
+
+ + setDraftFilters((current) => ({ ...current, keyword: event.target.value }))} + placeholder="支持 ILIKE 模糊匹配" + /> +
+
+ + +
+ +
+
+ + +
+
+
+
+ + + + 日志结果 + 日志可能存在 30 秒左右的延迟 + + + {logsQuery.isLoading ? ( +
日志加载中...
+ ) : logsQuery.data?.items.length ? ( +
+ + + + + + + + + + + + + + {logsQuery.data.items.map((item) => ( + + + + + + + + + + + + + + + ))} + +
时间模型 / 令牌端点 / 渠道结果 / 状态方式 / 用时资源消耗详情
+
{formatDateTime(item.timestamp)}
+
{item.requestId || "--"}
+
+
{item.requestedModel || "--"}
+
{item.tokenName || "未命名令牌"}
+
+
{item.providerType || "--"}
+
{item.channelKey || "--"}
+
+
+ + {item.result === "success" ? "成功" : "失败"} + +
+
+ {item.upstreamStatus || "--"} · 重试 {item.retryCount} +
+
+
+ {item.streamMode === "stream" ? "流式" : "非流式"} +
+
{formatDuration(item.latencyMs)}
+
+ {/*
{formatCompactNumber(item.totalTokens)} tokens
*/} +
+ 输入 {formatCompactNumber(item.promptTokens)} / 输出{" "} + {formatCompactNumber(item.completionTokens)} +
+
{formatCurrency(item.totalCost)}
+
+ +
+
+
+ {totalItems > 0 ? `显示第 ${currentStart}-${currentEnd} 条,共 ${totalItems} 条` : "暂无记录"} +
+
+ + {visiblePages.map((page) => ( + + ))} + +
+
+
+ ) : ( +
+

没有匹配的日志记录

+

调整起止时间、维度或关键字后重试。

+
+ )} +
+
+
+ + !open && setSelectedItem(null)}> + + + 使用日志详情 + 查看本次请求的完整上下文、客户端信息、错误摘要与资源消耗。 + + + {selectedItem && ( +
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ +
+ +
+ + +
+ +
+ + + + +
+
+ )} +
+
+ + ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 473b3b3..b3b9ed7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -44,6 +44,141 @@ export interface PricingModel { export type PricingConfig = Record +export type AnalyticsRange = '24h' | '7d' | '30d' | '90d' +export type AnalyticsBreakdownDimension = 'token' | 'channel' | 'model' | 'provider' +export type UsageLogFilterDimension = + | 'route' + | 'token' + | 'channel' + | 'model' + | 'provider' + | 'requestId' + | 'traceId' + | 'clientIp' + | 'userAgent' + | 'country' + | 'region' + | 'city' + | 'colo' + | 'timezone' + | 'result' + | 'errorCode' + | 'errorSummary' + +export interface AnalyticsOverviewData { + range: AnalyticsRange + totals: { + requests: number + successes: number + failures: number + successRate: number + totalCost: number + totalTokens: number + promptTokens: number + completionTokens: number + avgLatencyMs: number + } +} + +export interface AnalyticsTrendPoint { + timestamp: string + requests: number + successes: number + failures: number + successRate: number + totalCost: number +} + +export interface AnalyticsTrendData { + range: AnalyticsRange + bucket: string + points: AnalyticsTrendPoint[] +} + +export interface AnalyticsBreakdownItem { + label: string + requests: number + successes: number + failures: number + successRate: number + totalCost: number + promptTokens: number + completionTokens: number + avgLatencyMs: number +} + +export interface AnalyticsBreakdownData { + range: AnalyticsRange + dimension: AnalyticsBreakdownDimension + items: AnalyticsBreakdownItem[] +} + +export interface AnalyticsEventItem { + timestamp: string + routeId: string + tokenName: string + channelKey: string + providerType: string + requestedModel: string + upstreamModel: string + result: string + streamMode: string + errorCode: string + statusFamily: string + requestId: string + traceId: string + clientIp: string + userAgent: string + country: string + region: string + city: string + colo: string + timezone: string + errorSummary: string + promptTokens: number + completionTokens: number + cachedTokens: number + totalTokens: number + totalCost: number + latencyMs: number + retryCount: number + upstreamStatus: number +} + +export interface AnalyticsEventsData { + range: AnalyticsRange + sampled: boolean + compatibilityWarning?: string + items: AnalyticsEventItem[] +} + +export interface UsageLogFilters { + start?: string + end?: string + dimension?: UsageLogFilterDimension + keyword?: string + result?: 'all' | 'success' | 'failure' + page?: number +} + +export interface UsageLogSearchData { + sampled: boolean + dimension: UsageLogFilterDimension + keyword: string + result: 'all' | 'success' | 'failure' + startTime: string + endTime: string + compatibilityWarning?: string + page: number + pageSize: number + total: number + totalPages: number + count: number + hasPrevPage: boolean + hasNextPage: boolean + items: AnalyticsEventItem[] +} + export interface ApiResponse { data?: T error?: string diff --git a/src/admin/analytics_api.ts b/src/admin/analytics_api.ts new file mode 100644 index 0000000..1d3131c --- /dev/null +++ b/src/admin/analytics_api.ts @@ -0,0 +1,205 @@ +import { Context } from "hono"; +import { OpenAPIRoute } from "chanfana"; +import { z } from "zod"; + +import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; +import { + AnalyticsQueryValidationError, + queryUsageOverview, + queryUsageTrend, + queryUsageBreakdown, + queryUsageEvents, + queryUsageLogRecords, +} from "../analytics/query"; + +const rangeSchema = z.enum(["24h", "7d", "30d", "90d"]).optional(); + +const toErrorResponse = ( + c: Context, + error: unknown, + fallbackMessage: string +) => { + const message = error instanceof Error ? error.message : fallbackMessage; + const status = error instanceof AnalyticsQueryValidationError ? 400 : 500; + return c.text(message, status); +}; + +export class AnalyticsOverviewEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Get usage analytics overview", + request: { + query: z.object({ + range: rangeSchema, + }), + }, + responses: { + ...CommonSuccessfulResponse(z.any()), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + try { + const result = await queryUsageOverview(c, c.req.query("range")); + return { + success: true, + data: result, + } as CommonResponse; + } catch (error) { + return toErrorResponse(c, error, "Failed to query analytics overview"); + } + } +} + +export class AnalyticsTrendEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Get usage analytics time series", + request: { + query: z.object({ + range: rangeSchema, + }), + }, + responses: { + ...CommonSuccessfulResponse(z.any()), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + try { + const result = await queryUsageTrend(c, c.req.query("range")); + return { + success: true, + data: result, + } as CommonResponse; + } catch (error) { + return toErrorResponse(c, error, "Failed to query analytics trend"); + } + } +} + +export class AnalyticsBreakdownEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Get usage analytics breakdown", + request: { + query: z.object({ + range: rangeSchema, + dimension: z.enum(["token", "channel", "model", "provider"]).optional(), + }), + }, + responses: { + ...CommonSuccessfulResponse(z.any()), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + try { + const result = await queryUsageBreakdown( + c, + c.req.query("range"), + c.req.query("dimension") + ); + return { + success: true, + data: result, + } as CommonResponse; + } catch (error) { + return toErrorResponse(c, error, "Failed to query analytics breakdown"); + } + } +} + +export class AnalyticsEventsEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Get recent usage analytics samples", + request: { + query: z.object({ + range: rangeSchema, + limit: z.coerce.number().min(1).max(100).optional(), + }), + }, + responses: { + ...CommonSuccessfulResponse(z.any()), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + try { + const result = await queryUsageEvents( + c, + c.req.query("range"), + c.req.query("limit") + ); + return { + success: true, + data: result, + } as CommonResponse; + } catch (error) { + return toErrorResponse(c, error, "Failed to query analytics events"); + } + } +} + +export class UsageLogSearchEndpoint extends OpenAPIRoute { + schema = { + tags: ["Admin API"], + summary: "Search usage logs with custom filters", + request: { + query: z.object({ + start: z.string().optional(), + end: z.string().optional(), + dimension: z.enum([ + "route", + "token", + "channel", + "model", + "provider", + "requestId", + "traceId", + "clientIp", + "userAgent", + "country", + "region", + "city", + "colo", + "timezone", + "result", + "errorCode", + "errorSummary", + ]).optional(), + keyword: z.string().optional(), + result: z.enum(["all", "success", "failure"]).optional(), + page: z.coerce.number().int().min(1).max(1000).optional(), + }), + }, + responses: { + ...CommonSuccessfulResponse(z.any()), + ...CommonErrorResponse, + }, + }; + + async handle(c: Context) { + try { + const result = await queryUsageLogRecords(c, { + start: c.req.query("start"), + end: c.req.query("end"), + dimension: c.req.query("dimension"), + keyword: c.req.query("keyword"), + result: c.req.query("result"), + page: c.req.query("page"), + }); + return { + success: true, + data: result, + } as CommonResponse; + } catch (error) { + return toErrorResponse(c, error, "Failed to query usage logs"); + } + } +} diff --git a/src/admin/index.ts b/src/admin/index.ts index 6406566..cf43caa 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -11,6 +11,13 @@ import { import { PricingGetEndpoint, PricingUpdateEndpoint } from "./pricing_api" +import { + AnalyticsOverviewEndpoint, + AnalyticsTrendEndpoint, + AnalyticsBreakdownEndpoint, + AnalyticsEventsEndpoint, + UsageLogSearchEndpoint, +} from "./analytics_api" const app = new Hono() export const api = fromHono(app) @@ -47,3 +54,10 @@ api.delete("/api/admin/token/:key", TokenDeleteEndpoint) // Pricing management routes api.get("/api/admin/pricing", PricingGetEndpoint) api.post("/api/admin/pricing", PricingUpdateEndpoint) + +// Analytics management routes +api.get("/api/admin/analytics/overview", AnalyticsOverviewEndpoint) +api.get("/api/admin/analytics/trend", AnalyticsTrendEndpoint) +api.get("/api/admin/analytics/breakdown", AnalyticsBreakdownEndpoint) +api.get("/api/admin/analytics/events", AnalyticsEventsEndpoint) +api.get("/api/admin/usage-logs", UsageLogSearchEndpoint) diff --git a/src/admin/token_utils.ts b/src/admin/token_utils.ts index 4d01375..9852aae 100644 --- a/src/admin/token_utils.ts +++ b/src/admin/token_utils.ts @@ -3,6 +3,15 @@ import { Context } from "hono"; import { CONSTANTS } from "../constants"; import { getJsonSetting } from "../utils"; +type UsageCostResult = { + totalCost: number; + requestCost: number; + inputCost: number; + outputCost: number; + cacheCost: number; + hasPricing: boolean; +} + // Token 工具对象 export const TokenUtils = { async updateUsage(c: Context, key: string, usageAmount: number): Promise { @@ -28,34 +37,75 @@ export const TokenUtils = { return globalPricingMap?.[model] || null; }, - async processUsage(c: Context, apiKey: string, model: string, targetChannelKey: string, targetChannelConfig: ChannelConfig, usage: Usage): Promise { - console.log("Usage data:", usage); - + async calculateUsageCost( + c: Context, + model: string, + targetChannelConfig: ChannelConfig, + usage: Usage + ): Promise { const pricing = await this.getPricing(c, model, targetChannelConfig); const hasTokens = usage.prompt_tokens != null && usage.completion_tokens != null; - const requestCost = pricing?.request || 0 + const requestCost = pricing?.request || 0; - if (pricing && (hasTokens || requestCost > 0)) { - const inputCost = hasTokens ? usage.prompt_tokens! * pricing.input : 0; - const outputCost = hasTokens ? usage.completion_tokens! * pricing.output : 0; + if (!pricing || (!hasTokens && requestCost <= 0)) { + return { + totalCost: 0, + requestCost, + inputCost: 0, + outputCost: 0, + cacheCost: 0, + hasPricing: false, + }; + } - let cacheCost = 0; - if (hasTokens && usage.cached_tokens && usage.cached_tokens > 0 && pricing.cache) { - cacheCost = usage.cached_tokens * pricing.cache; - } + const inputCost = hasTokens ? usage.prompt_tokens! * pricing.input : 0; + const outputCost = hasTokens ? usage.completion_tokens! * pricing.output : 0; - const totalCost = inputCost + outputCost + cacheCost + requestCost; + let cacheCost = 0; + if (hasTokens && usage.cached_tokens && usage.cached_tokens > 0 && pricing.cache) { + cacheCost = usage.cached_tokens * pricing.cache; + } - await this.updateUsage(c, apiKey, totalCost); + return { + totalCost: inputCost + outputCost + cacheCost + requestCost, + requestCost, + inputCost, + outputCost, + cacheCost, + hasPricing: true, + }; + }, + + async processUsage( + c: Context, + apiKey: string, + model: string, + targetChannelKey: string, + targetChannelConfig: ChannelConfig, + usage: Usage + ): Promise { + console.log("Usage data:", usage); + + const costResult = await this.calculateUsageCost(c, model, targetChannelConfig, usage); + + if (costResult.hasPricing) { + await this.updateUsage(c, apiKey, costResult.totalCost); const maskedApiKey = apiKey.length < 3 ? '*'.repeat(apiKey.length) : ( apiKey.slice(0, apiKey.length / 3) + '*'.repeat(apiKey.length / 3) + apiKey.slice((2 * apiKey.length) / 3) ); - console.log(`Model: ${model}, Channel: ${targetChannelKey}, apiKey: ${maskedApiKey}, Cost: ${totalCost} (request: ${requestCost}, input: ${inputCost}, cache: ${cacheCost}, output: ${outputCost})`); + console.log( + `Model: ${model}, Channel: ${targetChannelKey}, apiKey: ${maskedApiKey}, ` + + `Cost: ${costResult.totalCost} (request: ${costResult.requestCost}, ` + + `input: ${costResult.inputCost}, cache: ${costResult.cacheCost}, ` + + `output: ${costResult.outputCost})` + ); } else { console.warn(`No pricing found for model: ${model} in channel: ${targetChannelKey}`); } + + return costResult; } }; diff --git a/src/analytics/query.ts b/src/analytics/query.ts new file mode 100644 index 0000000..ee5b033 --- /dev/null +++ b/src/analytics/query.ts @@ -0,0 +1,1004 @@ +import { Context } from "hono"; + +import { DEFAULT_USAGE_ANALYTICS_DATASET_NAME } from "./usage-logger"; + +export type AnalyticsRange = "24h" | "7d" | "30d" | "90d"; +export type AnalyticsBreakdownDimension = "token" | "channel" | "model" | "provider"; +export type UsageLogFilterDimension = + | "route" + | "token" + | "channel" + | "model" + | "provider" + | "requestId" + | "traceId" + | "clientIp" + | "userAgent" + | "country" + | "region" + | "city" + | "colo" + | "timezone" + | "result" + | "errorCode" + | "errorSummary"; + +type RangeConfig = { + lookbackValue: string; + lookbackUnit: "HOUR" | "DAY"; + bucketValue: string; + bucketUnit: "HOUR" | "DAY"; + bucketLabel: string; + bucketCount: number; +} + +type TimeWindow = { + range?: AnalyticsRange; + startTime?: string; + endTime?: string; + whereClause: string; +} + +type AnalyticsQueryRawResult> = { + data: T[]; + meta?: Array<{ name?: string; type?: string }>; +} + +type DatasetColumnSupport = { + availableColumns: Set; +} + +type UsageLogQueryParams = { + start?: string; + end?: string; + dimension?: string; + keyword?: string; + result?: string; + page?: string; +} + +export class AnalyticsQueryValidationError extends Error {} + +const USAGE_LOG_PAGE_SIZE = 50; + +const RANGE_CONFIG: Record = { + "24h": { + lookbackValue: "24", + lookbackUnit: "HOUR", + bucketValue: "1", + bucketUnit: "HOUR", + bucketLabel: "1h", + bucketCount: 24, + }, + "7d": { + lookbackValue: "7", + lookbackUnit: "DAY", + bucketValue: "1", + bucketUnit: "DAY", + bucketLabel: "1d", + bucketCount: 7, + }, + "30d": { + lookbackValue: "30", + lookbackUnit: "DAY", + bucketValue: "1", + bucketUnit: "DAY", + bucketLabel: "1d", + bucketCount: 30, + }, + "90d": { + lookbackValue: "90", + lookbackUnit: "DAY", + bucketValue: "1", + bucketUnit: "DAY", + bucketLabel: "1d", + bucketCount: 90, + }, +}; + +const BLOB_FIELDS = { + routeId: "blob1", + tokenName: "blob2", + channelKey: "blob3", + providerType: "blob4", + requestedModel: "blob5", + upstreamModel: "blob6", + result: "blob7", + streamMode: "blob8", + errorCode: "blob9", + statusFamily: "blob10", + requestId: "blob11", + traceId: "blob12", + clientIp: "blob13", + userAgent: "blob14", + country: "blob15", + region: "blob16", + city: "blob17", + colo: "blob18", + timezone: "blob19", + errorSummary: "blob20", +} as const; + +const DOUBLE_FIELDS = { + promptTokens: "double1", + completionTokens: "double2", + cachedTokens: "double3", + totalTokens: "double4", + totalCost: "double5", + latencyMs: "double6", + retryCount: "double7", + upstreamStatus: "double8", + successFlag: "double9", +} as const; + +const BREAKDOWN_FIELDS: Record = { + token: BLOB_FIELDS.tokenName, + channel: BLOB_FIELDS.channelKey, + model: BLOB_FIELDS.requestedModel, + provider: BLOB_FIELDS.providerType, +}; + +const LOG_FILTER_FIELDS: Record = { + route: BLOB_FIELDS.routeId, + token: BLOB_FIELDS.tokenName, + channel: BLOB_FIELDS.channelKey, + model: BLOB_FIELDS.requestedModel, + provider: BLOB_FIELDS.providerType, + requestId: BLOB_FIELDS.requestId, + traceId: BLOB_FIELDS.traceId, + clientIp: BLOB_FIELDS.clientIp, + userAgent: BLOB_FIELDS.userAgent, + country: BLOB_FIELDS.country, + region: BLOB_FIELDS.region, + city: BLOB_FIELDS.city, + colo: BLOB_FIELDS.colo, + timezone: BLOB_FIELDS.timezone, + result: BLOB_FIELDS.result, + errorCode: BLOB_FIELDS.errorCode, + errorSummary: BLOB_FIELDS.errorSummary, +}; + +const LEGACY_BLOB_COLUMNS = [ + BLOB_FIELDS.routeId, + BLOB_FIELDS.tokenName, + BLOB_FIELDS.channelKey, + BLOB_FIELDS.providerType, + BLOB_FIELDS.requestedModel, + BLOB_FIELDS.upstreamModel, + BLOB_FIELDS.result, + BLOB_FIELDS.streamMode, + BLOB_FIELDS.errorCode, + BLOB_FIELDS.statusFamily, +]; + +const EXTENDED_LOG_COLUMNS = [ + BLOB_FIELDS.requestId, + BLOB_FIELDS.traceId, + BLOB_FIELDS.clientIp, + BLOB_FIELDS.userAgent, + BLOB_FIELDS.country, + BLOB_FIELDS.region, + BLOB_FIELDS.city, + BLOB_FIELDS.colo, + BLOB_FIELDS.timezone, + BLOB_FIELDS.errorSummary, +]; + +const USAGE_LOG_NUMERIC_COLUMNS = [ + DOUBLE_FIELDS.promptTokens, + DOUBLE_FIELDS.completionTokens, + DOUBLE_FIELDS.cachedTokens, + DOUBLE_FIELDS.totalTokens, + DOUBLE_FIELDS.totalCost, + DOUBLE_FIELDS.latencyMs, + DOUBLE_FIELDS.retryCount, + DOUBLE_FIELDS.upstreamStatus, + DOUBLE_FIELDS.successFlag, +]; + +const ALL_PROBED_COLUMNS = [ + ...LEGACY_BLOB_COLUMNS, + ...EXTENDED_LOG_COLUMNS, + ...USAGE_LOG_NUMERIC_COLUMNS, +]; + +const DATASET_COLUMN_SUPPORT_CACHE_TTL_MS = 60_000; +const DATASET_COLUMN_SUPPORT_CACHE = new Map; +}>(); + +const sanitizeDatasetName = (value?: string): string => { + if (value && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + return value; + } + + return DEFAULT_USAGE_ANALYTICS_DATASET_NAME; +}; + +const toNumber = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return 0; +}; + +const toText = (value: unknown): string => { + return typeof value === "string" ? value : ""; +}; + +const escapeSqlString = (value: string): string => { + return value.replace(/'/g, "''"); +}; + +const getRangeConfig = (range?: string): { range: AnalyticsRange; config: RangeConfig } => { + if (range && range in RANGE_CONFIG) { + return { + range: range as AnalyticsRange, + config: RANGE_CONFIG[range as AnalyticsRange], + }; + } + + return { + range: "7d", + config: RANGE_CONFIG["7d"], + }; +}; + +const getDatasetName = (c: Context): string => { + return sanitizeDatasetName(c.env.USAGE_ANALYTICS_DATASET); +}; + +const formatSqlDateTime = (date: Date): string => { + return date.toISOString().slice(0, 19).replace("T", " "); +}; + +const parseDateTimeInput = (value: string | undefined, fieldLabel: string): string | undefined => { + if (!value) { + return undefined; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new AnalyticsQueryValidationError(`${fieldLabel} 格式无效`); + } + + return formatSqlDateTime(date); +}; + +const buildRangeWhereClause = (config: RangeConfig): string => { + return `timestamp >= NOW() - INTERVAL '${config.lookbackValue}' ${config.lookbackUnit}`; +}; + +const buildBucketClause = (config: RangeConfig): string => { + return `toStartOfInterval(timestamp, INTERVAL '${config.bucketValue}' ${config.bucketUnit})`; +}; + +const startOfUtcHour = (date: Date): Date => { + return new Date(Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + 0, + 0, + 0 + )); +}; + +const startOfUtcDay = (date: Date): Date => { + return new Date(Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + 0, + 0, + 0, + 0 + )); +}; + +const addUtcHours = (date: Date, hours: number): Date => { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +}; + +const addUtcDays = (date: Date, days: number): Date => { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); +}; + +const buildExplicitTimeWhereClause = (start: Date, endExclusive: Date): string => { + return [ + `timestamp >= toDateTime('${escapeSqlString(formatSqlDateTime(start))}')`, + `timestamp < toDateTime('${escapeSqlString(formatSqlDateTime(endExclusive))}')`, + ].join(" AND "); +}; + +const buildTrendWindow = (range: AnalyticsRange, config: RangeConfig) => { + if (range === "24h") { + const latestBucket = startOfUtcHour(new Date()); + const start = addUtcHours(latestBucket, -(config.bucketCount - 1)); + const endExclusive = addUtcHours(latestBucket, 1); + + return { + whereClause: buildExplicitTimeWhereClause(start, endExclusive), + bucketTimestamps: Array.from({ length: config.bucketCount }, (_, index) => + Math.floor(addUtcHours(start, index).getTime() / 1000) + ), + }; + } + + const latestBucket = startOfUtcDay(new Date()); + const start = addUtcDays(latestBucket, -(config.bucketCount - 1)); + const endExclusive = addUtcDays(latestBucket, 1); + + return { + whereClause: buildExplicitTimeWhereClause(start, endExclusive), + bucketTimestamps: Array.from({ length: config.bucketCount }, (_, index) => + Math.floor(addUtcDays(start, index).getTime() / 1000) + ), + }; +}; + +const buildCustomTimeWindow = ( + requestedStart?: string, + requestedEnd?: string +): TimeWindow => { + const startTime = parseDateTimeInput(requestedStart, "开始时间"); + const endTime = parseDateTimeInput(requestedEnd, "结束时间"); + + if (startTime && endTime && startTime >= endTime) { + throw new AnalyticsQueryValidationError("开始时间必须早于结束时间"); + } + + const clauses: string[] = []; + + if (startTime) { + clauses.push(`timestamp >= toDateTime('${escapeSqlString(startTime)}')`); + } + + if (endTime) { + clauses.push(`timestamp < toDateTime('${escapeSqlString(endTime)}')`); + } + + if (clauses.length === 0) { + const { range, config } = getRangeConfig("24h"); + return { + range, + whereClause: buildRangeWhereClause(config), + }; + } + + return { + startTime, + endTime, + whereClause: clauses.join(" AND "), + }; +}; + +const runAnalyticsQueryRaw = async >( + c: Context, + query: string +): Promise> => { + if (!c.env.CF_API_TOKEN || !c.env.CF_ACCOUNT_ID) { + throw new Error("CF analytics query credentials are not configured"); + } + + const api = `https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/analytics_engine/sql`; + const response = await fetch(api, { + method: "POST", + headers: { + Authorization: `Bearer ${c.env.CF_API_TOKEN}`, + }, + body: query, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Analytics query failed with ${response.status}`); + } + + const json = await response.json() as { + data?: T[]; + result?: T[]; + meta?: Array<{ name?: string; type?: string }>; + }; + + return { + data: json.data || json.result || [], + meta: json.meta, + }; +}; + +const runAnalyticsQuery = async >( + c: Context, + query: string +): Promise => { + const result = await runAnalyticsQueryRaw(c, query); + return result.data; +}; + +const probeColumnAvailability = async ( + c: Context, + dataset: string, + columnName: string, + whereClause?: string +): Promise => { + try { + await runAnalyticsQuery>(c, ` +SELECT + ${columnName} +FROM ${dataset} +${whereClause ? `WHERE ${whereClause}` : ""} +LIMIT 1 + `.trim()); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("unable to find type of column")) { + return false; + } + + throw error; + } +}; + +const getDatasetColumnSupport = async ( + c: Context, + dataset: string, + whereClause?: string +): Promise => { + const cacheKey = `${c.env.CF_ACCOUNT_ID || "unknown"}:${dataset}:${whereClause || "__all__"}`; + const cached = DATASET_COLUMN_SUPPORT_CACHE.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.promise; + } + + const pending = (async () => { + const availableColumns = new Set(); + const results = await Promise.all( + ALL_PROBED_COLUMNS.map(async (columnName) => ({ + columnName, + supported: await probeColumnAvailability(c, dataset, columnName, whereClause), + })) + ); + + results.forEach((result) => { + if (result.supported) { + availableColumns.add(result.columnName); + } + }); + + return { availableColumns }; + })(); + + DATASET_COLUMN_SUPPORT_CACHE.set(cacheKey, { + expiresAt: Date.now() + DATASET_COLUMN_SUPPORT_CACHE_TTL_MS, + promise: pending, + }); + + try { + return await pending; + } catch (error) { + DATASET_COLUMN_SUPPORT_CACHE.delete(cacheKey); + throw error; + } +}; + +const isColumnAvailable = (support: DatasetColumnSupport, columnName: string): boolean => { + return support.availableColumns.has(columnName); +}; + +const buildBlobSelect = ( + support: DatasetColumnSupport, + columnName: string, + alias: string +): string => { + return isColumnAvailable(support, columnName) + ? `${columnName} AS ${alias}` + : `'' AS ${alias}`; +}; + +const buildDoubleSelect = ( + support: DatasetColumnSupport, + columnName: string, + alias: string +): string => { + return isColumnAvailable(support, columnName) + ? `${columnName} AS ${alias}` + : `0 AS ${alias}`; +}; + +const hasAnyLegacyLogSchema = (support: DatasetColumnSupport): boolean => { + return LEGACY_BLOB_COLUMNS.some((columnName) => isColumnAvailable(support, columnName)); +}; + +const hasExtendedLogSchema = (support: DatasetColumnSupport): boolean => { + return EXTENDED_LOG_COLUMNS.every((columnName) => isColumnAvailable(support, columnName)); +}; + +const getUsageLogCompatibilityWarning = (support: DatasetColumnSupport): string | undefined => { + if (!hasAnyLegacyLogSchema(support)) { + return "当前 Analytics Engine dataset 不包含使用日志基础字段,通常说明它是旧的、空的,或并非当前版本写入的 usage log dataset。"; + } + + if (!hasExtendedLogSchema(support)) { + return "当前 Analytics Engine dataset 仍是旧日志 schema,request id / trace id / IP / UA / 地理位置 / 错误摘要 等新字段需要新版数据列可用后才可查询。"; + } + + return undefined; +}; + +const buildUsageLogBaseResponse = ( + timeWindow: TimeWindow, + dimension: UsageLogFilterDimension, + keyword: string, + result: "all" | "success" | "failure", + compatibilityWarning?: string +) => ({ + sampled: true, + dimension, + keyword, + result, + startTime: timeWindow.startTime || "", + endTime: timeWindow.endTime || "", + compatibilityWarning, +}); + +const isUsageLogFilterSupported = ( + support: DatasetColumnSupport, + dimension: UsageLogFilterDimension +): boolean => { + return isColumnAvailable(support, LOG_FILTER_FIELDS[dimension]); +}; + +const buildUsageLogEmptyResponse = ( + timeWindow: TimeWindow, + dimension: UsageLogFilterDimension, + keyword: string, + result: "all" | "success" | "failure", + compatibilityWarning: string | undefined +) => ({ + ...buildUsageLogBaseResponse( + timeWindow, + dimension, + keyword, + result, + compatibilityWarning + ), + page: 1, + pageSize: USAGE_LOG_PAGE_SIZE, + total: 0, + totalPages: 0, + count: 0, + hasPrevPage: false, + hasNextPage: false, + items: [], +}); + +export const queryUsageOverview = async ( + c: Context, + requestedRange?: string +) => { + const { range, config } = getRangeConfig(requestedRange); + const dataset = getDatasetName(c); + const rows = await runAnalyticsQuery>(c, ` +SELECT + sum(_sample_interval) AS requests, + sum(${DOUBLE_FIELDS.successFlag} * _sample_interval) AS successes, + sum(${DOUBLE_FIELDS.totalCost} * _sample_interval) AS total_cost, + sum(${DOUBLE_FIELDS.totalTokens} * _sample_interval) AS total_tokens, + sum(${DOUBLE_FIELDS.promptTokens} * _sample_interval) AS prompt_tokens, + sum(${DOUBLE_FIELDS.completionTokens} * _sample_interval) AS completion_tokens, + sum(${DOUBLE_FIELDS.latencyMs} * _sample_interval) / sum(_sample_interval) AS avg_latency_ms +FROM ${dataset} +WHERE ${buildRangeWhereClause(config)} + `.trim()); + + const row = rows[0] || {}; + const requests = toNumber(row.requests); + const successes = toNumber(row.successes); + + return { + range, + totals: { + requests, + successes, + failures: Math.max(0, requests - successes), + successRate: requests > 0 ? successes / requests : 0, + totalCost: toNumber(row.total_cost), + totalTokens: toNumber(row.total_tokens), + promptTokens: toNumber(row.prompt_tokens), + completionTokens: toNumber(row.completion_tokens), + avgLatencyMs: toNumber(row.avg_latency_ms), + }, + }; +}; + +export const queryUsageTrend = async ( + c: Context, + requestedRange?: string +) => { + const { range, config } = getRangeConfig(requestedRange); + const dataset = getDatasetName(c); + const trendWindow = buildTrendWindow(range, config); + const rows = await runAnalyticsQuery>(c, ` +SELECT + toUnixTimestamp(${buildBucketClause(config)}) AS bucket_ts, + sum(_sample_interval) AS requests, + sum(${DOUBLE_FIELDS.successFlag} * _sample_interval) AS successes, + sum(${DOUBLE_FIELDS.totalCost} * _sample_interval) AS total_cost +FROM ${dataset} +WHERE ${trendWindow.whereClause} +GROUP BY bucket_ts +ORDER BY bucket_ts ASC + `.trim()); + + const rowsByBucket = new Map>(); + rows.forEach((row) => { + rowsByBucket.set(toNumber(row.bucket_ts), row); + }); + + return { + range, + bucket: config.bucketLabel, + points: trendWindow.bucketTimestamps.map((bucketTimestamp) => { + const row = rowsByBucket.get(bucketTimestamp) || {}; + const requests = toNumber(row.requests); + const successes = toNumber(row.successes); + + return { + timestamp: new Date(bucketTimestamp * 1000).toISOString(), + requests, + successes, + failures: Math.max(0, requests - successes), + successRate: requests > 0 ? successes / requests : 0, + totalCost: toNumber(row.total_cost), + }; + }), + }; +}; + +export const queryUsageBreakdown = async ( + c: Context, + requestedRange?: string, + requestedDimension?: string +) => { + const { range, config } = getRangeConfig(requestedRange); + const dimension = (requestedDimension && requestedDimension in BREAKDOWN_FIELDS + ? requestedDimension + : "token") as AnalyticsBreakdownDimension; + const dataset = getDatasetName(c); + const dimensionField = BREAKDOWN_FIELDS[dimension]; + const rows = await runAnalyticsQuery>(c, ` +SELECT + ${dimensionField} AS label, + sum(_sample_interval) AS requests, + sum(${DOUBLE_FIELDS.successFlag} * _sample_interval) AS successes, + sum(${DOUBLE_FIELDS.totalCost} * _sample_interval) AS total_cost, + sum(${DOUBLE_FIELDS.promptTokens} * _sample_interval) AS prompt_tokens, + sum(${DOUBLE_FIELDS.completionTokens} * _sample_interval) AS completion_tokens, + sum(${DOUBLE_FIELDS.latencyMs} * _sample_interval) / sum(_sample_interval) AS avg_latency_ms +FROM ${dataset} +WHERE ${buildRangeWhereClause(config)} + AND ${dimensionField} != '' +GROUP BY label +ORDER BY total_cost DESC, requests DESC +LIMIT 12 + `.trim()); + + return { + range, + dimension, + items: rows.map((row) => { + const requests = toNumber(row.requests); + const successes = toNumber(row.successes); + + return { + label: toText(row.label), + requests, + successes, + failures: Math.max(0, requests - successes), + successRate: requests > 0 ? successes / requests : 0, + totalCost: toNumber(row.total_cost), + promptTokens: toNumber(row.prompt_tokens), + completionTokens: toNumber(row.completion_tokens), + avgLatencyMs: toNumber(row.avg_latency_ms), + }; + }), + }; +}; + +export const queryUsageEvents = async ( + c: Context, + requestedRange?: string, + requestedLimit?: string +) => { + const { range, config } = getRangeConfig(requestedRange); + const dataset = getDatasetName(c); + const limit = Math.min(Math.max(Number(requestedLimit || 40) || 40, 1), 100); + const countRows = await runAnalyticsQuery>(c, ` +SELECT + count() AS total +FROM ${dataset} +WHERE ${buildRangeWhereClause(config)} + `.trim()); + const total = toNumber(countRows[0]?.total); + + if (total === 0) { + return { + range, + sampled: true, + compatibilityWarning: undefined, + items: [], + }; + } + + const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config)); + const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport); + const rows = await runAnalyticsQuery>(c, ` +SELECT + timestamp, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.routeId, "route_id")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.tokenName, "token_name")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.channelKey, "channel_key")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.providerType, "provider_type")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.requestedModel, "requested_model")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.upstreamModel, "upstream_model")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.result, "result")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.streamMode, "stream_mode")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.errorCode, "error_code")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.statusFamily, "status_family")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.requestId, "request_id")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.traceId, "trace_id")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.clientIp, "client_ip")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.userAgent, "user_agent")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.country, "country")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.region, "region")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.city, "city")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.colo, "colo")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.timezone, "timezone")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.errorSummary, "error_summary")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.promptTokens, "prompt_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.completionTokens, "completion_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.cachedTokens, "cached_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalTokens, "total_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalCost, "total_cost")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.latencyMs, "latency_ms")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.retryCount, "retry_count")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.upstreamStatus, "upstream_status")} +FROM ${dataset} +WHERE ${buildRangeWhereClause(config)} +ORDER BY timestamp DESC +LIMIT ${limit} + `.trim()); + + return { + range, + sampled: true, + compatibilityWarning, + items: rows.map((row) => ({ + timestamp: toText(row.timestamp), + routeId: toText(row.route_id), + tokenName: toText(row.token_name), + channelKey: toText(row.channel_key), + providerType: toText(row.provider_type), + requestedModel: toText(row.requested_model), + upstreamModel: toText(row.upstream_model), + result: toText(row.result), + streamMode: toText(row.stream_mode), + errorCode: toText(row.error_code), + statusFamily: toText(row.status_family), + requestId: toText(row.request_id), + traceId: toText(row.trace_id), + clientIp: toText(row.client_ip), + userAgent: toText(row.user_agent), + country: toText(row.country), + region: toText(row.region), + city: toText(row.city), + colo: toText(row.colo), + timezone: toText(row.timezone), + errorSummary: toText(row.error_summary), + promptTokens: toNumber(row.prompt_tokens), + completionTokens: toNumber(row.completion_tokens), + cachedTokens: toNumber(row.cached_tokens), + totalTokens: toNumber(row.total_tokens), + totalCost: toNumber(row.total_cost), + latencyMs: toNumber(row.latency_ms), + retryCount: toNumber(row.retry_count), + upstreamStatus: toNumber(row.upstream_status), + })), + }; +}; + +export const queryUsageLogRecords = async ( + c: Context, + params: UsageLogQueryParams +) => { + const dataset = getDatasetName(c); + const timeWindow = buildCustomTimeWindow(params.start, params.end); + const requestedPage = Math.min(Math.max(Number(params.page || 1) || 1, 1), 1000); + const dimension = (params.dimension && params.dimension in LOG_FILTER_FIELDS + ? params.dimension + : "token") as UsageLogFilterDimension; + const keyword = params.keyword?.trim(); + const result = params.result === "success" || params.result === "failure" ? params.result : "all"; + const baseCountRows = await runAnalyticsQuery>(c, ` +SELECT + count() AS total +FROM ${dataset} +WHERE ${timeWindow.whereClause} + `.trim()); + const baseTotal = toNumber(baseCountRows[0]?.total); + + if (baseTotal === 0) { + return buildUsageLogEmptyResponse( + timeWindow, + dimension, + keyword || "", + result, + undefined + ); + } + + const columnSupport = await getDatasetColumnSupport(c, dataset, timeWindow.whereClause); + const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport); + + if (!hasAnyLegacyLogSchema(columnSupport)) { + return buildUsageLogEmptyResponse( + timeWindow, + dimension, + keyword || "", + result, + compatibilityWarning + ); + } + + if (keyword && !isUsageLogFilterSupported(columnSupport, dimension)) { + return buildUsageLogEmptyResponse( + timeWindow, + dimension, + keyword, + result, + compatibilityWarning + ); + } + + if (result !== "all" && !isUsageLogFilterSupported(columnSupport, "result")) { + return buildUsageLogEmptyResponse( + timeWindow, + dimension, + keyword || "", + result, + compatibilityWarning + ); + } + + const clauses = [timeWindow.whereClause]; + + if (keyword) { + clauses.push(`${LOG_FILTER_FIELDS[dimension]} ILIKE '%${escapeSqlString(keyword)}%'`); + } + + if (result === "success" || result === "failure") { + clauses.push(`${BLOB_FIELDS.result} = '${result}'`); + } + + const whereClause = clauses.join("\n AND "); + const countRows = await runAnalyticsQuery>(c, ` +SELECT + count() AS total +FROM ${dataset} +WHERE ${whereClause} + `.trim()); + const total = toNumber(countRows[0]?.total); + const totalPages = total > 0 ? Math.ceil(total / USAGE_LOG_PAGE_SIZE) : 0; + const page = totalPages > 0 ? Math.min(requestedPage, totalPages) : 1; + const offset = (page - 1) * USAGE_LOG_PAGE_SIZE; + + if (total === 0) { + return { + ...buildUsageLogEmptyResponse( + timeWindow, + dimension, + keyword || "", + result, + compatibilityWarning + ), + page, + }; + } + + const rows = await runAnalyticsQuery>(c, ` +SELECT + timestamp, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.routeId, "route_id")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.tokenName, "token_name")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.channelKey, "channel_key")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.providerType, "provider_type")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.requestedModel, "requested_model")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.upstreamModel, "upstream_model")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.result, "result")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.streamMode, "stream_mode")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.errorCode, "error_code")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.statusFamily, "status_family")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.requestId, "request_id")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.traceId, "trace_id")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.clientIp, "client_ip")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.userAgent, "user_agent")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.country, "country")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.region, "region")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.city, "city")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.colo, "colo")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.timezone, "timezone")}, + ${buildBlobSelect(columnSupport, BLOB_FIELDS.errorSummary, "error_summary")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.promptTokens, "prompt_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.completionTokens, "completion_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.cachedTokens, "cached_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalTokens, "total_tokens")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.totalCost, "total_cost")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.latencyMs, "latency_ms")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.retryCount, "retry_count")}, + ${buildDoubleSelect(columnSupport, DOUBLE_FIELDS.upstreamStatus, "upstream_status")} +FROM ${dataset} +WHERE ${whereClause} +ORDER BY timestamp DESC, route_id DESC, channel_key DESC, requested_model DESC +LIMIT ${USAGE_LOG_PAGE_SIZE} +OFFSET ${offset} + `.trim()); + + return { + ...buildUsageLogBaseResponse( + timeWindow, + dimension, + keyword || "", + result, + compatibilityWarning + ), + page, + pageSize: USAGE_LOG_PAGE_SIZE, + total, + totalPages, + count: rows.length, + hasPrevPage: totalPages > 0 && page > 1, + hasNextPage: totalPages > 0 && page < totalPages, + items: rows.map((row) => ({ + timestamp: toText(row.timestamp), + routeId: toText(row.route_id), + tokenName: toText(row.token_name), + channelKey: toText(row.channel_key), + providerType: toText(row.provider_type), + requestedModel: toText(row.requested_model), + upstreamModel: toText(row.upstream_model), + result: toText(row.result), + streamMode: toText(row.stream_mode), + errorCode: toText(row.error_code), + statusFamily: toText(row.status_family), + requestId: toText(row.request_id), + traceId: toText(row.trace_id), + clientIp: toText(row.client_ip), + userAgent: toText(row.user_agent), + country: toText(row.country), + region: toText(row.region), + city: toText(row.city), + colo: toText(row.colo), + timezone: toText(row.timezone), + errorSummary: toText(row.error_summary), + promptTokens: toNumber(row.prompt_tokens), + completionTokens: toNumber(row.completion_tokens), + cachedTokens: toNumber(row.cached_tokens), + totalTokens: toNumber(row.total_tokens), + totalCost: toNumber(row.total_cost), + latencyMs: toNumber(row.latency_ms), + retryCount: toNumber(row.retry_count), + upstreamStatus: toNumber(row.upstream_status), + })), + }; +}; diff --git a/src/analytics/usage-logger.ts b/src/analytics/usage-logger.ts new file mode 100644 index 0000000..3804c33 --- /dev/null +++ b/src/analytics/usage-logger.ts @@ -0,0 +1,275 @@ +import { Context } from "hono"; + +export const DEFAULT_USAGE_ANALYTICS_DATASET_NAME = "usage_events_by_token"; + +export type UsageLogContext = { + routeId: string; + tokenHash: string; + tokenName: string; + channelKey: string; + providerType: string; + requestedModel: string; + upstreamModel: string; + streamMode: "stream" | "sync"; + requestId: string; + traceId: string; + clientIp: string; + userAgent: string; + country: string; + region: string; + city: string; + colo: string; + timezone: string; + startedAt: number; + trackingState: RequestTrackingState; +} + +type UsageCostResult = { + totalCost: number; +} + +type FailureLogParams = { + errorCode: string; + errorSummary?: string; +} + +const MAX_DIMENSION_LENGTH = 200; + +const safeNumber = (value: unknown): number => { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +}; + +const normalizeDimension = (value: unknown): string => { + if (typeof value !== "string") { + return ""; + } + + return value.slice(0, MAX_DIMENSION_LENGTH); +}; + +const firstNonEmpty = (...values: Array): string => { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + + return ""; +}; + +const getStatusFamily = (status?: number): string => { + if (!status || status < 100) { + return "network"; + } + + return `${Math.floor(status / 100)}xx`; +}; + +const getDatasetName = (c: Context): string => { + return c.env.USAGE_ANALYTICS_DATASET || DEFAULT_USAGE_ANALYTICS_DATASET_NAME; +}; + +const getAnalyticsBinding = (c: Context): AnalyticsEngineDataset | null => { + if (!c.env.USAGE_ANALYTICS || !getDatasetName(c)) { + return null; + } + + return c.env.USAGE_ANALYTICS; +}; + +const writeDataPoint = ( + c: Context, + point: AnalyticsEngineDataPoint +) => { + const binding = getAnalyticsBinding(c); + if (!binding) { + return; + } + + try { + binding.writeDataPoint(point); + } catch (error) { + console.error("Failed to write usage analytics datapoint:", error); + } +}; + +const extractSummaryFromUnknown = (value: unknown): string => { + if (typeof value === "string") { + return normalizeDimension(value.replace(/\s+/g, " ").trim()); + } + + if (value && typeof value === "object") { + const candidateRecord = value as Record; + for (const key of ["message", "error", "detail", "description", "reason", "title"]) { + const candidateValue = candidateRecord[key]; + if (typeof candidateValue === "string" && candidateValue.trim()) { + return normalizeDimension(candidateValue.replace(/\s+/g, " ").trim()); + } + if (candidateValue && typeof candidateValue === "object") { + const nested = extractSummaryFromUnknown(candidateValue); + if (nested) { + return nested; + } + } + } + } + + return ""; +}; + +export const summarizeErrorText = (text: string): string => { + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) { + return ""; + } + + try { + const parsed = JSON.parse(normalized) as unknown; + const summary = extractSummaryFromUnknown(parsed); + if (summary) { + return summary; + } + } catch { + // ignore JSON parse errors and fallback to plain text + } + + return normalizeDimension(normalized); +}; + +export const summarizeErrorFromResponse = async (response: Response): Promise => { + try { + const text = await response.clone().text(); + return summarizeErrorText(text); + } catch { + return ""; + } +}; + +export const summarizeErrorFromUnknown = (error: unknown): string => { + if (error instanceof Error) { + return summarizeErrorText(error.message); + } + + return summarizeErrorText(String(error ?? "")); +}; + +export const buildUsageRequestMetadata = (c: Context) => { + const requestCf = (c.req.raw.cf || {}) as Partial>; + const requestId = firstNonEmpty( + c.req.header("x-request-id"), + c.req.header("cf-ray"), + crypto.randomUUID() + ); + const traceId = firstNonEmpty( + c.req.header("traceparent"), + c.req.header("x-b3-traceid"), + c.req.header("x-trace-id"), + requestId + ); + + return { + requestId, + traceId, + clientIp: firstNonEmpty( + c.req.header("cf-connecting-ip"), + c.req.header("x-real-ip"), + c.req.header("x-forwarded-for")?.split(",")[0] + ), + userAgent: firstNonEmpty(c.req.header("user-agent")), + country: firstNonEmpty(requestCf.country), + region: firstNonEmpty(requestCf.region, requestCf.regionCode), + city: firstNonEmpty(requestCf.city), + colo: firstNonEmpty(requestCf.colo), + timezone: firstNonEmpty(requestCf.timezone), + }; +}; + +const buildCommonPoint = ( + context: UsageLogContext, + usage: Usage, + totalCost: number, + result: "success" | "failure", + errorCode: string, + errorSummary: string +): AnalyticsEngineDataPoint => { + const promptTokens = safeNumber(usage.prompt_tokens); + const completionTokens = safeNumber(usage.completion_tokens); + const cachedTokens = safeNumber(usage.cached_tokens); + const totalTokens = safeNumber( + usage.total_tokens ?? (promptTokens + completionTokens) + ); + const upstreamStatus = safeNumber(context.trackingState.upstreamStatus); + + return { + indexes: [context.tokenHash], + blobs: [ + normalizeDimension(context.routeId), + normalizeDimension(context.tokenName), + normalizeDimension(context.channelKey), + normalizeDimension(context.providerType), + normalizeDimension(context.requestedModel), + normalizeDimension(context.upstreamModel), + result, + context.streamMode, + normalizeDimension(errorCode), + getStatusFamily(upstreamStatus), + normalizeDimension(context.requestId), + normalizeDimension(context.traceId), + normalizeDimension(context.clientIp), + normalizeDimension(context.userAgent), + normalizeDimension(context.country), + normalizeDimension(context.region), + normalizeDimension(context.city), + normalizeDimension(context.colo), + normalizeDimension(context.timezone), + normalizeDimension(errorSummary), + ], + doubles: [ + promptTokens, + completionTokens, + cachedTokens, + totalTokens, + safeNumber(totalCost), + Math.max(0, Date.now() - context.startedAt), + safeNumber(context.trackingState.retryCount), + upstreamStatus, + result === "success" ? 1 : 0, + ], + }; +}; + +export const writeUsageSuccessEvent = ( + c: Context, + context: UsageLogContext, + usage: Usage, + costResult: UsageCostResult +) => { + writeDataPoint(c, buildCommonPoint(context, usage, costResult.totalCost, "success", "", "")); +}; + +export const writeUsageFailureEvent = ( + c: Context, + context: UsageLogContext, + params: FailureLogParams +) => { + writeDataPoint( + c, + buildCommonPoint( + context, + {}, + 0, + "failure", + params.errorCode, + params.errorSummary || context.trackingState.errorSummary || "" + ) + ); +}; + +export const hashTokenKey = async (value: string): Promise => { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest)) + .map((part) => part.toString(16).padStart(2, "0")) + .join("") + .slice(0, 32); +}; diff --git a/src/providers/azure-openai-proxy.ts b/src/providers/azure-openai-proxy.ts index b3a7850..de8ca74 100644 --- a/src/providers/azure-openai-proxy.ts +++ b/src/providers/azure-openai-proxy.ts @@ -29,6 +29,7 @@ export default { config: ChannelConfig, requestBody: any, saveUsage: (usage: Usage) => Promise, + trackingState: RequestTrackingState, ): Promise { const { stream } = requestBody; @@ -41,6 +42,7 @@ export default { const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const response = await fetch(proxyRequest) + trackingState.upstreamStatus = response.status if (stream) { const [streamForClient, streamForServer] = response.body?.tee() || [] diff --git a/src/providers/azure-openai-responses-proxy.ts b/src/providers/azure-openai-responses-proxy.ts index 6e45cff..4d83845 100644 --- a/src/providers/azure-openai-responses-proxy.ts +++ b/src/providers/azure-openai-responses-proxy.ts @@ -32,11 +32,13 @@ export default { config: ChannelConfig, requestBody: any, saveUsage: (usage: Usage) => Promise, + trackingState: RequestTrackingState, ): Promise { const { stream } = requestBody const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const response = await fetch(proxyRequest) + trackingState.upstreamStatus = response.status if (stream) { const [streamForClient, streamForServer] = response.body?.tee() || [] diff --git a/src/providers/claude-proxy.ts b/src/providers/claude-proxy.ts index ecc73cc..a916f08 100644 --- a/src/providers/claude-proxy.ts +++ b/src/providers/claude-proxy.ts @@ -261,6 +261,7 @@ export default { config: ChannelConfig, requestBody: any, saveUsage: (usage: Usage) => Promise, + trackingState: RequestTrackingState, ): Promise { const { stream } = requestBody; @@ -272,6 +273,7 @@ export default { // Build and send proxy request const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const response = await fetch(proxyRequest) + trackingState.upstreamStatus = response.status // Handle streaming response if (stream) { diff --git a/src/providers/claude-to-openai-proxy.ts b/src/providers/claude-to-openai-proxy.ts index 55c4118..7b358c4 100644 --- a/src/providers/claude-to-openai-proxy.ts +++ b/src/providers/claude-to-openai-proxy.ts @@ -529,6 +529,7 @@ export default { config: ChannelConfig, requestBody: any, saveUsage: (usage: Usage) => Promise, + trackingState: RequestTrackingState, ): Promise { const { stream } = requestBody; @@ -544,6 +545,7 @@ export default { const proxyRequest = buildProxyRequest(c.req.raw, openaiReq, config); const response = await fetch(proxyRequest); + trackingState.upstreamStatus = response.status; if (stream) { if (!response.ok || !response.body) { diff --git a/src/providers/index.ts b/src/providers/index.ts index 4b492bb..270d19d 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -42,14 +42,22 @@ class UnifiedProxyEndpoint extends OpenAPIRoute { const result = await resolveChannel(c, routeId) if (result instanceof Response) return result - const { channel, requestBody, saveUsage } = result + const { channel, requestBody, saveUsage, logFailure, trackingState } = result const provider = getProvider(channel.config.type || "") if (!provider) { return c.text("Channel type not supported", 400) } - return executeWithChannelKeys(c, channel.config, requestBody, saveUsage, provider) + return executeWithChannelKeys( + c, + channel.config, + requestBody, + saveUsage, + logFailure, + trackingState, + provider + ) } } diff --git a/src/providers/openai-proxy.ts b/src/providers/openai-proxy.ts index e7e5f14..807a995 100644 --- a/src/providers/openai-proxy.ts +++ b/src/providers/openai-proxy.ts @@ -29,6 +29,7 @@ export default { config: ChannelConfig, requestBody: any, saveUsage: (usage: Usage) => Promise, + trackingState: RequestTrackingState, ): Promise { const { stream } = requestBody; @@ -41,6 +42,7 @@ export default { const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const response = await fetch(proxyRequest) + trackingState.upstreamStatus = response.status if (stream) { const [streamForClient, streamForServer] = response.body?.tee() || [] diff --git a/src/providers/openai-responses-proxy.ts b/src/providers/openai-responses-proxy.ts index 1f3a631..a601b5a 100644 --- a/src/providers/openai-responses-proxy.ts +++ b/src/providers/openai-responses-proxy.ts @@ -32,11 +32,13 @@ export default { config: ChannelConfig, requestBody: any, saveUsage: (usage: Usage) => Promise, + trackingState: RequestTrackingState, ): Promise { const { stream } = requestBody const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const response = await fetch(proxyRequest) + trackingState.upstreamStatus = response.status if (stream) { const [streamForClient, streamForServer] = response.body?.tee() || [] diff --git a/src/providers/shared/channel-resolver.ts b/src/providers/shared/channel-resolver.ts index 7a35033..8f72b1b 100644 --- a/src/providers/shared/channel-resolver.ts +++ b/src/providers/shared/channel-resolver.ts @@ -4,11 +4,19 @@ import { RouteId, getRoutePolicy } from "./route-policy" import { findChannelModelMapping } from "../../utils" import { TokenUtils } from "../../admin/token_utils" import { normalizeChannelConfig } from "../../channel-config" +import { + buildUsageRequestMetadata, + hashTokenKey, + writeUsageFailureEvent, + writeUsageSuccessEvent, +} from "../../analytics/usage-logger" export type ChannelResolution = { channel: { key: string; config: ChannelConfig } requestBody: any saveUsage: (usage: Usage) => Promise + logFailure: (errorCode: string, errorSummary?: string) => void + trackingState: RequestTrackingState } export const resolveChannel = async ( @@ -90,6 +98,35 @@ export const resolveChannel = async ( const selectedChannel = availableChannels[randomIndex]; const targetChannelKey = selectedChannel.key; const targetChannelConfig = selectedChannel.config; + const trackingState: RequestTrackingState = { + retryCount: 0, + }; + const requestStartedAt = Date.now(); + const tokenHash = await hashTokenKey(apiKey); + const tokenName = tokenData.name?.trim() || "未命名令牌"; + const requestMetadata = buildUsageRequestMetadata(c); + const usageContext = { + routeId, + tokenHash, + tokenName, + channelKey: targetChannelKey, + providerType: targetChannelConfig.type || "unknown", + requestedModel, + upstreamModel: selectedChannel.mapping.id, + streamMode: requestBody.stream ? "stream" as const : "sync" as const, + requestId: requestMetadata.requestId, + traceId: requestMetadata.traceId, + clientIp: requestMetadata.clientIp, + userAgent: requestMetadata.userAgent, + country: requestMetadata.country, + region: requestMetadata.region, + city: requestMetadata.city, + colo: requestMetadata.colo, + timezone: requestMetadata.timezone, + startedAt: requestStartedAt, + trackingState, + }; + let usageLogState: "idle" | "processing" | "done" = "idle"; requestBody.model = selectedChannel.mapping.id; @@ -98,16 +135,41 @@ export const resolveChannel = async ( } const saveUsage = async (usage: Usage) => { + if (usageLogState !== "idle") { + return; + } + + usageLogState = "processing"; try { - await TokenUtils.processUsage(c, apiKey, requestedModel, targetChannelKey, targetChannelConfig, usage); + const costResult = await TokenUtils.processUsage( + c, + apiKey, + requestedModel, + targetChannelKey, + targetChannelConfig, + usage + ); + writeUsageSuccessEvent(c, usageContext, usage, costResult); + usageLogState = "done"; } catch (error) { + usageLogState = "idle"; console.error('Error processing usage:', error); } }; + const logFailure = (errorCode: string, errorSummary?: string) => { + if (usageLogState === "done") { + return; + } + + writeUsageFailureEvent(c, usageContext, { errorCode, errorSummary }); + }; + return { channel: { key: targetChannelKey, config: targetChannelConfig }, requestBody, saveUsage, + logFailure, + trackingState, }; } diff --git a/src/providers/shared/provider-registry.ts b/src/providers/shared/provider-registry.ts index 20beb08..e501c62 100644 --- a/src/providers/shared/provider-registry.ts +++ b/src/providers/shared/provider-registry.ts @@ -11,7 +11,8 @@ export type ProviderFetch = ( c: Context, config: ChannelConfig, requestBody: any, - saveUsage: (usage: Usage) => Promise + saveUsage: (usage: Usage) => Promise, + trackingState: RequestTrackingState, ) => Promise const providerMap: Record = { diff --git a/src/providers/shared/upstream-retry.ts b/src/providers/shared/upstream-retry.ts index d359518..2b172ab 100644 --- a/src/providers/shared/upstream-retry.ts +++ b/src/providers/shared/upstream-retry.ts @@ -5,6 +5,10 @@ import { MAX_ROTATION_ATTEMPTS, normalizeChannelConfig, } from "../../channel-config"; +import { + summarizeErrorFromResponse, + summarizeErrorFromUnknown, +} from "../../analytics/usage-logger"; import { ProviderFetch } from "./provider-registry"; const RETRYABLE_STATUS_CODES = new Set([401, 403, 408, 409, 429, 500, 502, 503, 504, 529]); @@ -56,6 +60,8 @@ export const executeWithChannelKeys = async ( config: ChannelConfig, requestBody: any, saveUsage: (usage: Usage) => Promise, + logFailure: (errorCode: string, errorSummary?: string) => void, + trackingState: RequestTrackingState, provider: ProviderFetch, ): Promise => { const normalizedConfig = normalizeChannelConfig(config); @@ -77,6 +83,7 @@ export const executeWithChannelKeys = async ( const isLastKey = keyIndex === candidateKeys.length - 1; try { + trackingState.retryCount = keyIndex * attemptsPerKey + attemptIndex; const runtimeConfig: ChannelConfig = { ...normalizedConfig, api_key: currentKey, @@ -88,6 +95,7 @@ export const executeWithChannelKeys = async ( runtimeConfig, cloneRequestBody(requestBody), saveUsage, + trackingState, ); if (response.ok) { @@ -95,6 +103,9 @@ export const executeWithChannelKeys = async ( } if (!shouldRetryResponse(response)) { + const errorSummary = await summarizeErrorFromResponse(response); + trackingState.errorSummary = errorSummary; + logFailure(`http_${response.status}`, errorSummary); return response; } @@ -106,6 +117,9 @@ export const executeWithChannelKeys = async ( ); if (isLastAttemptForKey && isLastKey) { + const errorSummary = await summarizeErrorFromResponse(response); + trackingState.errorSummary = errorSummary; + logFailure(`http_${response.status}`, errorSummary); return response; } @@ -119,6 +133,9 @@ export const executeWithChannelKeys = async ( ); if (isLastAttemptForKey && isLastKey) { + trackingState.upstreamStatus = 0; + trackingState.errorSummary = summarizeErrorFromUnknown(error); + logFailure("upstream_exception", trackingState.errorSummary); const message = error instanceof Error ? error.message : "Unknown upstream error"; return c.text(`Upstream request failed: ${message}`, 502); } diff --git a/type.d.ts b/type.d.ts index d43aac5..07ac46c 100644 --- a/type.d.ts +++ b/type.d.ts @@ -2,11 +2,26 @@ type Variables = { lang: string | undefined | null } +type AnalyticsEngineDataPoint = { + blobs?: string[]; + doubles?: number[]; + indexes?: string[]; +} + +interface AnalyticsEngineDataset { + writeDataPoint: (point: AnalyticsEngineDataPoint) => void; + writeDataPoints?: (points: AnalyticsEngineDataPoint[]) => void; +} + type CloudflareBindings = { DB: D1Database; ASSETS: Fetcher; ADMIN_TOKEN: string; + CF_API_TOKEN?: string; + CF_ACCOUNT_ID?: string; FRONTEND_DEV_SERVER_URL?: string; + USAGE_ANALYTICS?: AnalyticsEngineDataset; + USAGE_ANALYTICS_DATASET?: string; } type HonoCustomType = { @@ -103,3 +118,9 @@ type ApiTokenData = { channel_keys: string[]; total_quota: number; } + +type RequestTrackingState = { + retryCount: number; + upstreamStatus?: number; + errorSummary?: string; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index e9a89bd..5849065 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -4,7 +4,9 @@ "main": "src/index.ts", "compatibility_date": "2025-04-28", "workers_dev": true, - "vars": {}, + "vars": { + "USAGE_ANALYTICS_DATASET": "usage_events_by_token" + }, "secrets": { "required": [ "ADMIN_TOKEN", @@ -24,5 +26,11 @@ "database_name": "one-api-workers", "database_id": "" } + ], + "analytics_engine_datasets": [ + { + "binding": "USAGE_ANALYTICS", + "dataset": "usage_events_by_token" + } ] }