Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,
SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) as today,
SUM(CASE WHEN status >= 400 OR error IS NOT NULL THEN 1 ELSE 0 END) as errors,
AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration,
SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END) 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
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
16 changes: 8 additions & 8 deletions src/lib/usageAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,14 @@ export async function computeAnalytics(
}
}

// Pre-fetch all costs in parallel — avoids N sequential async round-trips
const entryCosts = await Promise.all(
entries.map((e) => calculateCost(e.provider, e.model, e.tokens).catch(() => 0))
);

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.

high

While Promise.all is an improvement over sequential await, calling calculateCost for every entry (up to 50,000 as per the PR description) is still highly inefficient. Each call to calculateCost performs a dynamic import() and a database lookup for pricing data. Since most entries share the same provider and model, you are performing thousands of redundant database queries.

Consider fetching all unique pricing records once at the start of computeAnalytics and then calculating costs synchronously in the loop. If refactoring calculateCost is not feasible, at least implement a local cache for the pricing lookups to avoid redundant I/O.

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

Executing Promise.all on an array of 50,000 promises can lead to significant memory pressure and may impact the responsiveness of the event loop. For very large datasets, it is generally safer to process the array in smaller batches or use a concurrency-limited promise pool.


// ---- 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 +144,7 @@ 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 cost = entryCosts[i];

// Summary
summary.promptTokens += pt;
Expand Down