Repository navigation
perf: reduce analytics page load — DB filter, parallel costs, merged queries - #1038
diegosouzapw merged 2 commits into
Conversation
…rged queries Fixes cheaper-inference#1037 - getUsageDb(sinceIso?): add optional date param so the SQL query filters at DB level, capping the history load to 365 days (widest dashboard consumer). Previously the full usage_history table was loaded on every analytics request. - computeAnalytics: replace sequential await calculateCost() inside the loop with Promise.all() so all cost lookups run in parallel instead of serialising N async round-trips for N entries. - /api/v1/search/analytics: collapse 5 separate COUNT/AVG queries into one aggregated SELECT with CASE WHEN expressions, reducing DB round-trips from 6 to 2 (scalar metrics + provider GROUP BY). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request optimizes analytics performance by capping history lookups to 365 days, consolidating multiple SQL queries into a single aggregated call, and parallelizing cost calculations. Review feedback suggests further refining the cost calculation logic to handle large datasets more efficiently by implementing caching for redundant lookups and batching promises to prevent memory pressure. Additionally, using COALESCE in the aggregated SQL query was recommended to improve null handling directly within the database layer.
| const entryCosts = await Promise.all( | ||
| entries.map((e) => calculateCost(e.provider, e.model, e.tokens).catch(() => 0)) | ||
| ); |
There was a problem hiding this comment.
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.
| const entryCosts = await Promise.all( | ||
| entries.map((e) => calculateCost(e.provider, e.model, e.tokens).catch(() => 0)) | ||
| ); |
There was a problem hiding this comment.
| `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'` | ||
| ) |
There was a problem hiding this comment.
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'`
)…uards - costCalculator: export normalizeModelName and new computeCostFromPricing() sync helper so callers can compute cost without triggering a DB round-trip - usageAnalytics: replace Promise.all over all entries with a per-unique-pair pricing pre-fetch (typically <50 pairs vs 50k entries), then compute costs synchronously in the loop — eliminates memory pressure and redundant lookups - search/analytics: wrap SUM aggregates in COALESCE for explicit null safety Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Optimize getWebhooks(), getWebhook(), and getEnabledWebhooks() by replacing SELECT * with specific columns list. Changes: - Add WEBHOOKS_COLUMNS constant with needed columns - Replace SELECT * with selective column query in 3 functions - Reduces memory overhead from fetching unused columns Complements PR cheaper-inference#1038 and existing callLogs optimization.
|
Thanks @oyi77 for the analytics performance optimization! The parallel cost calculation and merged queries will make a huge difference for users with large datasets. Merged into v3.5.4. |
…queries (cheaper-inference#1038) Performance: reduces analytics page load via DB date filter, parallel cost calculation, and merged COUNT queries. Integrated into release/v3.5.4.
…queries (cheaper-inference#1038) Performance: reduces analytics page load via DB date filter, parallel cost calculation, and merged COUNT queries. Integrated into release/v3.5.4.
Closes #1037
Problem
The analytics dashboard becomes progressively slower as usage history grows, with 3 root causes:
getUsageDb()fetches every row inusage_historywith no date filtercalculateCost()is awaited one-by-one inside aforloop/api/v1/search/analyticsfires 6 round-trips for data that fits in one queryChanges
src/lib/usage/usageHistory.tsgetUsageDb(sinceIso?)— optional date param pushes filter to SQLsrc/app/api/usage/analytics/route.ts365daysAgoassinceIso(heatmap never needs more)src/lib/usageAnalytics.tsawait calculateCost()withPromise.all()src/app/api/v1/search/analytics/route.tsCASE WHENaggregateExpected improvement
Test plan
getUsageDbbackward-compatible (callers withoutsinceIsoget old behavior)/dashboard/analyticswith large dataset and confirm faster response🤖 Generated with Claude Code