Skip to content

fix(activity): count calls once, so Activity and Usage agree - #1048

Merged
github-actions[bot] merged 6 commits into
mainfrom
fix/f1-activity-usage-call-parity
Aug 25, 2026
Merged

github-actions[bot] merged 6 commits into
mainfrom
fix/f1-activity-usage-call-parity

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 25, 2026

Copy link
Copy Markdown
Member

Fixes the P0 from the Web UI UX audit (finding F1, also F24) — #1046.

The Activity Log and the Usage tab reported different totals for the same 24-hour window. A repro instance produced three numbers from the same traffic, two of them on the same screen:

Surface Before After
Activity Log header 51 calls · 6 errors 83 events · 50 calls · 12 errors
Usage "Tool calls" tile 19 (31.6% error rate) Calls 50 (24.0%)
Usage "Activity over time" 25 50

Root cause

Two independent miscounts, one per surface:

  • handleActivitySummary (internal/httpapi/activity.go) counts every activity record in the window, and the frontend labelled that total N calls. Security scans, quarantine auto-approvals and system starts are rows in the log, not calls.
  • Usage.vue summed tools client-side. That list is lifetime-cumulative (the window only filters which tools appear), upstream-only (mcpproxy's own retrieve_tools/describe_tool calls vanish) and truncated to top-N — so it also disagreed with the histogram printed directly beneath it.

Fix

One definition of "a call the user made" — storage.CountsAsCall — consulted by both surfaces. The usage aggregate already applied these rules implicitly for its timeline; it now delegates to the shared contract, so timeline, summary endpoint, Web UI and CLI cannot drift apart.

  • ActivitySummaryResponse gains call_count / call_error_count.
  • UsageAggregateResponse gains total_calls / total_errors, computed server-side as the sum of the timeline it carries.
  • The event total is still reported, under its own name — it is the paginator's denominator.
  • mcpproxy activity stats no longer prints the event total as "Total Calls".

Tests

  • internal/httpapi/activity_call_parity_test.go — one record set, both endpoints, asserts the two totals are equal (and that the event total is deliberately larger).
  • internal/storage/activity_call_population_test.go — the admission table.
  • internal/runtime/usage_glance_parity_test.go — the timeline is exactly the shared population; the existing tray-glance oracle still agrees independently (its default case was extended to mirror GlanceSelection.qualifies).
  • frontend/tests/unit/activity-usage-call-parity.spec.ts — header labelling and the Usage headline.

Verified: -race green for httpapi/runtime/storage/contracts/server; both editions build; golangci-lint v2 0 issues; 673 frontend unit tests green; live Playwright check on an isolated instance confirms both rendered surfaces now print 50.

The Activity Log header and the Dashboard's Usage tab reported different
totals for the same 24-hour window on the same instance. On a repro
instance the same traffic produced three numbers within seconds:

  Activity header       51 calls   (every activity record in the window)
  Usage "Activity over time"  25   (calls the user made)
  Usage "Tool calls" tile     19   (a client-side sum of `tools`)

The two Usage numbers sat on the same screen, one directly above the
other.

Root causes, one per surface:

  * handleActivitySummary counted every record type as the header's
    total, and the frontend labelled that total "N calls". Security
    scans, quarantine auto-approvals and system starts are rows in the
    log, not calls.
  * Usage.vue summed `tools` client-side. That list is
    lifetime-cumulative (the window only filters which tools appear),
    upstream-only (mcpproxy's own retrieve_tools/describe_tool calls
    vanish) and truncated to top-N (the tail is silently dropped).

Both now draw on one definition — storage.CountsAsCall — which the usage
aggregate already applied implicitly and now consults explicitly, so the
timeline, the summary endpoint, the Web UI and the CLI cannot drift
apart. The summary gains call_count/call_error_count; the usage response
gains total_calls/total_errors, computed server-side as the sum of the
timeline it carries. The event total is still reported, under its own
name, because it is the paginator's denominator.

After, on the same instance: Activity reads "83 events · 50 calls · 12
errors" and Usage reads "Calls 50 · Errors 12 · 24.0%".

Audit finding F1 (also F24) — #1046
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 967825a
Status: ✅  Deploy successful!
Preview URL: https://94dbccae.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-f1-activity-usage-call-p.mcpproxy-docs.pages.dev

View logs

ActivityFilter.ExcludeCallToolSuccess hides EVERY call_tool_* record, not
only successful ones — a dispatched call emits its own tool_call record
under the same request id whatever the outcome, and a shed one is written
by the limiter. So there is no pre-dispatch failure the counter loses;
the exclusion matches the list's own.
…tract

Cross-model review flagged that the usage endpoint serves a snapshot
behind observability.usage_cache_ttl (5s) while the summary endpoint
counts live, so a call can reach the Activity Log a few seconds before
the Usage tiles. That is inherent to the endpoint never scanning the log
per request (SC-005), it is bounded, and freshness_ms already discloses
it — so it is documented on the contract and in the parity test rather
than traded away.
@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/f1-activity-usage-call-parity

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 32832813359 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

usage_cache_ttl is hot-reloadable and it is the documented bound on how
far the Usage figures may lag the Activity Log. The cache banked an
absolute expiry from the TTL in force when the entry was written, so
lowering the TTL left entries alive past the new bound — an operator
tightening the bound got no effect until the old expiry passed. Entries
now record when they were stored and freshness is judged against the TTL
in force at read time.

Found by cross-model review of the F1 parity work.
@codecov-commenter

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 87.93103% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/mcpproxy/activity_cmd.go 0.00% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

TestUsageCacheHonoursAHotReloadedTTL asserted a 1ns TTL rejects an entry
written moments earlier. On a Windows runner two adjacent time.Now()
calls can report zero elapsed time, so nothing was older than any TTL and
the test failed there. It now back-dates the entry by a minute and checks
a lowered TTL rejects it and a raised one accepts it — no sleeping, no
dependence on clock granularity.

@github-actions github-actions 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.

Approved (Model B): Paperclip review verdicts = ACCEPT and qa-gate green at this head SHA. Arming auto-merge; GitHub merges when all required checks pass.

@github-actions
github-actions Bot merged commit 568a296 into main Aug 25, 2026
41 checks passed
Dumbris added a commit that referenced this pull request Aug 25, 2026
Audit findings F2, F5, F22, F23, F25, F26, F27 and the Activity/Usage
part of F36 (#1046). Builds on #1048's storage.CountsAsCall contract.

F2 — the status tiles are a partition again. ActivitySummaryResponse
gains other_count: the rows whose status is outside the tool-call
vocabulary (a quarantine change stores its action there, a policy
decision its verdict). They were in the total and in no tile, so the
row read 15+4+0+0 under a 42. The tile list now lives in one pure
function, an "Other / internal" tile renders the residual, it filters
like every other tile, and Go + Vitest tests assert the sum.

F5 — repeats fold. Consecutive rows that agree on everything the
collapsed line prints become one expandable run. Status is part of the
run identity, so a run can never mix outcomes and "echo x12" can never
hide the one call that failed; the sensitive-data flag and the
code_execution parent link are in the identity for the same reason.
Folding is on by default, remembered, and off whenever the table is
sorted by something other than time. Successful rows no longer print
"Success" at all — the word stays for screen readers only, so the
marked rows in the column are exactly the ones needing a human.

F22 — the usage charts stop inventing tools and stop quantizing
latency. Names that never completed a call are excluded from "Calls per
tool" and "Token sinks" (named in a footnote, still charted under
errors, and counted the same way by the "active tools" tile).
latencyBucketBoundsMs gains sub-10ms resolution, which is where local
MCP servers live and where the old first bucket had none; Percentile
now reports whether it hit the unbounded overflow bucket, so a figure
renders as a bound ("<= 5 ms") or a floor ("> 10 s") and never as a
stopwatch reading. Every chart axis says what it measures.

F23 — the tokens-saved headline explains itself. It is a structural
estimate over the current tool catalog, so it correctly does not move
per call; the label said otherwise. Relabelled per-request, given the
derivation on hover, and the Dashboard chip now opens the breakdown
that computes it.

F25 — a security_scan drawer shows its verdict and findings by severity
and offers the server and the Security page. A session id is now a
control: filter this log, or open Sessions.

F26 — the Intent column paints the operation word beside the glyph, the
Sensitive badge carries an accessible name, and the filter panel holds
a visible legend for both.

F27 — the Connected tile names its denominator ("2 of 3 enabled
online"): a switched-off server is not an outage. serverCount.disabled
is now asked directly and excludes quarantined servers, so the
Dashboard rail stops reporting one server as two problems.

F36 — the Sessions "View Activity" button stops clipping its label.
github-actions Bot pushed a commit that referenced this pull request Aug 25, 2026
…1052)

* fix(webui): make the Activity and Usage numbers say what they mean

Audit findings F2, F5, F22, F23, F25, F26, F27 and the Activity/Usage
part of F36 (#1046). Builds on #1048's storage.CountsAsCall contract.

F2 — the status tiles are a partition again. ActivitySummaryResponse
gains other_count: the rows whose status is outside the tool-call
vocabulary (a quarantine change stores its action there, a policy
decision its verdict). They were in the total and in no tile, so the
row read 15+4+0+0 under a 42. The tile list now lives in one pure
function, an "Other / internal" tile renders the residual, it filters
like every other tile, and Go + Vitest tests assert the sum.

F5 — repeats fold. Consecutive rows that agree on everything the
collapsed line prints become one expandable run. Status is part of the
run identity, so a run can never mix outcomes and "echo x12" can never
hide the one call that failed; the sensitive-data flag and the
code_execution parent link are in the identity for the same reason.
Folding is on by default, remembered, and off whenever the table is
sorted by something other than time. Successful rows no longer print
"Success" at all — the word stays for screen readers only, so the
marked rows in the column are exactly the ones needing a human.

F22 — the usage charts stop inventing tools and stop quantizing
latency. Names that never completed a call are excluded from "Calls per
tool" and "Token sinks" (named in a footnote, still charted under
errors, and counted the same way by the "active tools" tile).
latencyBucketBoundsMs gains sub-10ms resolution, which is where local
MCP servers live and where the old first bucket had none; Percentile
now reports whether it hit the unbounded overflow bucket, so a figure
renders as a bound ("<= 5 ms") or a floor ("> 10 s") and never as a
stopwatch reading. Every chart axis says what it measures.

F23 — the tokens-saved headline explains itself. It is a structural
estimate over the current tool catalog, so it correctly does not move
per call; the label said otherwise. Relabelled per-request, given the
derivation on hover, and the Dashboard chip now opens the breakdown
that computes it.

F25 — a security_scan drawer shows its verdict and findings by severity
and offers the server and the Security page. A session id is now a
control: filter this log, or open Sessions.

F26 — the Intent column paints the operation word beside the glyph, the
Sensitive badge carries an accessible name, and the filter panel holds
a visible legend for both.

F27 — the Connected tile names its denominator ("2 of 3 enabled
online"): a switched-off server is not an outage. serverCount.disabled
is now asked directly and excludes quarantined servers, so the
Dashboard rail stops reporting one server as two problems.

F36 — the Sessions "View Activity" button stops clipping its label.

* fix(webui): widen the run identity to everything the folded row prints

Cross-model review found three ways a run could fold away something the
collapsed line was displaying on the members' behalf:

- the intent OPERATION TYPE was not in the key, so a run of
  call_tool_write calls against a tool could fold under a "read" lead —
  the two produce records identical in type, server, tool and status.
- only the sensitive-data BOOLEAN was in the key, but the badge prints
  the severity glyph and the detection count, so a critical detection
  could fold under a low-severity lead.
- the key was a space-joined string over free-form fields (server name,
  tool name, details text), so two different rows could agree on one
  joined value. It is JSON.stringify now.

The reason stays out of the key on purpose — that is what reasonsVary
reports. Four regression tests, one per hazard.

* fix(activity): stop dropping the scan findings summary, and clamp the page

Three more from cross-model review.

The settled-scan event carries its rollup as a map[string]int, and
handleSecurityScanSettled read it with a map[string]interface{} type
assertion that never matched — so findings_summary was dropped from
every security_scan record ever written. Nothing noticed because
nothing rendered it; F25's new drawer does. The read now takes the
concrete type (and a JSON round-tripped one), and keeps an EMPTY rollup,
because "the scanners found nothing" and "this record has no summary"
are opposite claims and the drawer must not print the first when it only
knows the second. It now says so, and points at the Security page.

The Activity paginator never clamped currentPage, so folding repeats on
while on a later page — or any refresh that shrinks the list — rendered
an empty table with no hint why. Clamped on the derived page count, so
every path is covered by one rule. Expanded runs and the page now also
reset on a sort, session-filter or grouping change; a run key is a lead
row's id, and a stale one re-expands the wrong run.

The Usage "active tools" figure counts what is charted, so it now admits
the folded tail when the server truncated the list.

* fix(webui): three tie-breaks and one export that would come back empty

Cross-model review, round three.

Exporting from the "Other / internal" view sent that synthetic status to
an endpoint that matches the closed vocabulary exactly, so the file came
back empty — which reads as "there was nothing there". The export drops
the status filter instead; a wider export is recoverable.

Sorting by p95 could not tell a tool BOUNDED at the last histogram
bucket from one that ran PAST it, and the response is truncated to
top-N, so the genuinely slow tool could be dropped in favour of one that
merely touched the ceiling. The overflow flag now breaks that tie.

A failed scan can still carry findings: the debouncer keeps the last
non-nil rollup, so a storm where some scanners finished and a later one
failed settles as "failed" with real findings attached. The drawer shows
them — hiding real findings is the worse error in a security view — and
labels them as partial rather than as the scan's verdict.
Dumbris added a commit that referenced this pull request Aug 26, 2026
The document read as an open P0-P3 backlog, but seven PRs merged on 2026-08-25
(#1044, #1048, #1049, #1051, #1052, #1053, #1054) closed it. Anyone opening this
file against current main was being handed 36 stale defects.

Added a status banner and a Resolution section that separates three things
honestly:

- Re-checked live on v0.61.0 against a running isolated core with real /mcp
  traffic: F1, F4, F5, F7, F8, F12, F13, F20, F24, F26, F33 confirmed fixed,
  each with the observed evidence rather than a PR reference.
- Still open: F6 is half fixed. Focus enters the Add Server dialog and submit is
  in a sticky footer, but Escape still does not close it - AddServerModal.vue has
  no key handler and JsonViewer.vue is the only component under components/ that
  handles Escape at all. F28's "key called optional" half is fixed; its
  three-surfaces state is not.
- Not re-checked: named explicitly rather than implied, so the next reader knows
  which closures rest on a PR body and which on an observation.

Five defects found on main while re-checking are recorded separately, since they
are not regressions of anything in the audit. The first is security-relevant:
quarantining a server through the config file leaves its tools in the search
index, so retrieve_tools still hands the agent their names and descriptions -
which is exactly what quarantine exists to prevent - before the call is refused.
Runtime.QuarantineServer purges the index; the reload path detects the same
transition (quarantined_changed: true) and does not.

Also corrects the record on #1053: its body names F1-F16, which is the tray
audit's numbering mis-pasted. #1055 is the tray PR; #1053 touches no Swift file.
Dumbris added a commit that referenced this pull request Aug 26, 2026
The document read as an open P0-P3 backlog, but eight PRs merged on 2026-08-25
(#1044, #1048, #1049, #1050, #1051, #1052, #1053, #1054) closed it. Anyone
opening this file against current main was being handed 36 stale defects.

Added a status banner and a Resolution section that separates four things:

- Re-checked live on v0.61.0 against a running isolated core with real /mcp
  traffic: F1, F4, F5, F6, F7, F8, F12, F13, F20, F24, F26, F33 confirmed fixed,
  each with the observed evidence rather than a PR reference.
- Not re-checked: named explicitly rather than implied, so the next reader knows
  which closures rest on a PR body and which on an observation. F28 is called
  out as partial - its "key called optional" half is fixed, its
  three-surfaces-at-once state is not.
- Corrections to the audit itself: F23 mis-diagnosed a per-request structural
  estimate as a stale aggregation, and F31's "Manual badge has no tooltip" half
  was wrong even at audit time (spec-088 trust-mode badge, already titled).
- Five defects found on main while re-checking, recorded separately since they
  are not regressions of anything in the audit. The first is security-relevant:
  quarantining a server through the config file leaves its tools in the search
  index, so retrieve_tools still hands the agent their names and descriptions -
  which is exactly what quarantine exists to prevent - before the call is
  refused. Runtime.QuarantineServer purges the index; the reload path detects
  the same transition (quarantined_changed: true) and does not.

F6 carries a methodology note. Re-checking it produced a false negative: Escape
driven through the browser-automation layer never reaches the page's document
keydown listener, so the dialog looked stuck open. Dispatched in-page it closes
at once, and #1050's useModalA11y is wired into all five <dialog :open> modals.
The sweep assertion this document proposes must dispatch in-page, or it tests
the automation layer instead of the app.

Also corrects the record on #1053: its body names F1-F16, which is the tray
audit's numbering mis-pasted. #1055 is the tray PR; #1053 touches no Swift file.
Dumbris added a commit that referenced this pull request Aug 26, 2026
* docs(qa): Web UI UX audit — ranked findings (ux-audit epic)

Heuristic + live-instance audit of every routed Web UI view in light/dark,
first-run and populated states, at 1440/820/390px. 36 findings ranked P0-P3
with view, evidence, heuristic violated, suggested fix and effort.

Audited against standard heuristics plus the house activity-UI rules
(grouping, failure-only status, intent reason, histogram/log parity).

* docs(qa): record what the Web UI audit's backlog actually resolved to

The document read as an open P0-P3 backlog, but eight PRs merged on 2026-08-25
(#1044, #1048, #1049, #1050, #1051, #1052, #1053, #1054) closed it. Anyone
opening this file against current main was being handed 36 stale defects.

Added a status banner and a Resolution section that separates four things:

- Re-checked live on v0.61.0 against a running isolated core with real /mcp
  traffic: F1, F4, F5, F6, F7, F8, F12, F13, F20, F24, F26, F33 confirmed fixed,
  each with the observed evidence rather than a PR reference.
- Not re-checked: named explicitly rather than implied, so the next reader knows
  which closures rest on a PR body and which on an observation. F28 is called
  out as partial - its "key called optional" half is fixed, its
  three-surfaces-at-once state is not.
- Corrections to the audit itself: F23 mis-diagnosed a per-request structural
  estimate as a stale aggregation, and F31's "Manual badge has no tooltip" half
  was wrong even at audit time (spec-088 trust-mode badge, already titled).
- Five defects found on main while re-checking, recorded separately since they
  are not regressions of anything in the audit. The first is security-relevant:
  quarantining a server through the config file leaves its tools in the search
  index, so retrieve_tools still hands the agent their names and descriptions -
  which is exactly what quarantine exists to prevent - before the call is
  refused. Runtime.QuarantineServer purges the index; the reload path detects
  the same transition (quarantined_changed: true) and does not.

F6 carries a methodology note. Re-checking it produced a false negative: Escape
driven through the browser-automation layer never reaches the page's document
keydown listener, so the dialog looked stuck open. Dispatched in-page it closes
at once, and #1050's useModalA11y is wired into all five <dialog :open> modals.
The sweep assertion this document proposes must dispatch in-page, or it tests
the automation layer instead of the app.

Also corrects the record on #1053: its body names F1-F16, which is the tray
audit's numbering mis-pasted. #1055 is the tray PR; #1053 touches no Swift file.
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.

2 participants