feat(analytics): 添加使用情况日志记录功能

- 新增 usage-logger.ts 文件,包含使用情况日志记录的逻辑
- 在多个代理中添加 trackingState 参数以跟踪请求状态
- 记录成功和失败的使用情况事件
- 更新相关类型定义以支持新的日志记录功能
- 配置 wrangler.jsonc 以启用使用情况分析数据集
This commit is contained in:
Jeff Gu committed 2026-04-11 08:57:01 +08:00
1 parent 7707de2b3e
commit 9485366765
28 files changed
+2938 -24

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
- 🚀 **基于 Cloudflare Workers**:全球分布式网络、低延迟、高性能 - 🚀 **基于 Cloudflare Workers**:全球分布式网络、低延迟、高性能
- 🔐 **多渠道**:支持 OpenAI、Gemini、Azure OpenAI、Claude 等多种 AI 服务商 - 🔐 **多渠道**:支持 OpenAI、Gemini、Azure OpenAI、Claude 等多种 AI 服务商
- 🎫 **负载均衡**:支持多KEY/渠道间分配请求,自动重试+自动轮换,确保高可用性 - 🎫 **负载均衡**:支持多KEY/渠道间分配请求,自动重试+自动轮换,确保高可用性
- 📊 **用量统计<sup>*</sup>**:Analytics Engine 实时统计,精确成本核算与日志回溯 - 📊 **用量统计**:Analytics Engine 实时统计,支持日志回溯便于成本分析
- 💰 **定价管理**:灵活的模型定价配置 - 💰 **定价管理**:灵活的模型定价配置
- 🎨 **Web 管理界面**:直观的 Web 界面进行配置管理 - 🎨 **Web 管理界面**:直观的 Web 界面进行配置管理
- 🧪 **API 测试工具**:内置 API 测试功能,支持实时调试 - 🧪 **API 测试工具**:内置 API 测试功能,支持实时调试
+20 -1
View File
@@ -5,6 +5,8 @@ import { AppLayout } from "./components/layout/AppLayout";
import { Toaster } from "./components/ui/toaster"; import { Toaster } from "./components/ui/toaster";
import { useAuthStore } from "./store/auth"; import { useAuthStore } from "./store/auth";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { Analytics } from "./pages/Analytics";
import { UsageLogs } from "./pages/UsageLogs";
import { Channels } from "./pages/Channels"; import { Channels } from "./pages/Channels";
import { Tokens } from "./pages/Tokens"; import { Tokens } from "./pages/Tokens";
import { Pricing } from "./pages/Pricing"; import { Pricing } from "./pages/Pricing";
@@ -42,7 +44,7 @@ function HomeRoute() {
} }
if (isAuthenticated) { if (isAuthenticated) {
return <Navigate to="/channels" replace />; return <Navigate to="/dashboard" replace />;
} }
return <Dashboard />; return <Dashboard />;
@@ -61,6 +63,7 @@ function App() {
<AppLayout> <AppLayout>
<Routes> <Routes>
<Route path="/" element={<HomeRoute />} /> <Route path="/" element={<HomeRoute />} />
<Route path="/analytics" element={<Navigate to="/dashboard" replace />} />
<Route <Route
path="/api-test" path="/api-test"
element={ element={
@@ -69,6 +72,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Analytics />
</ProtectedRoute>
}
/>
<Route
path="/usage-logs"
element={
<ProtectedRoute>
<UsageLogs />
</ProtectedRoute>
}
/>
<Route <Route
path="/channels" path="/channels"
element={ element={
+58 -1
View File
@@ -1,4 +1,16 @@
import { ApiResponse, AudioTestResponse, TestResponse } from '@/types' import {
ApiResponse,
AudioTestResponse,
TestResponse,
AnalyticsOverviewData,
AnalyticsTrendData,
AnalyticsBreakdownData,
AnalyticsEventsData,
AnalyticsBreakdownDimension,
AnalyticsRange,
UsageLogFilters,
UsageLogSearchData,
} from '@/types'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '' const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
@@ -121,6 +133,19 @@ function buildAudioFilename(endpoint: string, contentType: string): string {
return `${suffix}.${extension}` return `${suffix}.${extension}`
} }
function buildQueryString(params: Record<string, string | number | undefined>): 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<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${BASE_URL}${endpoint}` const url = `${BASE_URL}${endpoint}`
@@ -194,6 +219,38 @@ export const apiClient = {
body: JSON.stringify(config), body: JSON.stringify(config),
}), }),
// Analytics APIs
getAnalyticsOverview: (range: AnalyticsRange) =>
request<ApiResponse<AnalyticsOverviewData>>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }),
getAnalyticsTrend: (range: AnalyticsRange) =>
request<ApiResponse<AnalyticsTrendData>>(`/api/admin/analytics/trend?range=${encodeURIComponent(range)}`, { method: 'GET' }),
getAnalyticsBreakdown: (range: AnalyticsRange, dimension: AnalyticsBreakdownDimension) =>
request<ApiResponse<AnalyticsBreakdownData>>(
`/api/admin/analytics/breakdown?range=${encodeURIComponent(range)}&dimension=${encodeURIComponent(dimension)}`,
{ method: 'GET' }
),
getAnalyticsEvents: (range: AnalyticsRange, limit = 40) =>
request<ApiResponse<AnalyticsEventsData>>(
`/api/admin/analytics/events?range=${encodeURIComponent(range)}&limit=${encodeURIComponent(String(limit))}`,
{ method: 'GET' }
),
getUsageLogs: (filters: UsageLogFilters) =>
request<ApiResponse<UsageLogSearchData>>(
`/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,不走通用拦截器 // API Test - 使用自定义 token,不走通用拦截器
testApi: async (endpoint: string, token: string, body: unknown): Promise<TestResponse> => { testApi: async (endpoint: string, token: string, body: unknown): Promise<TestResponse> => {
const url = `${BASE_URL}${endpoint}` const url = `${BASE_URL}${endpoint}`
@@ -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 (
<Card className="overflow-hidden border-0">
<CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
Array.from({ length: 4 }, (_, index) => (
<div key={index} className="rounded-2xl border bg-muted/20 p-3 animate-pulse">
<div className="h-4 w-1/3 rounded bg-muted" />
<div className="mt-3 h-2 rounded-full bg-muted" />
</div>
))
) : isError ? (
<div className="rounded-2xl border border-dashed bg-muted/10 px-4 py-8 text-center text-sm text-muted-foreground">
排行数据加载失败
</div>
) : visibleItems.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-muted/10 px-4 py-8 text-center text-sm text-muted-foreground">
当前时间范围暂无维度排行数据
</div>
) : (
visibleItems.map((item, index) => {
const scaleValue = useCostScale ? item.totalCost : item.requests;
const width = scaleValue > 0 ? Math.max((scaleValue / maxScaleValue) * 100, 6) : 0;
return (
<div key={`${title}-${item.label}-${index}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
badgeClassName || "bg-primary/10 text-primary",
)}
>
{index + 1}
</span>
<div className="truncate font-medium">
{item.label}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>请求 {formatCompactNumber(item.requests)}</span>
<span>成功率 {formatPercent(item.successRate)}</span>
<span>输出 {formatCompactNumber(item.completionTokens)}</span>
</div>
</div>
</div>
</div>
<div className="text-right">
<div className="font-semibold">
{useCostScale ? formatCurrency(item.totalCost) : formatCompactNumber(item.requests)}
</div>
<div className="text-xs text-muted-foreground">{Math.round(item.avgLatencyMs)} ms</div>
</div>
</div>
<div className="mt-2 h-2 bg-muted/60">
<div
className={cn("h-2 bg-gradient-to-r from-primary to-primary/50", barClassName)}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
})
)}
</CardContent>
</Card>
);
}
@@ -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 (
<div className="space-y-5">
<div className="overflow-x-auto rounded-2xl bg-card p-8 pb-6">
<div className="w-full" style={{ minWidth: `${chartMinWidth}px` }}>
<div className="relative">
<div className="pointer-events-none absolute inset-x-0 top-0 h-56">
{yTicks.map((tick) => (
<div
key={`${tick.value}-${tick.top}`}
className="absolute inset-x-0 flex items-center"
style={{ top: tick.top }}
>
<span className="w-10 -translate-y-1/2 pr-3 text-right text-[11px] text-muted-foreground">
{formatCompactNumber(Math.round(tick.value))}
</span>
{/* <div className="h-px flex-1 bg-border/20" /> */}
</div>
))}
</div>
<div
className="ml-12 grid gap-1"
style={{ gridTemplateColumns: `repeat(${points.length}, minmax(0, 1fr))` }}
>
{points.map((point) => {
const label = formatAxisLabel(point.timestamp, range);
const barHeight = Math.max((point.requests / peakRequests) * 100, point.requests > 0 ? 6 : 0);
return (
<div key={`${point.timestamp}-${point.requests}`} className="min-w-0">
<div className="flex h-56 items-end bg-muted/50">
<div
className="w-full bg-gradient-to-t from-sky-500 to-cyan-400 transition-all duration-300"
style={{ height: `${barHeight}%` }}
/>
</div>
<div className="mt-4 text-center text-[11px] text-muted-foreground">{label}</div>
<div className="text-center text-sm font-medium">{formatCompactNumber(point.requests)}</div>
<div className="text-center text-[11px] text-muted-foreground">
{formatCurrency(point.totalCost)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function AppLayout({ children }: AppLayoutProps) {
setAdminToken('') setAdminToken('')
setIsMobileNavOpen(false) setIsMobileNavOpen(false)
addToast('登录成功', 'success') addToast('登录成功', 'success')
navigate('/channels', { replace: true }) navigate('/dashboard', { replace: true })
} catch (error) { } catch (error) {
setAuthError(error instanceof Error ? error.message : '管理员令牌无效') setAuthError(error instanceof Error ? error.message : '管理员令牌无效')
} finally { } finally {
@@ -4,6 +4,8 @@ import {
Link as LinkIcon, Link as LinkIcon,
Key, Key,
DollarSign, DollarSign,
BarChart3,
FileText,
TestTube2, TestTube2,
GitBranch, GitBranch,
Moon, Moon,
@@ -24,6 +26,8 @@ interface NavItem {
} }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ title: "总览看板", href: "/dashboard", icon: BarChart3 },
{ title: "使用日志", href: "/usage-logs", icon: FileText },
{ title: "渠道管理", href: "/channels", icon: LinkIcon }, { title: "渠道管理", href: "/channels", icon: LinkIcon },
{ title: "令牌管理", href: "/tokens", icon: Key }, { title: "令牌管理", href: "/tokens", icon: Key },
{ title: "定价管理", href: "/pricing", icon: DollarSign }, { title: "定价管理", href: "/pricing", icon: DollarSign },
+278
View File
@@ -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 (
<div className="rounded-xl border border-dashed bg-muted/20 px-4 py-10 text-center">
<p className="text-sm font-medium">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
);
};
export function Analytics() {
const [range, setRange] = useState<AnalyticsRange>("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<AnalyticsBreakdownDimension, AnalyticsBreakdownData | undefined> = {
token: tokenBreakdownQuery.data,
channel: channelBreakdownQuery.data,
model: modelBreakdownQuery.data,
provider: providerBreakdownQuery.data,
};
const breakdownStateMap: Record<AnalyticsBreakdownDimension, { isLoading: boolean; isError: boolean }> = {
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 (
<PageContainer
title="总览看板"
description="聚合查看请求、成本、成功率与关键维度分布。详细排查请进入使用日志页面。"
actions={
<div className="flex items-center gap-2">
<div className="w-28">
<Select className="h-9" value={range} onChange={(event) => setRange(event.target.value as AnalyticsRange)}>
{RANGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
</div>
}
>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>请求总量</CardDescription>
<CardTitle className="text-3xl">{formatCompactNumber(overview?.totals.requests || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>成功 {formatCompactNumber(overview?.totals.successes || 0)}</span>
<Activity className="h-4 w-4 text-sky-500" />
</CardContent>
</Card>
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>成功率</CardDescription>
<CardTitle className="text-3xl">{formatPercent(overview?.totals.successRate || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>失败 {formatCompactNumber(overview?.totals.failures || 0)}</span>
<ShieldCheck className="h-4 w-4 text-emerald-500" />
</CardContent>
</Card>
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>输入 Tokens</CardDescription>
<CardTitle className="text-3xl">{formatCompactNumber(overview?.totals.promptTokens || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>总计 {formatCompactNumber(overview?.totals.totalTokens || 0)}</span>
<DatabaseZap className="h-4 w-4 text-violet-500" />
</CardContent>
</Card>
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>输出 Tokens</CardDescription>
<CardTitle className="text-3xl">{formatCompactNumber(overview?.totals.completionTokens || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>{formatCurrency(overview?.totals.totalCost || 0)}</span>
<CircleDollarSign className="h-4 w-4 text-amber-500" />
</CardContent>
</Card>
<Card className="border-0">
<CardHeader className="pb-3">
<CardDescription>平均延迟</CardDescription>
<CardTitle className="text-3xl">{formatDuration(overview?.totals.avgLatencyMs || 0)}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<span>{RANGE_OPTIONS.find((option) => option.value === range)?.label}</span>
<Clock3 className="h-4 w-4 text-slate-500" />
</CardContent>
</Card>
</div>
{!trend || trend.points.length === 0 ? (
<EmptyState title="暂无趋势数据" description="写入第一批使用事件后,这里会展示完整柱状趋势。" />
) : (
<TrendBarChart points={trend.points} range={range} />
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
{BREAKDOWN_CHARTS.map((chart) => (
<BreakdownChartCard
key={chart.dimension}
title={chart.title}
description={chart.description}
items={breakdownDataMap[chart.dimension]?.items}
isLoading={breakdownStateMap[chart.dimension].isLoading}
isError={breakdownStateMap[chart.dimension].isError}
barClassName={chart.barClassName}
badgeClassName={chart.badgeClassName}
/>
))}
</div>
</div>
</PageContainer>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ const features = [
{ {
icon: BarChart3, icon: BarChart3,
title: "用量追踪", title: "用量追踪",
description: "Analytics Engine 实时统计,精确成本核算与日志回溯。", description: "Analytics Engine 实时统计,支持日志回溯便于成本分析。",
gradient: "from-amber-500/10 to-orange-500/10", gradient: "from-amber-500/10 to-orange-500/10",
iconBg: "from-amber-500 to-orange-500", iconBg: "from-amber-500 to-orange-500",
delay: "100ms", delay: "100ms",
+538
View File
@@ -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 (
<div className="min-w-[190px]">
<div className="font-medium">{item.clientIp || "--"}</div>
<div className="mt-1 text-xs text-muted-foreground">
{locationParts.length > 0 ? locationParts.join(" / ") : "未知位置"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.colo || "--"} {item.timezone ? `· ${item.timezone}` : ""}
</div>
</div>
);
};
const DetailField = ({ label, value, mono = false }: { label: string; value: string | number; mono?: boolean }) => {
const text = String(value || "--");
return (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
<div className={cn("mt-1 text-sm text-foreground break-all", mono && "font-mono text-xs")}>{text}</div>
</div>
);
};
export function UsageLogs() {
const defaultFilters = useMemo(() => createFilterPreset(24), []);
const [draftFilters, setDraftFilters] = useState<UsageLogFilterState>(defaultFilters);
const [appliedFilters, setAppliedFilters] = useState<UsageLogFilterState>(defaultFilters);
const [currentPage, setCurrentPage] = useState(1);
const [selectedItem, setSelectedItem] = useState<AnalyticsEventItem | null>(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 (
<PageContainer
title="使用日志"
description="按自定义起止时间与维度筛选最近可读取的使用记录,重点用于排查异常设备、失败请求和上游问题。"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => logsQuery.refetch()} disabled={logsQuery.isFetching}>
<RefreshCw className={cn("h-4 w-4", logsQuery.isFetching && "animate-spin")} />
</Button>
</div>
}
>
<div className="space-y-6">
{/* <Card className="overflow-hidden border-0 bg-gradient-to-br from-slate-950 to-slate-900 text-slate-50">
<CardContent className="p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/6 px-3 py-1 text-xs uppercase tracking-[0.18em] text-slate-300">
<ShieldAlert className="h-3.5 w-3.5" />
Diagnostics
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-tight">面向排障的请求日志视图</h2>
<p className="mt-2 text-sm leading-6 text-slate-300">
记录 request id、trace id、错误摘要、IP、UA 与地理位置,适合定位某一批异常设备或单个失败链路。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => applyPreset(24)}>最近 24 小时</Button>
<Button variant="secondary" size="sm" onClick={() => applyPreset(24 * 7)}>最近 7 天</Button>
<Button variant="secondary" size="sm" onClick={() => applyPreset(24 * 30)}>最近 30 天</Button>
</div>
</div>
</CardContent>
</Card> */}
<Card>
<CardContent className="pt-6 space-y-4">
{logsQuery.data?.compatibilityWarning && (
<Alert>
<AlertDescription>{logsQuery.data.compatibilityWarning}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">开始时间</label>
<Input
className="block"
type="datetime-local"
value={draftFilters.start}
onChange={(event) => setDraftFilters((current) => ({ ...current, start: event.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">结束时间</label>
<Input
className="block"
type="datetime-local"
value={draftFilters.end}
onChange={(event) => setDraftFilters((current) => ({ ...current, end: event.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">查询维度</label>
<Select
value={draftFilters.dimension}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
dimension: event.target.value as UsageLogFilterDimension,
}))
}
>
{FILTER_DIMENSIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">关键字</label>
<Input
value={draftFilters.keyword}
onChange={(event) => setDraftFilters((current) => ({ ...current, keyword: event.target.value }))}
placeholder="支持 ILIKE 模糊匹配"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">结果过滤</label>
<Select
value={draftFilters.result}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
result: event.target.value as UsageLogFilterState["result"],
}))
}
>
{RESULT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div className="flex flex-wrap items-end gap-2 sm:col-end-4 xl:col-end-5">
<div className="flex-1"></div>
<Button onClick={handleSearch}>
<Search className="h-4 w-4" />
查询日志
</Button>
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="h-4 w-4" />
重置
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">日志结果</CardTitle>
<CardDescription>日志可能存在 30 秒左右的延迟</CardDescription>
</CardHeader>
<CardContent>
{logsQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">日志加载中...</div>
) : logsQuery.data?.items.length ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-2 py-3 font-medium">时间</th>
<th className="px-2 py-3 font-medium">模型 / 令牌</th>
<th className="px-2 py-3 font-medium">端点 / 渠道</th>
<th className="px-2 py-3 font-medium">结果 / 状态</th>
<th className="px-2 py-3 font-medium">方式 / 用时</th>
<th className="px-2 py-3 font-medium">资源消耗</th>
<th className="px-2 py-3 font-medium text-right">详情</th>
</tr>
</thead>
<tbody>
{logsQuery.data.items.map((item) => (
<tr
key={`${item.requestId || item.timestamp}-${item.traceId || item.channelKey}`}
className="border-b align-top last:border-0"
>
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">{formatDateTime(item.timestamp)}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.requestId || "--"}</div>
</td>
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">{item.requestedModel || "--"}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.tokenName || "未命名令牌"}</div>
</td>
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">{item.providerType || "--"}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.channelKey || "--"}</div>
</td>
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">
<span
className={cn(
item.result === "success"
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400",
)}
>
{item.result === "success" ? "成功" : "失败"}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.upstreamStatus || "--"} · 重试 {item.retryCount}
</div>
</td>
<td className="px-2 py-3">
<div className="text-xs text-muted-foreground">
{item.streamMode === "stream" ? "流式" : "非流式"}
</div>
<div className="mt-1 text-xs text-muted-foreground">{formatDuration(item.latencyMs)}</div>
</td>
<td className="px-2 py-3">
{/* <div className="font-medium">{formatCompactNumber(item.totalTokens)} tokens</div> */}
<div className="text-xs text-muted-foreground">
输入 {formatCompactNumber(item.promptTokens)} / 输出{" "}
{formatCompactNumber(item.completionTokens)}
</div>
<div className="mt-1 text-xs text-muted-foreground">{formatCurrency(item.totalCost)}</div>
</td>
<td className="px-2 py-3 text-right">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setSelectedItem(item)}
aria-label="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">
{totalItems > 0 ? `显示第 ${currentStart}-${currentEnd} 条,共 ${totalItems} 条` : "暂无记录"}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(activePage - 1)}
disabled={!logsQuery.data?.hasPrevPage || logsQuery.isFetching}
>
上一页
</Button>
{visiblePages.map((page) => (
<Button
key={page}
variant={page === activePage ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page)}
disabled={logsQuery.isFetching}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(activePage + 1)}
disabled={!logsQuery.data?.hasNextPage || logsQuery.isFetching}
>
下一页
</Button>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-dashed bg-muted/20 px-4 py-14 text-center">
<p className="text-sm font-medium">没有匹配的日志记录</p>
<p className="mt-1 text-sm text-muted-foreground">调整起止时间、维度或关键字后重试。</p>
</div>
)}
</CardContent>
</Card>
</div>
<Dialog open={Boolean(selectedItem)} onOpenChange={(open) => !open && setSelectedItem(null)}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>使用日志详情</DialogTitle>
<DialogDescription>查看本次请求的完整上下文、客户端信息、错误摘要与资源消耗。</DialogDescription>
</DialogHeader>
{selectedItem && (
<div className="max-h-[70vh] space-y-6 overflow-y-auto pr-1">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<DetailField label="时间" value={formatDateTime(selectedItem.timestamp)} />
<DetailField label="结果" value={selectedItem.result === "success" ? "成功" : "失败"} />
<DetailField label="HTTP 状态" value={selectedItem.upstreamStatus || "--"} />
<DetailField label="状态族" value={selectedItem.statusFamily || "--"} />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<DetailField label="令牌名称" value={selectedItem.tokenName || "--"} />
<DetailField label="渠道标识" value={selectedItem.channelKey || "--"} />
<DetailField label="服务商" value={selectedItem.providerType || "--"} />
<DetailField label="接口路由" value={selectedItem.routeId || "--"} />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<DetailField label="请求模型" value={selectedItem.requestedModel || "--"} />
<DetailField label="上游模型" value={selectedItem.upstreamModel || "--"} />
<DetailField label="流式模式" value={selectedItem.streamMode === "stream" ? "流式" : "非流式"} />
<DetailField label="重试次数" value={selectedItem.retryCount} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<DetailField label="Request ID" value={selectedItem.requestId || "--"} mono />
<DetailField label="Trace ID" value={selectedItem.traceId || "--"} mono />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<DetailField label="客户端 IP" value={selectedItem.clientIp || "--"} mono />
<DetailField
label="地理位置"
value={
[selectedItem.country, selectedItem.region, selectedItem.city].filter(Boolean).join(" / ") || "--"
}
/>
<DetailField
label="边缘节点 / 时区"
value={[selectedItem.colo, selectedItem.timezone].filter(Boolean).join(" · ") || "--"}
/>
</div>
<div className="grid gap-3">
<DetailField label="User-Agent" value={selectedItem.userAgent || "--"} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<DetailField label="错误码" value={selectedItem.errorCode || "--"} />
<DetailField label="错误摘要" value={selectedItem.errorSummary || "--"} />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<DetailField label="总 Tokens" value={formatCompactNumber(selectedItem.totalTokens)} />
<DetailField
label="输入 / 输出 Tokens"
value={`${formatCompactNumber(selectedItem.promptTokens)} / ${formatCompactNumber(selectedItem.completionTokens)}`}
/>
<DetailField label="总成本" value={formatCurrency(selectedItem.totalCost)} />
<DetailField label="延迟" value={formatDuration(selectedItem.latencyMs)} />
</div>
</div>
)}
</DialogContent>
</Dialog>
</PageContainer>
);
}
+135
View File
@@ -44,6 +44,141 @@ export interface PricingModel {
export type PricingConfig = Record<string, PricingModel> export type PricingConfig = Record<string, PricingModel>
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<T = any> { export interface ApiResponse<T = any> {
data?: T data?: T
error?: string error?: string
+205
View File
@@ -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<HonoCustomType>,
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<HonoCustomType>) {
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<HonoCustomType>) {
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<HonoCustomType>) {
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<HonoCustomType>) {
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<HonoCustomType>) {
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");
}
}
}
+14
View File
@@ -11,6 +11,13 @@ import {
import { import {
PricingGetEndpoint, PricingUpdateEndpoint PricingGetEndpoint, PricingUpdateEndpoint
} from "./pricing_api" } from "./pricing_api"
import {
AnalyticsOverviewEndpoint,
AnalyticsTrendEndpoint,
AnalyticsBreakdownEndpoint,
AnalyticsEventsEndpoint,
UsageLogSearchEndpoint,
} from "./analytics_api"
const app = new Hono<HonoCustomType>() const app = new Hono<HonoCustomType>()
export const api = fromHono(app) export const api = fromHono(app)
@@ -47,3 +54,10 @@ api.delete("/api/admin/token/:key", TokenDeleteEndpoint)
// Pricing management routes // Pricing management routes
api.get("/api/admin/pricing", PricingGetEndpoint) api.get("/api/admin/pricing", PricingGetEndpoint)
api.post("/api/admin/pricing", PricingUpdateEndpoint) 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)
+64 -14
View File
@@ -3,6 +3,15 @@ import { Context } from "hono";
import { CONSTANTS } from "../constants"; import { CONSTANTS } from "../constants";
import { getJsonSetting } from "../utils"; import { getJsonSetting } from "../utils";
type UsageCostResult = {
totalCost: number;
requestCost: number;
inputCost: number;
outputCost: number;
cacheCost: number;
hasPricing: boolean;
}
// Token 工具对象 // Token 工具对象
export const TokenUtils = { export const TokenUtils = {
async updateUsage(c: Context<HonoCustomType>, key: string, usageAmount: number): Promise<boolean> { async updateUsage(c: Context<HonoCustomType>, key: string, usageAmount: number): Promise<boolean> {
@@ -28,34 +37,75 @@ export const TokenUtils = {
return globalPricingMap?.[model] || null; return globalPricingMap?.[model] || null;
}, },
async processUsage(c: Context<HonoCustomType>, apiKey: string, model: string, targetChannelKey: string, targetChannelConfig: ChannelConfig, usage: Usage): Promise<void> { async calculateUsageCost(
console.log("Usage data:", usage); c: Context<HonoCustomType>,
model: string,
targetChannelConfig: ChannelConfig,
usage: Usage
): Promise<UsageCostResult> {
const pricing = await this.getPricing(c, model, targetChannelConfig); const pricing = await this.getPricing(c, model, targetChannelConfig);
const hasTokens = usage.prompt_tokens != null && usage.completion_tokens != null; 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)) { if (!pricing || (!hasTokens && requestCost <= 0)) {
const inputCost = hasTokens ? usage.prompt_tokens! * pricing.input : 0; return {
const outputCost = hasTokens ? usage.completion_tokens! * pricing.output : 0; totalCost: 0,
requestCost,
inputCost: 0,
outputCost: 0,
cacheCost: 0,
hasPricing: false,
};
}
let cacheCost = 0; const inputCost = hasTokens ? usage.prompt_tokens! * pricing.input : 0;
if (hasTokens && usage.cached_tokens && usage.cached_tokens > 0 && pricing.cache) { const outputCost = hasTokens ? usage.completion_tokens! * pricing.output : 0;
cacheCost = usage.cached_tokens * pricing.cache;
}
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<HonoCustomType>,
apiKey: string,
model: string,
targetChannelKey: string,
targetChannelConfig: ChannelConfig,
usage: Usage
): Promise<UsageCostResult> {
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) : ( const maskedApiKey = apiKey.length < 3 ? '*'.repeat(apiKey.length) : (
apiKey.slice(0, apiKey.length / 3) apiKey.slice(0, apiKey.length / 3)
+ '*'.repeat(apiKey.length / 3) + '*'.repeat(apiKey.length / 3)
+ apiKey.slice((2 * 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 { } else {
console.warn(`No pricing found for model: ${model} in channel: ${targetChannelKey}`); console.warn(`No pricing found for model: ${model} in channel: ${targetChannelKey}`);
} }
return costResult;
} }
}; };
File diff suppressed because it is too large. Load diff
+275
View File
@@ -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 | null | undefined>): 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<HonoCustomType>): string => {
return c.env.USAGE_ANALYTICS_DATASET || DEFAULT_USAGE_ANALYTICS_DATASET_NAME;
};
const getAnalyticsBinding = (c: Context<HonoCustomType>): AnalyticsEngineDataset | null => {
if (!c.env.USAGE_ANALYTICS || !getDatasetName(c)) {
return null;
}
return c.env.USAGE_ANALYTICS;
};
const writeDataPoint = (
c: Context<HonoCustomType>,
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<string, unknown>;
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<string> => {
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<HonoCustomType>) => {
const requestCf = (c.req.raw.cf || {}) as Partial<IncomingRequestCfProperties<unknown>>;
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<HonoCustomType>,
context: UsageLogContext,
usage: Usage,
costResult: UsageCostResult
) => {
writeDataPoint(c, buildCommonPoint(context, usage, costResult.totalCost, "success", "", ""));
};
export const writeUsageFailureEvent = (
c: Context<HonoCustomType>,
context: UsageLogContext,
params: FailureLogParams
) => {
writeDataPoint(
c,
buildCommonPoint(
context,
{},
0,
"failure",
params.errorCode,
params.errorSummary || context.trackingState.errorSummary || ""
)
);
};
export const hashTokenKey = async (value: string): Promise<string> => {
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);
};
+2
View File
@@ -29,6 +29,7 @@ export default {
config: ChannelConfig, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void>, saveUsage: (usage: Usage) => Promise<void>,
trackingState: RequestTrackingState,
): Promise<Response> { ): Promise<Response> {
const { stream } = requestBody; const { stream } = requestBody;
@@ -41,6 +42,7 @@ export default {
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
const response = await fetch(proxyRequest) const response = await fetch(proxyRequest)
trackingState.upstreamStatus = response.status
if (stream) { if (stream) {
const [streamForClient, streamForServer] = response.body?.tee() || [] const [streamForClient, streamForServer] = response.body?.tee() || []
@@ -32,11 +32,13 @@ export default {
config: ChannelConfig, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void>, saveUsage: (usage: Usage) => Promise<void>,
trackingState: RequestTrackingState,
): Promise<Response> { ): Promise<Response> {
const { stream } = requestBody const { stream } = requestBody
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
const response = await fetch(proxyRequest) const response = await fetch(proxyRequest)
trackingState.upstreamStatus = response.status
if (stream) { if (stream) {
const [streamForClient, streamForServer] = response.body?.tee() || [] const [streamForClient, streamForServer] = response.body?.tee() || []
+2
View File
@@ -261,6 +261,7 @@ export default {
config: ChannelConfig, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void>, saveUsage: (usage: Usage) => Promise<void>,
trackingState: RequestTrackingState,
): Promise<Response> { ): Promise<Response> {
const { stream } = requestBody; const { stream } = requestBody;
@@ -272,6 +273,7 @@ export default {
// Build and send proxy request // Build and send proxy request
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
const response = await fetch(proxyRequest) const response = await fetch(proxyRequest)
trackingState.upstreamStatus = response.status
// Handle streaming response // Handle streaming response
if (stream) { if (stream) {
+2
View File
@@ -529,6 +529,7 @@ export default {
config: ChannelConfig, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void>, saveUsage: (usage: Usage) => Promise<void>,
trackingState: RequestTrackingState,
): Promise<Response> { ): Promise<Response> {
const { stream } = requestBody; const { stream } = requestBody;
@@ -544,6 +545,7 @@ export default {
const proxyRequest = buildProxyRequest(c.req.raw, openaiReq, config); const proxyRequest = buildProxyRequest(c.req.raw, openaiReq, config);
const response = await fetch(proxyRequest); const response = await fetch(proxyRequest);
trackingState.upstreamStatus = response.status;
if (stream) { if (stream) {
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
+10 -2
View File
@@ -42,14 +42,22 @@ class UnifiedProxyEndpoint extends OpenAPIRoute {
const result = await resolveChannel(c, routeId) const result = await resolveChannel(c, routeId)
if (result instanceof Response) return result if (result instanceof Response) return result
const { channel, requestBody, saveUsage } = result const { channel, requestBody, saveUsage, logFailure, trackingState } = result
const provider = getProvider(channel.config.type || "") const provider = getProvider(channel.config.type || "")
if (!provider) { if (!provider) {
return c.text("Channel type not supported", 400) 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
)
} }
} }
+2
View File
@@ -29,6 +29,7 @@ export default {
config: ChannelConfig, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void>, saveUsage: (usage: Usage) => Promise<void>,
trackingState: RequestTrackingState,
): Promise<Response> { ): Promise<Response> {
const { stream } = requestBody; const { stream } = requestBody;
@@ -41,6 +42,7 @@ export default {
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
const response = await fetch(proxyRequest) const response = await fetch(proxyRequest)
trackingState.upstreamStatus = response.status
if (stream) { if (stream) {
const [streamForClient, streamForServer] = response.body?.tee() || [] const [streamForClient, streamForServer] = response.body?.tee() || []
+2
View File
@@ -32,11 +32,13 @@ export default {
config: ChannelConfig, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void>, saveUsage: (usage: Usage) => Promise<void>,
trackingState: RequestTrackingState,
): Promise<Response> { ): Promise<Response> {
const { stream } = requestBody const { stream } = requestBody
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config) const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
const response = await fetch(proxyRequest) const response = await fetch(proxyRequest)
trackingState.upstreamStatus = response.status
if (stream) { if (stream) {
const [streamForClient, streamForServer] = response.body?.tee() || [] const [streamForClient, streamForServer] = response.body?.tee() || []
+63 -1
View File
@@ -4,11 +4,19 @@ import { RouteId, getRoutePolicy } from "./route-policy"
import { findChannelModelMapping } from "../../utils" import { findChannelModelMapping } from "../../utils"
import { TokenUtils } from "../../admin/token_utils" import { TokenUtils } from "../../admin/token_utils"
import { normalizeChannelConfig } from "../../channel-config" import { normalizeChannelConfig } from "../../channel-config"
import {
buildUsageRequestMetadata,
hashTokenKey,
writeUsageFailureEvent,
writeUsageSuccessEvent,
} from "../../analytics/usage-logger"
export type ChannelResolution = { export type ChannelResolution = {
channel: { key: string; config: ChannelConfig } channel: { key: string; config: ChannelConfig }
requestBody: any requestBody: any
saveUsage: (usage: Usage) => Promise<void> saveUsage: (usage: Usage) => Promise<void>
logFailure: (errorCode: string, errorSummary?: string) => void
trackingState: RequestTrackingState
} }
export const resolveChannel = async ( export const resolveChannel = async (
@@ -90,6 +98,35 @@ export const resolveChannel = async (
const selectedChannel = availableChannels[randomIndex]; const selectedChannel = availableChannels[randomIndex];
const targetChannelKey = selectedChannel.key; const targetChannelKey = selectedChannel.key;
const targetChannelConfig = selectedChannel.config; 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; requestBody.model = selectedChannel.mapping.id;
@@ -98,16 +135,41 @@ export const resolveChannel = async (
} }
const saveUsage = async (usage: Usage) => { const saveUsage = async (usage: Usage) => {
if (usageLogState !== "idle") {
return;
}
usageLogState = "processing";
try { 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) { } catch (error) {
usageLogState = "idle";
console.error('Error processing usage:', error); console.error('Error processing usage:', error);
} }
}; };
const logFailure = (errorCode: string, errorSummary?: string) => {
if (usageLogState === "done") {
return;
}
writeUsageFailureEvent(c, usageContext, { errorCode, errorSummary });
};
return { return {
channel: { key: targetChannelKey, config: targetChannelConfig }, channel: { key: targetChannelKey, config: targetChannelConfig },
requestBody, requestBody,
saveUsage, saveUsage,
logFailure,
trackingState,
}; };
} }
+2 -1
View File
@@ -11,7 +11,8 @@ export type ProviderFetch = (
c: Context<HonoCustomType>, c: Context<HonoCustomType>,
config: ChannelConfig, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void> saveUsage: (usage: Usage) => Promise<void>,
trackingState: RequestTrackingState,
) => Promise<Response> ) => Promise<Response>
const providerMap: Record<string, ProviderFetch> = { const providerMap: Record<string, ProviderFetch> = {
+17
View File
@@ -5,6 +5,10 @@ import {
MAX_ROTATION_ATTEMPTS, MAX_ROTATION_ATTEMPTS,
normalizeChannelConfig, normalizeChannelConfig,
} from "../../channel-config"; } from "../../channel-config";
import {
summarizeErrorFromResponse,
summarizeErrorFromUnknown,
} from "../../analytics/usage-logger";
import { ProviderFetch } from "./provider-registry"; import { ProviderFetch } from "./provider-registry";
const RETRYABLE_STATUS_CODES = new Set([401, 403, 408, 409, 429, 500, 502, 503, 504, 529]); 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, config: ChannelConfig,
requestBody: any, requestBody: any,
saveUsage: (usage: Usage) => Promise<void>, saveUsage: (usage: Usage) => Promise<void>,
logFailure: (errorCode: string, errorSummary?: string) => void,
trackingState: RequestTrackingState,
provider: ProviderFetch, provider: ProviderFetch,
): Promise<Response> => { ): Promise<Response> => {
const normalizedConfig = normalizeChannelConfig(config); const normalizedConfig = normalizeChannelConfig(config);
@@ -77,6 +83,7 @@ export const executeWithChannelKeys = async (
const isLastKey = keyIndex === candidateKeys.length - 1; const isLastKey = keyIndex === candidateKeys.length - 1;
try { try {
trackingState.retryCount = keyIndex * attemptsPerKey + attemptIndex;
const runtimeConfig: ChannelConfig = { const runtimeConfig: ChannelConfig = {
...normalizedConfig, ...normalizedConfig,
api_key: currentKey, api_key: currentKey,
@@ -88,6 +95,7 @@ export const executeWithChannelKeys = async (
runtimeConfig, runtimeConfig,
cloneRequestBody(requestBody), cloneRequestBody(requestBody),
saveUsage, saveUsage,
trackingState,
); );
if (response.ok) { if (response.ok) {
@@ -95,6 +103,9 @@ export const executeWithChannelKeys = async (
} }
if (!shouldRetryResponse(response)) { if (!shouldRetryResponse(response)) {
const errorSummary = await summarizeErrorFromResponse(response);
trackingState.errorSummary = errorSummary;
logFailure(`http_${response.status}`, errorSummary);
return response; return response;
} }
@@ -106,6 +117,9 @@ export const executeWithChannelKeys = async (
); );
if (isLastAttemptForKey && isLastKey) { if (isLastAttemptForKey && isLastKey) {
const errorSummary = await summarizeErrorFromResponse(response);
trackingState.errorSummary = errorSummary;
logFailure(`http_${response.status}`, errorSummary);
return response; return response;
} }
@@ -119,6 +133,9 @@ export const executeWithChannelKeys = async (
); );
if (isLastAttemptForKey && isLastKey) { 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"; const message = error instanceof Error ? error.message : "Unknown upstream error";
return c.text(`Upstream request failed: ${message}`, 502); return c.text(`Upstream request failed: ${message}`, 502);
} }
Vendored
+21
View File
@@ -2,11 +2,26 @@ type Variables = {
lang: string | undefined | null lang: string | undefined | null
} }
type AnalyticsEngineDataPoint = {
blobs?: string[];
doubles?: number[];
indexes?: string[];
}
interface AnalyticsEngineDataset {
writeDataPoint: (point: AnalyticsEngineDataPoint) => void;
writeDataPoints?: (points: AnalyticsEngineDataPoint[]) => void;
}
type CloudflareBindings = { type CloudflareBindings = {
DB: D1Database; DB: D1Database;
ASSETS: Fetcher; ASSETS: Fetcher;
ADMIN_TOKEN: string; ADMIN_TOKEN: string;
CF_API_TOKEN?: string;
CF_ACCOUNT_ID?: string;
FRONTEND_DEV_SERVER_URL?: string; FRONTEND_DEV_SERVER_URL?: string;
USAGE_ANALYTICS?: AnalyticsEngineDataset;
USAGE_ANALYTICS_DATASET?: string;
} }
type HonoCustomType = { type HonoCustomType = {
@@ -103,3 +118,9 @@ type ApiTokenData = {
channel_keys: string[]; channel_keys: string[];
total_quota: number; total_quota: number;
} }
type RequestTrackingState = {
retryCount: number;
upstreamStatus?: number;
errorSummary?: string;
}
+9 -1
View File
@@ -4,7 +4,9 @@
"main": "src/index.ts", "main": "src/index.ts",
"compatibility_date": "2025-04-28", "compatibility_date": "2025-04-28",
"workers_dev": true, "workers_dev": true,
"vars": {}, "vars": {
"USAGE_ANALYTICS_DATASET": "usage_events_by_token"
},
"secrets": { "secrets": {
"required": [ "required": [
"ADMIN_TOKEN", "ADMIN_TOKEN",
@@ -24,5 +26,11 @@
"database_name": "one-api-workers", "database_name": "one-api-workers",
"database_id": "" "database_id": ""
} }
],
"analytics_engine_datasets": [
{
"binding": "USAGE_ANALYTICS",
"dataset": "usage_events_by_token"
}
] ]
} }