Skip to content

feat(dashboard): rework usage charts with cache-aware diverging bars - #574

Merged
SantiagoDePolonia merged 6 commits into
mainfrom
feat/charts-iteration
Jul 21, 2026
Merged

feat(dashboard): rework usage charts with cache-aware diverging bars#574
SantiagoDePolonia merged 6 commits into
mainfrom
feat/charts-iteration

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Reworks the usage page's three breakdown charts (by model, by user path, by label) and surfaces cache traffic in them.

Chart rework

  • Horizontal diverging bars: one row per model/user path/label; input-side series grow left from zero, output-side series grow right, with an emphasized zero divider. Row height scales with the number of entries (top 10 + "Other").
  • Three-way view toggle per chart: diverging, stacked (all series piling right as segments of one bar), and the existing table — each with its own icon.
  • Layout: charts flow two per row; a chart alone in its row (odd count, or a neighbor hidden) stretches to full width.

Cache series (new)

  • Prompt Cached (provider prompt-cache reads) joins the input side — in tokens mode as its own series (Input becomes paid input: uncached + cache writes, falling back to full input for rows without the split), and in costs mode as an estimated cached-read cost split out of Input Cost, priced from current catalog pricing via the existing PricingResolver (same trade-off as pricing recalculation).
  • Locally Cached (GoModel response-cache hits) joins each side it belongs to: cached input on the left, cached output on the right. Local hits are free, so costs mode shows no local series.
  • Cache series appear only when they have data; colors reuse the overview chart's token palette so the dashboard reads as one system.
  • The table views gain "Prompt Cached" and "Local Cached" columns (with the ~$ estimate and per-side split in tooltips).

