mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user