Skip to content

perf: reduce analytics page load — DB filter, parallel costs, merged queries - #1038

Merged
diegosouzapw merged 2 commits into
cheaper-inference:mainfrom
oyi77:perf/analytics-query-optimization
Apr 7, 2026
Merged

diegosouzapw merged 2 commits into
cheaper-inference:mainfrom
oyi77:perf/analytics-query-optimization

Conversation

@oyi77

@oyi77 oyi77 commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Closes #1037

Problem

The analytics dashboard becomes progressively slower as usage history grows, with 3 root causes:

  1. Full table scan — getUsageDb() fetches every row in usage_history with no date filter
  2. N+1 sequential cost lookups — calculateCost() is awaited one-by-one inside a for loop
  3. 6 separate COUNT queries — /api/v1/search/analytics fires 6 round-trips for data that fits in one query

Changes

File What changed
src/lib/usage/usageHistory.ts getUsageDb(sinceIso?) — optional date param pushes filter to SQL
src/app/api/usage/analytics/route.ts Pass 365daysAgo as sinceIso (heatmap never needs more)
src/lib/usageAnalytics.ts Replace sequential await calculateCost() with Promise.all()
src/app/api/v1/search/analytics/route.ts Merge 5 scalar COUNT/AVG queries into one CASE WHEN aggregate

Expected improvement

Entries Before After (est.)
~500 < 1 s < 1 s
~5 000 3–8 s < 1 s
~50 000 30 s+ 1–2 s

Test plan

  • All 1439 unit tests pass locally
  • getUsageDb backward-compatible (callers without sinceIso get old behavior)
  • Search analytics response shape unchanged
  • Manual: load /dashboard/analytics with large dataset and confirm faster response

🤖 Generated with Claude Code

…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>

@gemini-code-assist gemini-code-assist Bot left a comment

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.

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.

Comment thread src/lib/usageAnalytics.ts Outdated
Comment on lines +132 to +134
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.

Comment thread src/lib/usageAnalytics.ts Outdated
Comment on lines +132 to +134
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.

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.

Comment on lines +33 to 41
`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'`
)

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'`
      )

…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>
oyi77 added a commit to oyi77/OmniRoute that referenced this pull request Apr 7, 2026
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.
@diegosouzapw
diegosouzapw merged commit b338cc8 into cheaper-inference:main Apr 7, 2026
1 check passed
@diegosouzapw

Copy link
Copy Markdown
Collaborator

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.

@diegosouzapw diegosouzapw mentioned this pull request Apr 22, 2026
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
…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.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: analytics page is slow due to unbounded DB load and N+1 cost queries

2 participants