Backend

  • ModelUsage/UserPathUsage/LabelUsage gain a shared GroupCacheFields block: prompt-cache input split, per-side local-cache tokens, and estimated cached_input_cost.
  • Populated by a second streaming pass over the same filters (aggregate SQL can't return per-row raw_data), folded through one shared accumulator reusing EntryInputSegments — the same pattern as the daily series. Implemented for SQLite, PostgreSQL, and MongoDB; label expansion happens in Go, matching each backend's json_each/$unwind semantics.
  • The fold streams with cache mode "all" so local-cache tokens are populated even though the aggregates default to uncached-only.

User-visible notes

  • For additive-accounting providers (Anthropic), a row's input side can exceed the old input bar since cached reads now appear as their own segment (same behavior as the overview chart).
  • cached_input_cost is a read-time estimate at today's prices; models without a cached-input rate are omitted from it.

Testing

  • New Go tests: shared fold via SQLite integration (by model/user path/label, filter-following) and EstimateCachedInputCost unit tests; full internal/... suite green.
  • Dashboard JS suite extended to 515 tests (diverging/stacked shapes, cache series, costs-mode estimate split, "Other" folding) — all green.
  • Rendering verified with headless-Chrome screenshots in both modes and both chart views.
  • Swagger docs regenerated.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Usage analytics now exposes expanded prompt-cached and locally-cached token metrics for models, user paths, and labels, plus cached input cost when available.
    • Usage charts add a stacked view alongside chart mode, with improved horizontal bar rendering and an Other aggregate for lower-ranked items.
  • UI/Documentation
    • The Tokens/Costs toggle is now pinned in the sticky date-range controls, and table views include Prompt Cached and Local Cached columns.
    • Swagger/OpenAPI schema updated to include the new cache-partitioned fields.
  • Tests
    • Updated usage and dashboard chart tests to cover stacked/diverging rendering and the new cache metrics.

Restructure the usage page's by-model, by-user-path, and by-label charts
into horizontal diverging bar charts: one row per entity, input-side
series growing left from zero and output-side series growing right, with
an alternative stacked view (everything piling right) and the existing
table view behind a three-way toggle. Charts flow two per row; a chart
alone in its row stretches to full width.

The charts now also surface cache traffic:

- Provider prompt-cache reads join the input side as their own series
  (tokens and, as a read-time estimate priced from current catalog
  pricing, costs); the Input series becomes paid input (uncached +
  cache writes).
- Locally cached tokens (GoModel response-cache hits) join each side
  they belong to: cached input on the left, cached output on the right.
  They are free, so costs mode shows no local series.

Backend: the by-model/user-path/label aggregates gain a shared
GroupCacheFields block (prompt-cache split, per-side local-cache tokens,
estimated cached input cost), folded per row from raw_data in a second
streaming pass shared by the SQLite, PostgreSQL, and MongoDB readers —
the same pattern the daily series uses. The admin handlers price each
group's cached tokens with the existing PricingResolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a719007a-c983-4a33-b292-5e8143724aa3

📥 Commits

Reviewing files that changed from the base of the PR and between 7fd31da and 48a0b7a.

📒 Files selected for processing (2)
  • internal/usage/reader_mongodb.go
  • internal/usage/reader_mongodb_test.go

📝 Walkthrough

Walkthrough

Usage reporting now aggregates cache-partitioned tokens and cached-input costs across storage backends, exposes them through admin APIs and OpenAPI schemas, and renders them in dashboard charts and tables with chart, stacked, and table views.

Changes

Cache analytics

Layer / File(s) Summary
Cache contracts and aggregation
internal/usage/reader.go, internal/usage/group_cache_stats.go, internal/usage/group_cache_stats_test.go
Adds cache-specific usage fields, grouping and folding logic, pricing-key breakdowns, cached-input cost estimation, and SQLite-backed tests.
Reader cache-stat passes
internal/usage/reader_sqlite.go, internal/usage/reader_postgresql.go, internal/usage/reader_mongodb.go, internal/usage/reader_mongodb_test.go
Adds filtered CacheModeAll passes that aggregate and merge cache statistics for model, user-path, and label results, including canonical user-path handling in MongoDB.
Handler enrichment and API schemas
internal/admin/handler_usage.go, cmd/gomodel/docs/docs.go, docs/openapi.json
Calculates cached-input costs in usage handlers and documents cache token and cost fields for all usage dimensions.
Dashboard cache visualization
internal/admin/dashboard/static/js/modules/charts.js, internal/admin/dashboard/templates/page-usage.html, internal/admin/dashboard/static/css/dashboard.css, internal/admin/dashboard/static/js/modules/charts.test.cjs, internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs
Adds diverging and stacked cache-aware charts, cached-token table columns, responsive chart layout, sticky controls, view toggles, and expanded rendering tests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dashboard
  participant UsageHandler
  participant UsageReader
  participant UsageStore
  Dashboard->>UsageHandler: Request model, user-path, or label usage
  UsageHandler->>UsageReader: Read grouped usage
  UsageReader->>UsageStore: Run cache-stat pass with CacheModeAll
  UsageStore-->>UsageReader: Stream cache rows
  UsageReader-->>UsageHandler: Merge cache token fields
  UsageHandler-->>Dashboard: Add cached-input cost
  Dashboard->>Dashboard: Render charts and tables
Loading

Possibly related PRs

Suggested reviewers: copilot

Poem

A bunny counts cached hops with care,
Through model, label, path, and share.
Bars split left and right in flight,
Tables show the cache just right.
“Thump!” says the rabbit—metrics glow!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main dashboard change: reworking usage charts with cache-aware diverging bars.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • ✅ Committed to branch successfully - (🔄 Check to regenerate)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/charts-iteration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 21, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 66.55518% with 100 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/usage/reader_mongodb.go 41.37% 49 Missing and 2 partials ⚠️
internal/usage/reader_postgresql.go 0.00% 26 Missing ⚠️
internal/usage/group_cache_stats.go 91.21% 8 Missing and 5 partials ⚠️
internal/usage/reader_sqlite.go 61.53% 5 Missing and 5 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/usage/group_cache_stats_test.go`:
- Around line 159-189: Refactor TestEstimateCachedInputCost into table-driven
subtests using a []struct with a descriptive name, breakdown, resolver, and
expected cost; run each case with t.Run. Preserve all four existing scenarios
and assertions, including nil expectations and the $1.00 result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 88c8ea0b-4627-4a41-882e-4fbb0d9b161b

📥 Commits

Reviewing files that changed from the base of the PR and between fbb1637 and 387263a.

📒 Files selected for processing (14)
  • cmd/gomodel/docs/docs.go
  • docs/openapi.json
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/modules/charts.js
  • internal/admin/dashboard/static/js/modules/charts.test.cjs
  • internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs
  • internal/admin/dashboard/templates/page-usage.html
  • internal/admin/handler_usage.go
  • internal/usage/group_cache_stats.go
  • internal/usage/group_cache_stats_test.go
  • internal/usage/reader.go
  • internal/usage/reader_mongodb.go
  • internal/usage/reader_postgresql.go
  • internal/usage/reader_sqlite.go

Comment thread internal/usage/group_cache_stats_test.go
@mintlify

mintlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jul 21, 2026, 5:30 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

Cache-only groups, explicit cache modes, and current-price cost splits can produce incorrect chart data.

  • Groups containing only local-cache hits can disappear.
  • Explicit cache modes can count local tokens in both aggregate and cache fields.
  • Current cached-input estimates can make chart totals exceed recorded costs.

internal/usage/group_cache_stats.go, the three storage readers, and internal/admin/dashboard/static/js/modules/charts.js

T-Rex T-Rex Logs

What T-Rex did

  • Created a focused reproduction harness targeting a local-cache-only model with a real SQLite store to verify local-only group dropping; the verification was blocked due to memory contention during the Go test compilation.
  • Set up a focused harness to seed SQLite with provider and local-cache traffic and exercise the /admin/usage/models endpoint with both cache_mode=all and cache_mode=cached; the run did not finish within the allotted execution budget.
  • Reproduced an inconsistency in the costs-based dashboard chart: the produced chart showed paid input 0.00 and cached input 0.75 with a total of 0.85 when including output cost, exceeding the recorded cost.
  • Observed UI transitions from the baseline single-series vertical chart to token/diverging charts and finally to cost-mode stacked charts, with server startup and Chart.js datasets verified in the browser log.
  • Compiled and linked artifacts for reviewer inspection, including the focused test harness, the targeted dashboard test, and the dashboard visuals and logs corresponding to proofs 0–3.

View all artifacts

T-Rex Ran code and verified through T-Rex

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
Q[Usage query] --> A[Aggregate pass]
Q --> C[Cache pass with all modes]
A --> G[Grouped usage rows]
C --> S[Prompt and local cache stats]
G --> M[Attach stats to existing groups]
S --> M
M --> P[Estimate cached input cost]
P --> D[Dashboard charts and tables]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart LR
Q[Usage query] --> A[Aggregate pass]
Q --> C[Cache pass with all modes]
A --> G[Grouped usage rows]
C --> S[Prompt and local cache stats]
G --> M[Attach stats to existing groups]
S --> M
M --> P[Estimate cached input cost]
P --> D[Dashboard charts and tables]
Loading

Reviews (1): Last reviewed commit: "feat(dashboard): rework usage charts wit..." | Re-trigger Greptile

Comment thread internal/usage/group_cache_stats.go Outdated
Comment on lines +173 to +176
func applyModelCacheStats(rows []ModelUsage, stats map[string]*GroupCacheStats) {
for i := range rows {
rows[i].assign(stats[usageModelGroupKey(rows[i].Model, rows[i].Provider, rows[i].ProviderName)])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Local-Only Groups Are Dropped

The cache fold can only attach data to rows created by the uncached aggregate pass. If a model has only exact or semantic cache hits in the selected period, no aggregate row exists, so its local input and output tokens are discarded and the model is absent from the chart. The user-path and label apply functions have the same gap.

Context Used: CLAUDE.md (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 4d01b5c: the fold now captures each group's identity from the first folded row, and the apply step materializes synthetic rows (zero provider tokens, nil costs) for groups with only local-cache traffic — covered by TestSQLiteLocalOnlyGroupsMaterializeRows for all three groupings.

Comment on lines +105 to +110
stats, err := r.usageCacheStats(ctx, params, "user_path", nil, modelGroupKeys)
if err != nil {
return nil, err
}
applyModelCacheStats(result, stats)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 All Mode Double-Counts Cache Tokens

When an API caller requests cache_mode=all or cache_mode=cached, the aggregate pass includes local-cache tokens in InputTokens and OutputTokens, while this second pass exposes the same tokens again in the additive local-cache fields. Consumers that combine the new series, including the chart accounting this response shape was designed for, report local traffic twice. PostgreSQL and MongoDB use the same two-pass behavior.

Context Used: CLAUDE.md (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 4d01b5c: the cache-stat pass now runs only for uncached-mode requests (the dashboard's mode); for cache_mode=cached/all the fields stay zero since the local tokens are already inside the aggregate sums (and the provider split would not partition them). Covered by TestSQLiteCacheStatsSkippedOutsideUncachedMode.

Comment on lines +441 to +446
if (costs) return Math.max(0, num(row.input_cost) - num(row.cached_input_cost));
const split = num(row.uncached_input_tokens) + num(row.cached_input_tokens) + num(row.cache_write_input_tokens);
return split > 0 ? num(row.uncached_input_tokens) + num(row.cache_write_input_tokens) : num(row.input_tokens);
};
const outputOf = (row) => num(costs ? row.output_cost : row.output_tokens);
const promptOf = (row) => num(costs ? row.cached_input_cost : row.cached_input_tokens);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Estimated Split Exceeds Recorded Cost

cached_input_cost uses current catalog pricing, but input_cost is the recorded historical charge. After a cached-input price increase, the estimate can exceed the recorded input cost; this clamps paid input to zero but still displays the full estimate as prompt-cached cost, so the chart total exceeds both the recorded input and total cost.

Context Used: CLAUDE.md (source)

Artifacts

Repro: targeted Node test exercising the real dashboard chart data path

  • Contains supporting evidence from the run (text/javascript; charset=utf-8).

Repro: verbose runtime output showing the inconsistent chart series and totals

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 4d01b5c: the costs-mode cached segment is now capped at the recorded input cost, so the two input-side segments always sum to it and the bar never exceeds recorded totals. Covered by a new chart test with an over-estimate fixture.

SantiagoDePolonia and others added 2 commits July 21, 2026 20:45
Three confirmed review findings on #574:

- Local-only groups were dropped: a model/user path/label served entirely
  from the local response cache produced no uncached aggregate row, so its
  fold stats were discarded. The apply step now materializes synthetic rows
  (zero provider tokens, nil costs) from the fold's captured group identity;
  synthetic label rows count their local-cache requests.
- cache_mode=all/cached double-counted local tokens: the aggregate sums
  already include local-cache rows in those modes while the additive
  local_cached_* fields repeated them. The cache-stat pass now runs only
  for uncached-mode requests (the dashboard's mode) and the fields stay
  zero otherwise, documented on the reader helpers.
- The estimated cached-input cost (current prices) could exceed the
  recorded input cost, making chart totals exceed recorded totals. The
  costs-mode cached segment is now capped at the recorded input cost, so
  the two input-side segments always sum to it.

Also refactors TestEstimateCachedInputCost into table-driven subtests per
coding guidelines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the Tokens/Costs switch from the usage page header into the sticky
bar it shares with the date picker, so the mode stays reachable while
scrolling the charts and request log — it changes the meaning of
everything on the page, like the date range next to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/usage/reader_postgresql.go (1)

120-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add PostgreSQL-specific tests for the cache-stats second pass.

Codecov flagged this file as missing coverage on the changed cache-stats lines. SQLite already has dedicated coverage for the equivalent behavior (TestSQLiteLocalOnlyGroupsMaterializeRows, TestSQLiteCacheStatsSkippedOutsideUncachedMode in group_cache_stats_test.go); porting analogous cases here (local-only synthetic rows, mode-skip, and ideally a UserPath-filtered case) would close the gap and guard this new code path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_postgresql.go` around lines 120 - 146, Add
PostgreSQL-specific tests covering the usageCacheStats second pass in
PostgreSQLReader: verify local-only groups materialize cache-stat rows, verify
the pass is skipped for cached and all cache modes, and add a UserPath-filtered
case if supported by the existing test helpers. Follow the analogous SQLite
tests and use the PostgreSQL setup and assertions already present in the test
suite.
internal/usage/reader_mongodb.go (1)

290-351: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

MongoDB cache-stats fold doesn't canonicalize the UserPath filter, unlike its own GetUsageByUserPath main query.

GetUsageByUserPath (lines 354-393 in this file) deliberately avoids filtering the raw user_path field: it clears matchParams.UserPath before the $match, adds a canonical _gomodel_user_path field via $addFields, and only then matches the subtree regex against that canonical field — with a comment explaining this is needed to match "the same grouped (root-normalized) expression the rows are grouped by."

usageCacheStats (called at line 436 with the original, unmodified params) skips this entirely and calls mongoUsageMatchFilters(params) directly, which matches the raw user_path field. When a caller filters GetUsageByUserPath by UserPath, the local-cache fold pass can therefore select a different row set than the main aggregate, producing incorrect LocalCachedInputTokens/LocalCachedOutputTokens and potentially wrong/missing local-only synthetic rows for that dimension.

Note reader_sqlite.go and reader_postgresql.go avoid this exact pitfall by threading a userPathExpr parameter through their usageCacheStats, passing the canonical grouped expression specifically for the GetUsageByUserPath call site.

🐛 Proposed fix: canonicalize the UserPath filter in the fold pass
 func (r *MongoDBReader) usageCacheStats(ctx context.Context, params UsageQueryParams, keysFor groupKeysFunc) (map[string]*GroupCacheStats, error) {
 	if normalizeCacheMode(params.CacheMode) != CacheModeUncached {
 		return nil, nil
 	}
 	params.CacheMode = CacheModeAll
-	matchFilters, err := mongoUsageMatchFilters(params)
+	matchParams := params
+	matchParams.UserPath = ""
+	matchFilters, err := mongoUsageMatchFilters(matchParams)
 	if err != nil {
 		return nil, err
 	}
 	pipeline := bson.A{}
 	if len(matchFilters) > 0 {
 		pipeline = append(pipeline, bson.D{{Key: "$match", Value: matchFilters}})
 	}
+	const canonicalUserPathField = "_gomodel_user_path"
+	pipeline = append(pipeline, bson.D{{Key: "$addFields", Value: bson.D{
+		{Key: canonicalUserPathField, Value: mongoUsageGroupedUserPathExpr()},
+	}}})
+	userPath, err := normalizeUsageUserPathFilter(params.UserPath)
+	if err != nil {
+		return nil, err
+	}
+	if userPath != "" {
+		pipeline = append(pipeline, bson.D{{Key: "$match", Value: bson.D{{Key: canonicalUserPathField, Value: bson.D{
+			{Key: "$regex", Value: usageUserPathSubtreeRegex(userPath)},
+		}}}}})
+	}
 	pipeline = append(pipeline, bson.D{{Key: "$project", Value: bson.D{
 		{Key: "model", Value: 1},
 		{Key: "provider", Value: 1},
 		{Key: "provider_name", Value: 1},
 		{Key: "user_path", Value: 1},
 		{Key: "labels", Value: 1},
 		{Key: "cache_type", Value: 1},
 		{Key: "input_tokens", Value: 1},
 		{Key: "output_tokens", Value: 1},
 		{Key: "raw_data", Value: 1},
 	}}})

Want me to open an issue to track this and add a regression test (e.g. a UserPath-filtered variant of the local-only-groups test) alongside the fix?

Also applies to: 436-442

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_mongodb.go` around lines 290 - 351, Update
usageCacheStats and its GetUsageByUserPath call path to apply UserPath filtering
against the same canonical, root-normalized expression used by the main grouped
query. Clear UserPath before mongoUsageMatchFilters, add the canonical user-path
field before matching, and preserve raw-field filtering for other callers;
ensure cache-fold rows match the aggregate rows and retain the existing
local-only grouping behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/usage/reader_mongodb.go`:
- Around line 290-351: Update usageCacheStats and its GetUsageByUserPath call
path to apply UserPath filtering against the same canonical, root-normalized
expression used by the main grouped query. Clear UserPath before
mongoUsageMatchFilters, add the canonical user-path field before matching, and
preserve raw-field filtering for other callers; ensure cache-fold rows match the
aggregate rows and retain the existing local-only grouping behavior.

In `@internal/usage/reader_postgresql.go`:
- Around line 120-146: Add PostgreSQL-specific tests covering the
usageCacheStats second pass in PostgreSQLReader: verify local-only groups
materialize cache-stat rows, verify the pass is skipped for cached and all cache
modes, and add a UserPath-filtered case if supported by the existing test
helpers. Follow the analogous SQLite tests and use the PostgreSQL setup and
assertions already present in the test suite.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f175b0ae-8759-4a89-ac36-10c1d7b89144

📥 Commits

Reviewing files that changed from the base of the PR and between 387263a and 4d01b5c.

📒 Files selected for processing (7)
  • internal/admin/dashboard/static/js/modules/charts.js
  • internal/admin/dashboard/static/js/modules/charts.test.cjs
  • internal/usage/group_cache_stats.go
  • internal/usage/group_cache_stats_test.go
  • internal/usage/reader_mongodb.go
  • internal/usage/reader_postgresql.go
  • internal/usage/reader_sqlite.go

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs`:
- Around line 128-131: Update the sticky-bar assertion in the dashboard layout
test to scope matching within the .sticky-date-range.usage-sticky-controls
container, preventing matches from crossing sibling elements. Replace the greedy
regular-expression approach with a bounded parser or scope-aware assertion while
still verifying the usage-mode-toggle and date-picker content inside that target
container.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e012b049-2700-4bc5-a099-0c80dd287d05

📥 Commits

Reviewing files that changed from the base of the PR and between 4d01b5c and 8fd992c.

📒 Files selected for processing (3)
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs
  • internal/admin/dashboard/templates/page-usage.html

Comment on lines +128 to +131
assert.match(
usage,
/<div class="sticky-date-range usage-sticky-controls">[\s\S]*class="usage-mode-toggle"[\s\S]*{{template "date-picker" \.}}[\s\S]*<\/div>/,
);

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scope the sticky-bar assertion to the target container.

The greedy [\s\S]* portions can cross sibling elements, so the test may pass even if the date picker is moved outside .sticky-date-range.usage-sticky-controls. Use a bounded parser or a scope-aware assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs` around
lines 128 - 131, Update the sticky-bar assertion in the dashboard layout test to
scope matching within the .sticky-date-range.usage-sticky-controls container,
preventing matches from crossing sibling elements. Replace the greedy
regular-expression approach with a bounded parser or scope-aware assertion while
still verifying the usage-mode-toggle and date-picker content inside that target
container.

The MongoDB cache-stat fold filtered the raw user_path field while
GetUsageByUserPath filters the canonical (trimmed, root-normalized)
expression it groups by, so a UserPath-filtered request could fold a
different row set than the aggregate it decorates — wrong or missing
local-cache figures for padded or blank paths. The fold now mirrors the
main query's canonical $addFields + regex match, but only for the
user-path call site: the model/label aggregates filter the raw field,
and their folds must keep doing the same.

Also covers the SQL-backend scan path through the shared pgx-style row
fixture (nullable columns, label expansion, local-vs-provider split),
closing the coverage gap flagged on the PostgreSQL reader's second pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 21, 2026 19:04
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Addressed the two outside-diff-range findings in 7fd31da:

MongoDB fold vs. canonical user-path filtering (Major — confirmed). The fold matched the raw user_path field while GetUsageByUserPath matches the canonical (trimmed, root-normalized) expression it groups by, so a UserPath-filtered request could fold a different row set than the aggregate. Fixed by mirroring the main query's $addFields + canonical regex match — but only for the user-path call site: the model/label aggregates filter the raw field, so their folds keep doing the same (the proposed patch would have canonicalized every call site and introduced the mirror-image mismatch there).

PostgreSQL cache-stats coverage (Trivial). The PG reader's unit tests use fake pgx rows rather than a live database, so the meaningful gap was the shared scan/fold path behind its second pass. Added TestFoldUsageCacheRowsScansNullableColumns, driving foldUsageCacheRows through the same pgx-style row interface: nullable provider_name/user_path/labels/cache_type/raw_data columns, label expansion, identity capture, and the local-vs-provider split. The usageCacheStats query bodies themselves remain integration-only territory, same as the other PG reader queries.

Copilot AI 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.

Pull request overview

This PR reworks the admin dashboard “Usage Analytics” breakdown charts (by model, user path, and label) to use horizontal diverging/stacked bar views and to surface provider prompt-cache and GoModel local response-cache traffic, backed by new per-group cache aggregation fields and cost estimation.

Changes:

  • Added shared per-group cache breakdown fields (GroupCacheFields) to usage aggregate rows and populated them via a second streaming pass across SQLite/PostgreSQL/MongoDB backends.
  • Estimated prompt-cached input cost at read time in the admin handler using current catalog pricing.
  • Reworked dashboard usage charts UI/JS: diverging vs stacked toggle, cache series, “Other” folding, responsive two-per-row layout, and updated JS/CSS/tests/docs.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/usage/reader.go Adds GroupCacheFields (and pricing key) to model/user-path/label aggregate DTOs.
internal/usage/reader_sqlite.go Runs a second pass in uncached mode to fold per-group cache stats and merges them into grouped results.
internal/usage/reader_postgresql.go Same cache-stats second pass + merge for PostgreSQL.
internal/usage/reader_mongodb.go Adds MongoDB cache-stats fold pass (aggregate + per-row decode) and merges results into grouped outputs.
internal/usage/group_cache_stats.go Implements shared folding/merging logic for per-group cache stats and cached-input cost estimation helper.
internal/usage/group_cache_stats_test.go Adds tests covering folding behavior, filter-following, local-only group materialization, and cached cost estimation.
internal/admin/handler_usage.go Computes cached_input_cost in admin responses using PricingResolver.
internal/admin/dashboard/templates/page-usage.html Updates usage page layout, chart toggles, and table columns to display cache series and new views.
internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs Updates layout tests for the usage page sticky controls and new view toggle.
internal/admin/dashboard/static/js/modules/charts.test.cjs Extends chart tests for diverging/stacked behavior, cache series, cost split, and “Other” folding.
internal/admin/dashboard/static/js/modules/charts.js Reworks chart data shaping/config to horizontal diverging/stacked bar charts and cache series handling.
internal/admin/dashboard/static/css/dashboard.css Adds sticky usage controls styling and two-per-row usage chart grid layout.
docs/openapi.json Updates OpenAPI schema for new cache-related aggregate fields.
cmd/gomodel/docs/docs.go Updates embedded swagger doc template for new cache-related aggregate fields.
Files not reviewed (1)
  • cmd/gomodel/docs/docs.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +241 to +256
func applyLabelCacheStats(rows []LabelUsage, stats map[string]*GroupCacheStats) []LabelUsage {
seen := map[string]bool{}
for i := range rows {
seen[rows[i].Label] = true
rows[i].assign(stats[rows[i].Label])
}
for key, s := range stats {
if seen[key] || !s.hasLocalTokens() {
continue
}
row := LabelUsage{Label: key, Requests: s.LocalRequests}
row.assign(s)
rows = append(rows, row)
}
return rows
}
Comment thread internal/usage/group_cache_stats.go Outdated
Comment on lines +56 to +57
// usageModelGroupKey mirrors the SQL grouping (model, provider,
// COALESCE(NULLIF(TRIM(provider_name), ”), provider)).
Comment on lines +11 to 14
<div class="usage-mode-toggle" role="group" aria-label="Usage mode">
<button type="button" class="usage-mode-btn" :class="{active: usageMode==='tokens'}" @click="toggleUsageMode('tokens')">Tokens</button>
<button type="button" class="usage-mode-btn" :class="{active: usageMode==='costs'}" @click="toggleUsageMode('costs')">Costs</button>
</div>

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/usage/reader_mongodb.go (2)

294-335: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Canonical user-path fold fix looks correct; consider extracting the duplicated filter-stage builder.

The canonicalUserPath branch correctly mirrors GetUsageByUserPath's own match/addFields/match sequence (394-404), so the fold now selects the same row set as the aggregate it decorates — this resolves the described mismatch. However, the addFields+regex-match pipeline construction (325-334) is now duplicated almost verbatim from GetUsageByUserPath (391-404). Since this exact duplication is what allowed the two pipelines to drift out of sync originally, extracting a shared helper (e.g., mongoCanonicalUserPathFilterStages(userPath string) []bson.D) would keep both call sites structurally coupled and prevent recurrence.

As per coding guidelines, **/*.go: "Keep the implementation explicit and maintainable rather than relying on clever abstractions" and "isolate provider-specific logic" — a small shared helper here reduces duplication without adding indirection complexity.

♻️ Proposed extraction
+func mongoCanonicalUserPathFilterStages(userPath string) []bson.D {
+	const canonicalUserPathField = "_gomodel_user_path"
+	if userPath == "" {
+		return nil
+	}
+	return []bson.D{
+		{{Key: "$addFields", Value: bson.D{
+			{Key: canonicalUserPathField, Value: mongoUsageGroupedUserPathExpr()},
+		}}},
+		{{Key: "$match", Value: bson.D{{Key: canonicalUserPathField, Value: bson.D{
+			{Key: "$regex", Value: usageUserPathSubtreeRegex(userPath)},
+		}}}}},
+	}
+}

Then both GetUsageByUserPath and usageCacheStats can append mongoCanonicalUserPathFilterStages(userPath)... instead of repeating the stage construction inline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_mongodb.go` around lines 294 - 335, Extract the
duplicated canonical user-path $addFields and regex $match stage construction
into a shared helper such as mongoCanonicalUserPathFilterStages(userPath string)
[]bson.D. Update both usageCacheStats and GetUsageByUserPath to append the
helper’s stages, preserving their existing filtering behavior and stage order.

Source: Coding guidelines


294-335: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add MongoDB regression coverage for the canonical user-path fold path. internal/usage/reader_mongodb_test.go covers match filters, data filters, search regex, and rewrite savings, but not usageCacheStats with canonicalUserPath=true and a subtree filter. That path still needs a test to keep the fold and aggregate aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/usage/reader_mongodb.go` around lines 294 - 335, Add regression
coverage in reader_mongodb_test.go for usageCacheStats with
canonicalUserPath=true and a subtree user-path filter. Verify the generated fold
pipeline applies the canonical grouped-path expression and subtree match
consistently with the aggregate path, preserving alignment between both row
sets.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/usage/reader_mongodb.go`:
- Around line 294-335: Extract the duplicated canonical user-path $addFields and
regex $match stage construction into a shared helper such as
mongoCanonicalUserPathFilterStages(userPath string) []bson.D. Update both
usageCacheStats and GetUsageByUserPath to append the helper’s stages, preserving
their existing filtering behavior and stage order.
- Around line 294-335: Add regression coverage in reader_mongodb_test.go for
usageCacheStats with canonicalUserPath=true and a subtree user-path filter.
Verify the generated fold pipeline applies the canonical grouped-path expression
and subtree match consistently with the aggregate path, preserving alignment
between both row sets.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b830fcc6-fe99-44a3-b8db-ceb071b7e8b0

📥 Commits

Reviewing files that changed from the base of the PR and between 8fd992c and 7fd31da.

📒 Files selected for processing (2)
  • internal/usage/group_cache_stats_test.go
  • internal/usage/reader_mongodb.go

…fold pipeline

Extract the canonical user-path $addFields/$match stage construction into
shared helpers used by both GetUsageByUserPath and the cache-stat fold,
and pull the fold's pipeline building into a pure
mongoUsageCacheStatsPipeline so the row-set alignment is unit-testable.
New tests pin both behaviors: with canonicalUserPath the raw user_path
filter is cleared and the subtree match runs against the materialized
canonical field via the aggregate's own stages; without it the raw-field
filter is preserved and no canonical stages are inserted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 21, 2026 19:21

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Note

Docstrings generation - SUCCESS
Generated docstrings and committed to branch feat/charts-iteration (commit: e00cba6a065a1137f72b689e3a9f4be0549d33ab)

Docstrings generation was requested by @SantiagoDePolonia.

The following files were modified:

* `internal/admin/dashboard/static/js/modules/charts.js`
* `internal/usage/group_cache_stats.go`
* `internal/usage/reader_mongodb.go`

These files were ignored:
* `internal/admin/dashboard/static/js/modules/charts.test.cjs`
* `internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs`
* `internal/usage/group_cache_stats_test.go`
* `internal/usage/reader_mongodb_test.go`

These file types are not supported:
* `docs/openapi.json`
* `internal/admin/dashboard/static/css/dashboard.css`
* `internal/admin/dashboard/templates/page-usage.html`
Copilot AI review requested due to automatic review settings July 21, 2026 19:51

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@SantiagoDePolonia
SantiagoDePolonia merged commit 9f276a3 into main Jul 21, 2026
19 checks passed
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.

3 participants