mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat: add request logging functionality and usage metrics display
This commit is contained in:
@@ -8,7 +8,7 @@ import { createRequestLogger } from "../utils/requestLogger.js";
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||
import { saveRequestUsage, trackPendingRequest } from "@/lib/usageDb.js";
|
||||
import { saveRequestUsage, trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
||||
|
||||
/**
|
||||
* Extract usage from non-streaming response body
|
||||
@@ -125,6 +125,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
// Track pending request
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
// Log start
|
||||
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {});
|
||||
|
||||
// 2. Log converted request to provider
|
||||
reqLogger.logConvertedRequest(providerUrl, providerHeaders, translatedBody);
|
||||
|
||||
@@ -159,6 +162,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
});
|
||||
} catch (error) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : 502}` }).catch(() => {});
|
||||
if (error.name === "AbortError") {
|
||||
streamController.handleError(error);
|
||||
return createErrorResult(499, "Request aborted");
|
||||
@@ -254,6 +258,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
if (!providerResponse.ok) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
const { statusCode, message } = await parseUpstreamError(providerResponse);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => {});
|
||||
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||
|
||||
@@ -275,6 +280,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// Log usage for non-streaming responses
|
||||
const usage = extractUsageFromResponse(responseBody, provider);
|
||||
appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => {});
|
||||
if (usage) {
|
||||
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage.prompt_tokens || 0} | out=${usage.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
|
||||
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { translateResponse, initState } from "../translator/index.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { saveRequestUsage, trackPendingRequest } from "@/lib/usageDb.js";
|
||||
import { saveRequestUsage, trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
||||
|
||||
// Get HH:MM:SS timestamp
|
||||
function getTimeString() {
|
||||
@@ -66,6 +66,9 @@ function logUsage(provider, usage, model = null, connectionId = null) {
|
||||
|
||||
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||
|
||||
// Log to log.txt
|
||||
appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => {});
|
||||
|
||||
// Save to DB
|
||||
saveRequestUsage({
|
||||
provider: provider || "unknown",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardSkeleton, Badge, UsageStats } from "@/shared/components";
|
||||
import { Card, CardSkeleton, Badge, UsageStats, RequestLogger } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import Link from "next/link";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const [activeTab, setActiveTab] = useState("connections");
|
||||
const [usageSubTab, setUsageSubTab] = useState("overview");
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -97,7 +98,27 @@ export default function ProvidersPage() {
|
||||
</div>
|
||||
|
||||
{activeTab === "usage" ? (
|
||||
<UsageStats />
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setUsageSubTab("overview")}
|
||||
className={`text-sm font-semibold transition-colors ${
|
||||
usageSubTab === "overview" ? "text-primary" : "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUsageSubTab("logs")}
|
||||
className={`text-sm font-semibold transition-colors ${
|
||||
usageSubTab === "logs" ? "text-primary" : "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
Logger
|
||||
</button>
|
||||
</div>
|
||||
{usageSubTab === "overview" ? <UsageStats /> : <RequestLogger />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* OAuth Providers */}
|
||||
|
||||
12
src/app/api/usage/logs/route.js
Normal file
12
src/app/api/usage/logs/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRecentLogs } from "@/lib/usageDb";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const logs = await getRecentLogs(200);
|
||||
return NextResponse.json(logs);
|
||||
} catch (error) {
|
||||
console.error("Error fetching logs:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function getUserDataDir() {
|
||||
// Data file path - stored in user home directory
|
||||
const DATA_DIR = getUserDataDir();
|
||||
const DB_FILE = path.join(DATA_DIR, "usage.json");
|
||||
const LOG_FILE = path.join(DATA_DIR, "log.txt");
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
@@ -167,6 +168,67 @@ export async function getUsageHistory(filter = {}) {
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date as dd-mm-yyyy h:m:s
|
||||
*/
|
||||
function formatLogDate(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const d = pad(date.getDate());
|
||||
const m = pad(date.getMonth() + 1);
|
||||
const y = date.getFullYear();
|
||||
const h = pad(date.getHours());
|
||||
const min = pad(date.getMinutes());
|
||||
const s = pad(date.getSeconds());
|
||||
return `${d}-${m}-${y} ${h}:${min}:${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append to log.txt
|
||||
* Format: datetime(dd-mm-yyyy h:m:s) | model | provider | account | tokens sent | tokens received | status
|
||||
*/
|
||||
export async function appendRequestLog({ model, provider, connectionId, tokens, status }) {
|
||||
try {
|
||||
const timestamp = formatLogDate();
|
||||
const p = provider?.toUpperCase() || "-";
|
||||
const m = model || "-";
|
||||
|
||||
// Resolve account name
|
||||
let account = connectionId ? connectionId.slice(0, 8) : "-";
|
||||
try {
|
||||
const { getProviderConnections } = await import("@/lib/localDb.js");
|
||||
const connections = await getProviderConnections();
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (conn) {
|
||||
account = conn.name || conn.email || account;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const sent = tokens?.prompt_tokens !== undefined ? tokens.prompt_tokens : "-";
|
||||
const received = tokens?.completion_tokens !== undefined ? tokens.completion_tokens : "-";
|
||||
|
||||
const line = `${timestamp} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${status}\n`;
|
||||
|
||||
fs.appendFileSync(LOG_FILE, line);
|
||||
} catch (error) {
|
||||
console.error("Failed to append to log.txt:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last N lines of log.txt
|
||||
*/
|
||||
export async function getRecentLogs(limit = 200) {
|
||||
if (!fs.existsSync(LOG_FILE)) return [];
|
||||
try {
|
||||
const content = fs.readFileSync(LOG_FILE, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
return lines.slice(-limit).reverse();
|
||||
} catch (error) {
|
||||
console.error("Failed to read log.txt:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated usage stats
|
||||
*/
|
||||
|
||||
124
src/shared/components/RequestLogger.js
Normal file
124
src/shared/components/RequestLogger.js
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Card from "./Card";
|
||||
|
||||
export default function RequestLogger() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let interval;
|
||||
if (autoRefresh) {
|
||||
interval = setInterval(() => {
|
||||
fetchLogs(false);
|
||||
}, 500);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const fetchLogs = async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/usage/logs");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setLogs(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch logs:", error);
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Request Logs</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-text-muted flex items-center gap-2 cursor-pointer">
|
||||
<span>Auto Refresh (500ms)</span>
|
||||
<div
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||
autoRefresh ? "bg-primary" : "bg-bg-subtle border border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||
autoRefresh ? "translate-x-5" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden bg-black/5 dark:bg-black/20">
|
||||
<div className="p-0 overflow-x-auto max-h-[600px] overflow-y-auto font-mono text-xs">
|
||||
{loading && logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-text-muted">Loading logs...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-text-muted">No logs recorded yet.</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse whitespace-nowrap">
|
||||
<thead className="sticky top-0 bg-bg-subtle border-b border-border z-10">
|
||||
<tr>
|
||||
<th className="px-3 py-2 border-r border-border">DateTime</th>
|
||||
<th className="px-3 py-2 border-r border-border">Model</th>
|
||||
<th className="px-3 py-2 border-r border-border">Provider</th>
|
||||
<th className="px-3 py-2 border-r border-border">Account</th>
|
||||
<th className="px-3 py-2 border-r border-border">In</th>
|
||||
<th className="px-3 py-2 border-r border-border">Out</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{logs.map((log, i) => {
|
||||
const parts = log.split(" | ");
|
||||
if (parts.length < 7) return null;
|
||||
|
||||
const status = parts[6];
|
||||
const isPending = status.includes("PENDING");
|
||||
const isFailed = status.includes("FAILED");
|
||||
const isSuccess = status.includes("OK");
|
||||
|
||||
return (
|
||||
<tr key={i} className={`hover:bg-primary/5 transition-colors ${isPending ? 'bg-primary/5' : ''}`}>
|
||||
<td className="px-3 py-1.5 border-r border-border text-text-muted">{parts[0]}</td>
|
||||
<td className="px-3 py-1.5 border-r border-border font-medium">{parts[1]}</td>
|
||||
<td className="px-3 py-1.5 border-r border-border">
|
||||
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px] uppercase font-bold">
|
||||
{parts[2]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 border-r border-border truncate max-w-[150px]" title={parts[3]}>{parts[3]}</td>
|
||||
<td className="px-3 py-1.5 border-r border-border text-right text-primary">{parts[4]}</td>
|
||||
<td className="px-3 py-1.5 border-r border-border text-right text-success">{parts[5]}</td>
|
||||
<td className={`px-3 py-1.5 font-bold ${
|
||||
isSuccess ? 'text-success' :
|
||||
isFailed ? 'text-error' :
|
||||
'text-primary animate-pulse'
|
||||
}`}>
|
||||
{status}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="text-[10px] text-text-muted italic">
|
||||
Logs are saved to log.txt in the application data directory.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export { default as Footer } from "./Footer";
|
||||
export { default as OAuthModal } from "./OAuthModal";
|
||||
export { default as ModelSelectModal } from "./ModelSelectModal";
|
||||
export { default as UsageStats } from "./UsageStats";
|
||||
export { default as RequestLogger } from "./RequestLogger";
|
||||
|
||||
// Layouts
|
||||
export * from "./layouts";
|
||||
|
||||
Reference in New Issue
Block a user