feat(analytics): 添加使用情况日志记录功能
- 新增 usage-logger.ts 文件,包含使用情况日志记录的逻辑 - 在多个代理中添加 trackingState 参数以跟踪请求状态 - 记录成功和失败的使用情况事件 - 更新相关类型定义以支持新的日志记录功能 - 配置 wrangler.jsonc 以启用使用情况分析数据集
This commit is contained in:
1 parent
7707de2b3e
commit
9485366765
28 files changed
+2938
-24
No files matched your search
@@ -12,7 +12,7 @@
|
||||
- 🚀 **基于 Cloudflare Workers**:全球分布式网络、低延迟、高性能
|
||||
- 🔐 **多渠道**:支持 OpenAI、Gemini、Azure OpenAI、Claude 等多种 AI 服务商
|
||||
- 🎫 **负载均衡**:支持多KEY/渠道间分配请求,自动重试+自动轮换,确保高可用性
|
||||
- 📊 **用量统计<sup>*</sup>**:Analytics Engine 实时统计,精确成本核算与日志回溯
|
||||
- 📊 **用量统计**:Analytics Engine 实时统计,支持日志回溯便于成本分析
|
||||
- 💰 **定价管理**:灵活的模型定价配置
|
||||
- 🎨 **Web 管理界面**:直观的 Web 界面进行配置管理
|
||||
- 🧪 **API 测试工具**:内置 API 测试功能,支持实时调试
|
||||
|
||||
+20
-1
@@ -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 <Navigate to="/channels" replace />;
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <Dashboard />;
|
||||
@@ -61,6 +63,7 @@ function App() {
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/analytics" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/api-test"
|
||||
element={
|
||||
@@ -69,6 +72,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Analytics />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/usage-logs"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<UsageLogs />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/channels"
|
||||
element={
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -121,6 +133,19 @@ function buildAudioFilename(endpoint: string, contentType: string): string {
|
||||
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> {
|
||||
const url = `${BASE_URL}${endpoint}`
|
||||
@@ -194,6 +219,38 @@ export const apiClient = {
|
||||
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,不走通用拦截器
|
||||
testApi: async (endpoint: string, token: string, body: unknown): Promise<TestResponse> => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,141 @@ export interface 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> {
|
||||
data?: T
|
||||
error?: string
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
import {
|
||||
PricingGetEndpoint, PricingUpdateEndpoint
|
||||
} from "./pricing_api"
|
||||
import {
|
||||
AnalyticsOverviewEndpoint,
|
||||
AnalyticsTrendEndpoint,
|
||||
AnalyticsBreakdownEndpoint,
|
||||
AnalyticsEventsEndpoint,
|
||||
UsageLogSearchEndpoint,
|
||||
} from "./analytics_api"
|
||||
|
||||
const app = new Hono<HonoCustomType>()
|
||||
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)
|
||||
+64
-14
@@ -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<HonoCustomType>, key: string, usageAmount: number): Promise<boolean> {
|
||||
@@ -28,34 +37,75 @@ export const TokenUtils = {
|
||||
return globalPricingMap?.[model] || null;
|
||||
},
|
||||
|
||||
async processUsage(c: Context<HonoCustomType>, apiKey: string, model: string, targetChannelKey: string, targetChannelConfig: ChannelConfig, usage: Usage): Promise<void> {
|
||||
console.log("Usage data:", usage);
|
||||
|
||||
async calculateUsageCost(
|
||||
c: Context<HonoCustomType>,
|
||||
model: string,
|
||||
targetChannelConfig: ChannelConfig,
|
||||
usage: Usage
|
||||
): Promise<UsageCostResult> {
|
||||
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<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) : (
|
||||
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;
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
};
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
trackingState: RequestTrackingState,
|
||||
): Promise<Response> {
|
||||
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() || []
|
||||
|
||||
@@ -32,11 +32,13 @@ export default {
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
trackingState: RequestTrackingState,
|
||||
): Promise<Response> {
|
||||
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() || []
|
||||
|
||||
@@ -261,6 +261,7 @@ export default {
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
trackingState: RequestTrackingState,
|
||||
): Promise<Response> {
|
||||
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) {
|
||||
|
||||
@@ -529,6 +529,7 @@ export default {
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
trackingState: RequestTrackingState,
|
||||
): Promise<Response> {
|
||||
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) {
|
||||
|
||||
+10
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
trackingState: RequestTrackingState,
|
||||
): Promise<Response> {
|
||||
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() || []
|
||||
|
||||
@@ -32,11 +32,13 @@ export default {
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
trackingState: RequestTrackingState,
|
||||
): Promise<Response> {
|
||||
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() || []
|
||||
|
||||
@@ -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<void>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,8 @@ export type ProviderFetch = (
|
||||
c: Context<HonoCustomType>,
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
trackingState: RequestTrackingState,
|
||||
) => Promise<Response>
|
||||
|
||||
const providerMap: Record<string, ProviderFetch> = {
|
||||
|
||||
@@ -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<void>,
|
||||
logFailure: (errorCode: string, errorSummary?: string) => void,
|
||||
trackingState: RequestTrackingState,
|
||||
provider: ProviderFetch,
|
||||
): Promise<Response> => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+9
-1
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user