Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/app/api/usage/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 25 additions & 34 deletions src/app/api/v1/search/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'`
)
Comment on lines +33 to 41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of SUM(CASE ... END) and AVG(CASE ... END) is a great way to merge multiple queries. However, note that if no rows match the WHERE clause, these aggregates will return NULL in SQLite. While your current code handles this with the null-coalescing operator (?? 0) on lines 44-48, using COALESCE(SUM(...), 0) directly in the SQL would be slightly cleaner and more robust.

      .prepare(
        `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
Expand All @@ -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({
Expand Down
37 changes: 36 additions & 1 deletion src/lib/usage/costCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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;

Expand Down
8 changes: 6 additions & 2 deletions src/lib/usage/usageHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,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);
Expand Down
39 changes: 30 additions & 9 deletions src/lib/usageAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down