perf: reduce analytics page load — DB filter, parallel costs, merged queries (#1038)

Performance: reduces analytics page load via DB date filter, parallel cost calculation, and merged COUNT queries. Integrated into release/v3.5.4.
This commit is contained in:
Paijo
2026-04-08 03:27:19 +07:00
committed by GitHub
parent 58c5f7e373
commit b338cc88fb
5 changed files with 102 additions and 47 deletions

View File

@@ -37,7 +37,11 @@ export async function GET(request) {
const { searchParams } = new URL(request.url);
const range = searchParams.get("range") || "30d";
const db = await getUsageDb();
// Cap history load to last 365 days — the heatmap never looks beyond that,
// and all named ranges (1d/7d/30d/90d/ytd) fall within this window.
const heatmapSince = new Date();
heatmapSince.setDate(heatmapSince.getDate() - 365);
const db = await getUsageDb(heatmapSince.toISOString());
const history = db.data.history || [];
// Build connection map for account names

View File

@@ -16,39 +16,38 @@ export async function GET(req: Request) {
try {
const db = getDbInstance();
// Total search requests
const totalRow = db
.prepare(`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search'`)
.get() as { cnt: number };
const total = totalRow?.cnt ?? 0;
// Today's searches (UTC date)
// Single aggregated query for all scalar metrics — replaces 5 separate round-trips
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const todayRow = db
.prepare(
`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND timestamp >= ?`
)
.get(todayStart.toISOString()) as { cnt: number };
const today = todayRow?.cnt ?? 0;
const todayIso = todayStart.toISOString();
// Errors
const errRow = db
type StatsRow = {
total: number;
today: number;
errors: number;
avg_duration: number | null;
cached: number;
};
const statsRow = db
.prepare(
`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND (status >= 400 OR error IS NOT NULL)`
`SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today,
COALESCE(SUM(CASE WHEN status >= 400 OR error IS NOT NULL THEN 1 ELSE 0 END), 0) as errors,
AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration,
COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached
FROM call_logs
WHERE request_type = 'search'`
)
.get() as { cnt: number };
const errors = errRow?.cnt ?? 0;
.get(todayIso) as StatsRow | undefined;
// Avg duration
const durRow = db
.prepare(
`SELECT AVG(duration) as avg FROM call_logs WHERE request_type = 'search' AND duration > 0`
)
.get() as { avg: number | null };
const avgDurationMs = Math.round(durRow?.avg ?? 0);
const total = statsRow?.total ?? 0;
const today = statsRow?.today ?? 0;
const errors = statsRow?.errors ?? 0;
const avgDurationMs = Math.round(statsRow?.avg_duration ?? 0);
const cached = statsRow?.cached ?? 0;
// Per-provider breakdown (provider column stores search provider id)
// Per-provider breakdown
const provRows = db
.prepare(
`SELECT provider, COUNT(*) as cnt
@@ -74,14 +73,6 @@ export async function GET(req: Request) {
totalCostUsd += cost;
}
// Cached: very fast responses (< 5ms) indicate cache hits
const cachedRow = db
.prepare(
`SELECT COUNT(*) as cnt FROM call_logs
WHERE request_type = 'search' AND duration > 0 AND duration < 5`
)
.get() as { cnt: number };
const cached = cachedRow?.cnt ?? 0;
const cacheHitRate = total > 0 ? Math.round((cached / total) * 100) : 0;
return NextResponse.json({

View File

@@ -18,7 +18,7 @@
* @param {string} model
* @returns {string}
*/
function normalizeModelName(model) {
export function normalizeModelName(model) {
if (!model || !model.includes("/")) return model;
const parts = model.split("/");
return parts[parts.length - 1];
@@ -41,6 +41,41 @@ function toNumber(value: unknown, fallback = 0): number {
* @param {Object} tokens
* @returns {Promise<number>} Cost in USD
*/
/**
* Compute cost synchronously from a pre-fetched pricing record.
* Use this when pricing has already been loaded (e.g. in batch analytics).
*/
export function computeCostFromPricing(
pricing: Record<string, unknown> | null | undefined,
tokens: any
): number {
if (!pricing || !tokens) return 0;
const inputPrice = toNumber(pricing.input, 0);
const cachedPrice = toNumber(pricing.cached, inputPrice);
const outputPrice = toNumber(pricing.output, 0);
const reasoningPrice = toNumber(pricing.reasoning, outputPrice);
const cacheCreationPrice = toNumber(pricing.cache_creation, inputPrice);
let cost = 0;
const inputTokens = tokens.input ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0;
const cachedTokens =
tokens.cacheRead ?? tokens.cached_tokens ?? tokens.cache_read_input_tokens ?? 0;
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
cost += nonCachedInput * (inputPrice / 1_000_000);
if (cachedTokens > 0) cost += cachedTokens * (cachedPrice / 1_000_000);
const outputTokens = tokens.output ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0;
cost += outputTokens * (outputPrice / 1_000_000);
const reasoningTokens = tokens.reasoning ?? tokens.reasoning_tokens ?? 0;
if (reasoningTokens > 0) cost += reasoningTokens * (reasoningPrice / 1_000_000);
const cacheCreationTokens = tokens.cacheCreation ?? tokens.cache_creation_input_tokens ?? 0;
if (cacheCreationTokens > 0) cost += cacheCreationTokens * (cacheCreationPrice / 1_000_000);
return cost;
}
export async function calculateCost(provider, model, tokens) {
if (!tokens || !provider || !model) return 0;

View File

@@ -108,9 +108,13 @@ export function getPendingRequests() {
* Returns an object compatible with the old LowDB interface.
* Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`.
*/
export async function getUsageDb() {
export async function getUsageDb(sinceIso?: string | null) {
const db = getDbInstance();
const rows = db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all();
const rows = sinceIso
? db
.prepare("SELECT * FROM usage_history WHERE timestamp >= ? ORDER BY timestamp ASC")
.all(sinceIso)
: db.prepare("SELECT * FROM usage_history ORDER BY timestamp ASC").all();
const history = rows.map((row) => {
const r = asRecord(row);

View File

@@ -5,7 +5,10 @@
* summary cards, daily trends, activity heatmap, model breakdown, etc.
*/
import { calculateCost } from "@/lib/usageDb";
import {
normalizeModelName as normModel,
computeCostFromPricing,
} from "@/lib/usage/costCalculator";
/**
* Compute date range boundaries
@@ -128,8 +131,31 @@ export async function computeAnalytics(
}
}
// Pre-fetch pricing for all unique (provider, model) pairs — one DB round-trip
// per unique pair instead of one per entry, then compute costs synchronously.
const { getPricingForModel } = await import("@/lib/localDb");
const pricingCache = new Map<string, Record<string, unknown> | null>();
const uniquePairs = new Set(entries.map((e) => `${e.provider}|||${e.model}`));
await Promise.all(
[...uniquePairs].map(async (key) => {
const [provider, model] = key.split("|||");
let pricing = (await getPricingForModel(provider, model)) as Record<string, unknown> | null;
if (!pricing) {
const normalized = normModel(model);
if (normalized !== model) {
pricing = (await getPricingForModel(provider, normalized)) as Record<
string,
unknown
> | null;
}
}
pricingCache.set(key, pricing ?? null);
})
);
// ---- Single pass over filtered entries for everything else ----
for (const entry of entries) {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const pt = entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0;
const ct = entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0;
const totalTkns = pt + ct;
@@ -138,13 +164,8 @@ export async function computeAnalytics(
const dayOfWeek = entryDate.getDay();
const modelShort = shortModelName(entry.model);
// Cost
let cost = 0;
try {
cost = await calculateCost(entry.provider, entry.model, entry.tokens);
} catch {
/* ignore */
}
const pricingKey = `${entry.provider}|||${entry.model}`;
const cost = computeCostFromPricing(pricingCache.get(pricingKey), entry.tokens);
// Summary
summary.promptTokens += pt;