From 950fdba44cfd387cb59c5981ee3147b2535182be Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:47:32 +0700 Subject: [PATCH] =?UTF-8?q?fix(analytics):=20use=20pure=20SQL=20aggregatio?= =?UTF-8?q?ns=20=E2=80=94=20no=20history=20rows=20loaded=20(#1802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.7.6 --- src/app/api/usage/analytics/route.ts | 418 +++++++++++++++++++---- src/lib/usage/usageHistory.ts | 42 ++- tests/unit/usage-analytics-route.test.ts | 171 ++++++++++ tests/unit/usage-history-db.test.ts | 122 +++++++ 4 files changed, 675 insertions(+), 78 deletions(-) create mode 100644 tests/unit/usage-analytics-route.test.ts create mode 100644 tests/unit/usage-history-db.test.ts diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 6647dd7b21..b4900fece7 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1,6 +1,4 @@ import { NextResponse } from "next/server"; -import { getUsageDb } from "@/lib/usageDb"; -import { computeAnalytics } from "@/lib/usageAnalytics"; import { getDbInstance } from "@/lib/db/core"; function getRangeStartIso(range: string): string | null { @@ -32,85 +30,326 @@ function getRangeStartIso(range: string): string | null { return start.toISOString(); } +function shortModelName(model: string | null): string { + if (!model) return "-"; + const parts = model.split(/[/:-]/); + return parts[parts.length - 1] || model; +} + export async function GET(request) { try { const { searchParams } = new URL(request.url); const range = searchParams.get("range") || "30d"; + const sinceIso = getRangeStartIso(range); const presetsParam = searchParams.get("presets"); - // 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 || []; + const db = getDbInstance(); + const whereClause = sinceIso ? "WHERE timestamp >= @since" : ""; + const params = sinceIso ? { since: sinceIso } : {}; - // Build connection map for account names - const { getProviderConnections } = await import("@/lib/localDb"); - const connectionMap: Record = {}; - try { - const connections = await getProviderConnections(); - for (const connRaw of connections as unknown[]) { - const conn = - connRaw && typeof connRaw === "object" && !Array.isArray(connRaw) - ? (connRaw as Record) - : {}; - const connectionId = - typeof conn.id === "string" && conn.id.trim().length > 0 ? conn.id : null; - if (!connectionId) continue; + // Fetch pricing data for cost calculation (no rows loaded) + const { getPricing } = await import("@/lib/db/settings"); + const pricingByProvider = await getPricing(); + const { normalizeModelName } = await import("@/lib/usage/costCalculator"); - const name = - (typeof conn.name === "string" && conn.name.trim()) || - (typeof conn.email === "string" && conn.email.trim()) || - connectionId; - connectionMap[connectionId] = name; - } - } catch { - /* ignore */ - } - - const analytics: any = await computeAnalytics(history, range, connectionMap); - - // T01: fallback transparency metrics from call_logs (requested_model vs routed model). - try { - const db = getDbInstance(); - const sinceIso = getRangeStartIso(range); - const whereClause = sinceIso ? "WHERE timestamp >= @since" : ""; - const row = db - .prepare( - ` - SELECT - COUNT(*) as total, - SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested, - SUM(CASE - WHEN requested_model IS NOT NULL - AND requested_model != '' - AND model IS NOT NULL - AND requested_model != model - THEN 1 ELSE 0 END - ) as fallbacks - FROM call_logs - ${whereClause} + const summaryRow = db + .prepare( ` - ) - .get(sinceIso ? { since: sinceIso } : {}) as - | { total?: number; with_requested?: number; fallbacks?: number } - | undefined; + SELECT + COUNT(*) as totalRequests, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, + COUNT(DISTINCT model) as uniqueModels, + COUNT(DISTINCT connection_id) as uniqueAccounts, + COUNT(DISTINCT api_key_id) as uniqueApiKeys, + COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests, + COALESCE(AVG(latency_ms), 0) as avgLatencyMs, + COALESCE(MIN(timestamp), '') as firstRequest, + COALESCE(MAX(timestamp), '') as lastRequest + FROM usage_history + ${whereClause} + ` + ) + .get(params) as Record; - const total = Number(row?.total || 0); - const withRequested = Number(row?.with_requested || 0); - const fallbackCount = Number(row?.fallbacks || 0); + const dailyRows = db + .prepare( + ` + SELECT + DATE(timestamp) as date, + COUNT(*) as requests, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens + FROM usage_history + ${whereClause} + GROUP BY DATE(timestamp) + ORDER BY date ASC + ` + ) + .all(params) as Array>; - analytics.summary.fallbackCount = fallbackCount; - analytics.summary.fallbackRatePct = - withRequested > 0 ? Number(((fallbackCount / withRequested) * 100).toFixed(2)) : 0; - analytics.summary.requestedModelCoveragePct = - total > 0 ? Number(((withRequested / total) * 100).toFixed(2)) : 0; - } catch { - analytics.summary.fallbackCount = 0; - analytics.summary.fallbackRatePct = 0; - analytics.summary.requestedModelCoveragePct = 0; + // Use requested range for heatmap if available, else default to 364 days (1 year) as fallback + const heatmapStart = sinceIso ? new Date(sinceIso) : new Date(); + if (!sinceIso) { + heatmapStart.setDate(heatmapStart.getDate() - 364); } + const heatmapRows = db + .prepare( + ` + SELECT + DATE(timestamp) as date, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens + FROM usage_history + WHERE timestamp >= @heatmapStart + GROUP BY DATE(timestamp) + ORDER BY date ASC + ` + ) + .all({ heatmapStart: heatmapStart.toISOString() }) as Array>; + + const modelRows = db + .prepare( + ` + SELECT + model, + provider, + COUNT(*) as requests, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, + COALESCE(AVG(latency_ms), 0) as avgLatencyMs, + COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests, + COALESCE(MAX(timestamp), '') as lastUsed + FROM usage_history + ${whereClause} + GROUP BY model, provider + ORDER BY requests DESC + LIMIT 50 + ` + ) + .all(params) as Array>; + + const providerRows = db + .prepare( + ` + SELECT + provider, + COUNT(*) as requests, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, + COALESCE(AVG(latency_ms), 0) as avgLatencyMs, + COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests + FROM usage_history + ${whereClause} + GROUP BY provider + ORDER BY requests DESC + ` + ) + .all(params) as Array>; + + const accountRows = db + .prepare( + ` + SELECT + connection_id as account, + COUNT(*) as requests, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens, + COALESCE(AVG(latency_ms), 0) as avgLatencyMs, + COALESCE(MAX(timestamp), '') as lastUsed + FROM usage_history + ${whereClause} + GROUP BY connection_id + ORDER BY requests DESC + LIMIT 50 + ` + ) + .all(params) as Array>; + + const weeklyRows = db + .prepare( + ` + SELECT + strftime('%w', timestamp) as dayOfWeek, + COUNT(*) as requests, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens + FROM usage_history + ${whereClause} + GROUP BY strftime('%w', timestamp) + ORDER BY dayOfWeek ASC + ` + ) + .all(params) as Array>; + + const fallbackRow = db + .prepare( + ` + SELECT + COUNT(*) as total, + SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested, + SUM(CASE + WHEN requested_model IS NOT NULL + AND requested_model != '' + AND model IS NOT NULL + AND requested_model != model + THEN 1 ELSE 0 END + ) as fallbacks + FROM call_logs + ${whereClause} + ` + ) + .get(params) as Record; + + const summary = { + totalRequests: Number(summaryRow?.totalRequests || 0), + promptTokens: Number(summaryRow?.promptTokens || 0), + completionTokens: Number(summaryRow?.completionTokens || 0), + totalTokens: Number(summaryRow?.totalTokens || 0), + uniqueModels: Number(summaryRow?.uniqueModels || 0), + uniqueAccounts: Number(summaryRow?.uniqueAccounts || 0), + uniqueApiKeys: Number(summaryRow?.uniqueApiKeys || 0), + successfulRequests: Number(summaryRow?.successfulRequests || 0), + successRatePct: + Number(summaryRow?.totalRequests || 0) > 0 + ? Number( + ( + (Number(summaryRow?.successfulRequests || 0) / + Number(summaryRow?.totalRequests || 1)) * + 100 + ).toFixed(2) + ) + : 0, + avgLatencyMs: Math.round(Number(summaryRow?.avgLatencyMs || 0)), + // Compute totalCost by summing cost from byModel (calculated later) + totalCost: 0, // Will be updated after byModel is processed + firstRequest: summaryRow?.firstRequest || "", + lastRequest: summaryRow?.lastRequest || "", + fallbackCount: Number(fallbackRow?.fallbacks || 0), + fallbackRatePct: + Number(fallbackRow?.with_requested || 0) > 0 + ? Number( + ( + (Number(fallbackRow?.fallbacks || 0) / Number(fallbackRow?.with_requested || 1)) * + 100 + ).toFixed(2) + ) + : 0, + requestedModelCoveragePct: + Number(fallbackRow?.total || 0) > 0 + ? Number( + ( + (Number(fallbackRow?.with_requested || 0) / Number(fallbackRow?.total || 1)) * + 100 + ).toFixed(2) + ) + : 0, + }; + + const dailyTrend = dailyRows.map((row) => ({ + date: row.date, + requests: Number(row.requests), + promptTokens: Number(row.promptTokens), + completionTokens: Number(row.completionTokens), + totalTokens: Number(row.totalTokens), + })); + + const activityMap: Record = {}; + for (const row of heatmapRows) { + activityMap[row.date as string] = Number(row.totalTokens); + } + + const byModel = modelRows.map((row) => { + const model = row.model as string; + const provider = row.provider as string; + const short = shortModelName(model); + const tokens = { + input: Number(row.promptTokens) || 0, + output: Number(row.completionTokens) || 0, + }; + // Compute cost from pricing and aggregated tokens + let cost = 0; + try { + const modelPricing = + pricingByProvider[provider]?.[model] || pricingByProvider[provider]?.[short]; + if (modelPricing && typeof modelPricing === "object") { + const p = modelPricing as Record; + const inputPrice = Number(p.input) || 0; + const outputPrice = Number(p.output) || 0; + cost = (tokens.input * inputPrice + tokens.output * outputPrice) / 1_000_000; + } + } catch { + /* ignore */ + } + return { + model: short, + provider, + rawModel: model, + requests: Number(row.requests), + promptTokens: tokens.input, + completionTokens: tokens.output, + totalTokens: Number(row.totalTokens), + avgLatencyMs: Math.round(Number(row.avgLatencyMs)), + successRatePct: + Number(row.requests) > 0 + ? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2) + : 0, + lastUsed: row.lastUsed, + cost: Math.round(cost * 100) / 100, + }; + }); + + // Compute totalCost from byModel sum + const totalCost = byModel.reduce((sum, m) => sum + (m.cost || 0), 0); + summary.totalCost = Math.round(totalCost * 100) / 100; + + const byProvider = providerRows.map((row) => ({ + provider: row.provider, + requests: Number(row.requests), + promptTokens: Number(row.promptTokens), + completionTokens: Number(row.completionTokens), + totalTokens: Number(row.totalTokens), + avgLatencyMs: Math.round(Number(row.avgLatencyMs)), + successRatePct: + Number(row.requests) > 0 + ? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2) + : 0, + })); + + const byAccount = accountRows.map((row) => ({ + account: row.account, + requests: Number(row.requests), + promptTokens: Number(row.promptTokens), + completionTokens: Number(row.completionTokens), + totalTokens: Number(row.totalTokens), + avgLatencyMs: Math.round(Number(row.avgLatencyMs)), + lastUsed: row.lastUsed, + })); + + const weeklyTokens = [0, 0, 0, 0, 0, 0, 0]; + const weeklyCounts = [0, 0, 0, 0, 0, 0, 0]; + for (const row of weeklyRows) { + const dayIdx = Number(row.dayOfWeek); + if (dayIdx >= 0 && dayIdx <= 6) { + weeklyTokens[dayIdx] = Number(row.totalTokens); + weeklyCounts[dayIdx] = Number(row.requests); + } + } + + const analytics = { + summary, + dailyTrend, + activityMap, + byModel, + byProvider, + byAccount, + weeklyTokens, + weeklyCounts, + range, + }; if (presetsParam) { const allowedRanges = new Set(["1d", "7d", "30d", "90d", "ytd", "all"]); @@ -128,9 +367,46 @@ export async function GET(request) { continue; } - const presetAnalytics: any = await computeAnalytics(history, presetRange, connectionMap); + const presetSinceIso = getRangeStartIso(presetRange); + const presetWhere = presetSinceIso ? "WHERE timestamp >= @presetSince" : ""; + const presetParams = presetSinceIso ? { presetSince: presetSinceIso } : {}; + + const presetModelRows = db + .prepare( + ` + SELECT + model, + provider, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens + FROM usage_history + ${presetWhere} + GROUP BY model, provider + ` + ) + .all(presetParams) as Array>; + + let presetTotalCost = 0; + for (const row of presetModelRows) { + const m = row.model as string; + const p = row.provider as string; + const short = shortModelName(m); + const inputTokens = Number(row.promptTokens) || 0; + const outputTokens = Number(row.completionTokens) || 0; + + try { + const modelPricing = pricingByProvider[p]?.[m] || pricingByProvider[p]?.[short]; + if (modelPricing && typeof modelPricing === "object") { + const mp = modelPricing as Record; + const inputPrice = Number(mp.input) || 0; + const outputPrice = Number(mp.output) || 0; + presetTotalCost += (inputTokens * inputPrice + outputTokens * outputPrice) / 1_000_000; + } + } catch {} + } + presetSummaries[presetRange] = { - totalCost: Number(presetAnalytics.summary?.totalCost || 0), + totalCost: Math.round(presetTotalCost * 100) / 100, }; } diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 97f1a83bd4..5ee4c75b0b 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -254,17 +254,42 @@ export function clearPendingRequests() { // ──────────────── getUsageDb Shim (backward compat) ──────────────── +const MAX_ROWS = 10000; + /** * Returns an object compatible with the old LowDB interface. * Only `api/usage/analytics/route.js` uses this — it reads `db.data.history`. + * + * @param sinceIso - ISO timestamp to filter from (inclusive) + * @param limit - Max rows to return (default 10,000) + * @param cursor - Timestamp cursor for pagination (exclusive, for next page) */ -export async function getUsageDb(sinceIso?: string | null) { +export async function getUsageDb(sinceIso?: string | null, limit?: number, cursor?: string | null) { const db = getDbInstance(); - 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 maxRows = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Number(limit) : MAX_ROWS; + + let rows; + if (cursor) { + // Cursor-based pagination (next page after cursor) + // Use > cursor to get rows after the last timestamp of previous page (ASC order) + rows = sinceIso + ? db + .prepare( + `SELECT * FROM usage_history WHERE timestamp >= ? AND timestamp > ? ORDER BY timestamp ASC LIMIT ?` + ) + .all(sinceIso, cursor, maxRows) + : db + .prepare(`SELECT * FROM usage_history WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?`) + .all(cursor, maxRows); + } else if (sinceIso) { + // Initial query with date filter + rows = db + .prepare(`SELECT * FROM usage_history WHERE timestamp >= ? ORDER BY timestamp ASC LIMIT ?`) + .all(sinceIso, maxRows); + } else { + // No filter - get all (with limit) + rows = db.prepare(`SELECT * FROM usage_history ORDER BY timestamp ASC LIMIT ?`).all(maxRows); + } const history = rows.map((row) => { const r = asRecord(row); @@ -290,7 +315,10 @@ export async function getUsageDb(sinceIso?: string | null) { }; }); - return { data: { history } }; + // Provide next cursor if we hit the limit (more rows exist) + const nextCursor = rows.length === maxRows ? (rows[rows.length - 1] as any)?.timestamp : null; + + return { data: { history, nextCursor } }; } // ──────────────── Save Request Usage ──────────────── diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts new file mode 100644 index 0000000000..5e61a8077e --- /dev/null +++ b/tests/unit/usage-analytics-route.test.ts @@ -0,0 +1,171 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-usage-analytics-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts"); + +const clearPendingRequests = usageHistory.clearPendingRequests; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + clearPendingRequests(); +} + +async function seedAnalyticsData() { + const db = core.getDbInstance(); + const now = new Date(); + for (let i = 0; i < 20; i++) { + const timestamp = new Date(now.getTime() - i * 60 * 60 * 1000).toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + i % 2 === 0 ? "openai" : "anthropic", + i % 2 === 0 ? "gpt-4o" : "claude-sonnet", + "test-conn", + "test-key", + 100 + i, + 50 + i, + 1, + 200 + i * 10, + timestamp + ); + } + db.prepare( + `INSERT INTO call_logs (provider, model, requested_model, connection_id, timestamp) + VALUES (?, ?, ?, ?, ?)` + ).run("openai", "gpt-4o", "gpt-4o-mini", "test-conn", new Date().toISOString()); +} + +function makeRequest(url: string) { + return new Request(url, { method: "GET" }); +} + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updatePricing({ + openai: { "gpt-4o": { input: 2.5, output: 10 } }, + anthropic: { "claude-sonnet": { input: 3, output: 15 } }, + }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /api/usage/analytics returns summary with aggregated metrics", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.summary.totalRequests, 20); + assert.equal(body.summary.uniqueModels, 2); + assert.equal(body.summary.uniqueAccounts, 1); + assert.ok(body.summary.totalTokens > 0); + assert.ok(body.summary.avgLatencyMs > 0); +}); + +test("GET /api/usage/analytics includes dailyTrend array", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.dailyTrend)); + assert.ok(body.dailyTrend.length > 0); +}); + +test("GET /api/usage/analytics includes byModel array with cost calculations", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.byModel)); + assert.ok(body.byModel.length > 0); + const gptEntry = body.byModel.find((m) => m.model === "4o" && m.provider === "openai"); + assert.ok(gptEntry); + assert.ok(typeof gptEntry.cost === "number"); +}); + +test("GET /api/usage/analytics filters by range parameter", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET( + makeRequest("http://localhost/api/usage/analytics?range=1d") + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.range, "1d"); +}); + +test("GET /api/usage/analytics includes byProvider array", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.byProvider)); + assert.ok(body.byProvider.length > 0); +}); + +test("GET /api/usage/analytics includes byAccount array", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.byAccount)); + assert.ok(body.byAccount.length > 0); + assert.equal(body.byAccount[0].account, "test-conn"); +}); + +test("GET /api/usage/analytics returns weeklyTokens and weeklyCounts", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.weeklyTokens)); + assert.equal(body.weeklyTokens.length, 7); + assert.ok(Array.isArray(body.weeklyCounts)); + assert.equal(body.weeklyCounts.length, 7); +}); + +test("GET /api/usage/analytics includes activityMap for heatmap", async () => { + await seedAnalyticsData(); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(typeof body.activityMap === "object"); + assert.ok(Object.keys(body.activityMap).length > 0); +}); + +test("GET /api/usage/analytics returns 500 on database errors", async () => { + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.ok(body.summary.totalRequests === 0); +}); diff --git a/tests/unit/usage-history-db.test.ts b/tests/unit/usage-history-db.test.ts new file mode 100644 index 0000000000..d3e44fc1d1 --- /dev/null +++ b/tests/unit/usage-history-db.test.ts @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-usage-history-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); + +const clearPendingRequests = usageHistory.clearPendingRequests; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + clearPendingRequests(); +} + +async function seedUsageEntries( + entries: Array<{ + provider: string; + model: string; + connectionId?: string; + tokens: { input?: number; output?: number }; + success?: boolean; + latencyMs?: number; + minutesAgo?: number; + }> +) { + for (const [i, e] of entries.entries()) { + await usageHistory.saveRequestUsage({ + provider: e.provider, + model: e.model, + connectionId: e.connectionId || `conn-${i}`, + apiKeyId: `key-${i}`, + apiKeyName: `Key ${i}`, + tokens: e.tokens, + success: e.success !== false, + latencyMs: e.latencyMs || 100, + timestamp: new Date(Date.now() - (e.minutesAgo || i) * 60 * 1000).toISOString(), + }); + } +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ──────────────── getUsageDb ──────────────── + +test("getUsageDb returns all rows up to MAX_ROWS limit in ASC order", async () => { + // Save 12 entries (limit is 10000, so all should return) + const entries = Array.from({ length: 12 }, (_, i) => ({ + provider: `prov-${i}`, + model: `model-${i}`, + tokens: { input: i * 10, output: i }, + success: true, + minutesAgo: i, + })); + await seedUsageEntries(entries); + + const result = await usageHistory.getUsageDb(); + + assert.equal(result.data.history.length, 12); + assert.equal(result.data.history[0].provider, "prov-11"); + assert.equal(result.data.history[11].provider, "prov-0"); + assert.equal(result.data.nextCursor, null); // No next cursor (didn't hit limit) +}); + +test("getUsageDb filters by sinceIso date", async () => { + const entries = [ + { provider: "old", model: "m", tokens: { input: 10 }, minutesAgo: 120 }, + { provider: "new", model: "m", tokens: { input: 20 }, minutesAgo: 1 }, + ]; + await seedUsageEntries(entries); + + const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const result = await usageHistory.getUsageDb(cutoff); + + assert.equal(result.data.history.length, 1); + assert.equal(result.data.history[0].provider, "new"); +}); + +test("getUsageDb provides nextCursor when rows exceed MAX_ROWS", async () => { + const db = core.getDbInstance(); + // Insert exactly MAX_ROWS + 1 = 10001 rows so getUsageDb returns a cursor + for (let i = 0; i < 10001; i++) { + db.prepare( + `INSERT INTO usage_history (provider, model, timestamp, tokens_input, tokens_output, success, latency_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(`prov-${i}`, `model-${i}`, new Date(Date.now() - i * 1000).toISOString(), 10, 5, 1, 100); + } + + const result = await usageHistory.getUsageDb(); + + assert.equal(result.data.history.length, 10000); + assert.notEqual(result.data.nextCursor, null); +}); + +test("getUsageDb cursor-based pagination fetches subsequent pages", async () => { + const entries = Array.from({ length: 5 }, (_, i) => ({ + provider: `prov-${i}`, + model: `model-${i}`, + tokens: { input: 10 }, + success: true, + minutesAgo: i, + })); + await seedUsageEntries(entries); + + // First page (limit=2) + const page1 = await usageHistory.getUsageDb(undefined, 2); + assert.equal(page1.data.history.length, 2); + assert.notEqual(page1.data.nextCursor, null); +});