feat: add session log usage tracking without proxy

Parse Claude Code JSONL session files (~/.claude/projects/) and Codex
SQLite database (~/.codex/state_5.sqlite) to track API usage without
requiring proxy interception. This enables usage statistics for users
who don't use the proxy feature.

Key changes:
- Add session_usage.rs: incremental JSONL parser with message.id dedup
- Add session_usage_codex.rs: import thread-level token data from Codex
- Add data_source column to proxy_request_logs (proxy/session_log/codex_db)
- Add session_log_sync table for tracking parse offsets
- Background sync every 60s + manual sync via DataSourceBar UI
- Schema migration v7→v8
- i18n support for zh/en/ja
This commit is contained in:
Jason
2026-04-06 10:54:09 +08:00
parent 2d581bce91
commit 154342ca00
16 changed files with 1238 additions and 5 deletions
+122
View File
@@ -0,0 +1,122 @@
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import { usageKeys } from "@/lib/query/usage";
import { Database, FileText, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { toast } from "sonner";
interface DataSourceBarProps {
refreshIntervalMs: number;
}
const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
proxy: <Database className="h-3.5 w-3.5" />,
session_log: <FileText className="h-3.5 w-3.5" />,
codex_db: <Database className="h-3.5 w-3.5" />,
};
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [syncing, setSyncing] = useState(false);
const { data: sources } = useQuery({
queryKey: [...usageKeys.all, "data-sources"],
queryFn: usageApi.getDataSourceBreakdown,
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
refetchIntervalInBackground: false,
});
const handleSync = async () => {
setSyncing(true);
try {
const result = await usageApi.syncSessionUsage();
if (result.imported > 0) {
toast.success(
t("usage.sessionSync.imported", {
count: result.imported,
defaultValue: "Imported {{count}} records from session logs",
}),
);
// Refresh all usage data
queryClient.invalidateQueries({ queryKey: usageKeys.all });
} else {
toast.info(
t("usage.sessionSync.upToDate", {
defaultValue: "Session logs are up to date",
}),
);
}
} catch {
toast.error(
t("usage.sessionSync.failed", {
defaultValue: "Session sync failed",
}),
);
} finally {
setSyncing(false);
}
};
if (!sources || sources.length === 0) {
return null;
}
const hasNonProxy = sources.some((s) => s.dataSource !== "proxy");
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground bg-muted/30 rounded-lg px-4 py-2">
<span className="font-medium text-foreground/70">
{t("usage.dataSources", { defaultValue: "Data Sources" })}:
</span>
<div className="flex items-center gap-3 flex-wrap">
{sources.map((source) => (
<div
key={source.dataSource}
className="flex items-center gap-1.5 bg-background/50 rounded-md px-2 py-1"
>
{DATA_SOURCE_ICONS[source.dataSource] ?? (
<Database className="h-3.5 w-3.5" />
)}
<span>
{t(`usage.dataSource.${source.dataSource}`, {
defaultValue: source.dataSource,
})}
</span>
<span className="font-mono font-medium text-foreground/80">
{source.requestCount.toLocaleString()}
</span>
</div>
))}
</div>
<div className="ml-auto">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleSync}
disabled={syncing}
title={t("usage.sessionSync.trigger", {
defaultValue: "Sync session logs",
})}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span className="ml-1">
{hasNonProxy
? t("usage.sessionSync.resync", { defaultValue: "Sync" })
: t("usage.sessionSync.import", {
defaultValue: "Import Sessions",
})}
</span>
</Button>
</div>
</div>
);
}
+19 -1
View File
@@ -371,13 +371,16 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
<TableHead className="whitespace-nowrap">
{t("usage.status")}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("usage.source", { defaultValue: "Source" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={11}
colSpan={12}
className="text-center text-muted-foreground"
>
{t("usage.noData")}
@@ -506,6 +509,21 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
{log.statusCode}
</span>
</TableCell>
<TableCell>
{log.dataSource && log.dataSource !== "proxy" ? (
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-indigo-100 text-indigo-800">
{t(`usage.dataSource.${log.dataSource}`, {
defaultValue: log.dataSource,
})}
</span>
) : (
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600">
{t("usage.dataSource.proxy", {
defaultValue: "Proxy",
})}
</span>
)}
</TableCell>
</TableRow>
))
)}
+3
View File
@@ -6,6 +6,7 @@ import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import { DataSourceBar } from "./DataSourceBar";
import type { TimeRange } from "@/types/usage";
import { motion } from "framer-motion";
import {
@@ -100,6 +101,8 @@ export function UsageDashboard() {
</Tabs>
</div>
<DataSourceBar refreshIntervalMs={refreshIntervalMs} />
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />