Skip to content

fix(usage): disclose the window a truncated read actually covers - #1532

Merged
lidge-jun merged 2 commits into
devfrom
codex/1497-usage-truncation-disclosure
Aug 12, 2026
Merged

fix(usage): disclose the window a truncated read actually covers#1532
lidge-jun merged 2 commits into
devfrom
codex/1497-usage-truncation-disclosure

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

GET /api/usage reads the newest managementUsageMaxReadBytes (64 MiB by default) and applies the 7d/30d/all filter after that read. On a high-volume installation the newest 64 MiB can cover far less than the selected range, so 30d and Available history summarize the same moving byte tail while the UI keeps both labels. In the reported case 30d omitted 73.6% of in-range requests, and cumulative totals can decrease as older rows fall out of the window.

This PR does not make aggregation complete. It stops the product from presenting a truncated read as the range the user asked for.

The response now carries snapshotWindowStart / snapshotWindowEnd: the timestamp bounds of the rows the reader actually loaded, computed over snapshot.entries before range and surface filtering. That population is deliberate — truncation is a property of the read, not of the query, so the window that matters is the one the reader could see.

Why there is no rangeFullyCovered flag. An earlier draft proposed one, derived from the oldest retained entry. It is unsound: usage.jsonl is appended when a request completes while each row carries the request start time, so a long-running request can be appended after shorter ones that started later. The oldest loaded timestamp therefore does not bound what the dropped prefix contains, and a flag claiming "this range is complete" could be wrong. Shipping a field whose entire value is trustworthiness, in a state where it can lie, would repeat the defect this issue reports.

The dashboard now names the loaded window and the notice moves from ok to warn — a total that silently omits in-range rows is a caveat, not a status update. The rendering guard uses != null so a dashboard talking to an older proxy that omits the fields falls back to the existing generic wording instead of rendering Invalid Date.

This is a partial mitigation and does not close #1497. The issue's bar is complete 7d/30d aggregation and monotonic all-time totals, which needs the daily-rollup work tracked in #1008.

Verification

Live reproduction against a real proxy on an isolated home, with a 4,000-row / 40-day ledger and managementUsageMaxReadBytes: 65536:

30d selected — the range claims 30 days; the banner names the ~2 days actually loaded.

30d view showing the truncation banner

Selecting Available history on the same data returns the identical 280 requests — exactly the confusion #1497 reports — now explained rather than hidden.

The wording states observed extrema rather than a covered interval. Review caught an earlier draft that said totals "cover the usage loaded from X to Y" and that "older entries exceed the read limit": both overstate what the data supports, in precisely the way this PR exists to stop. Retained rows can have gaps, and the dropped prefix can contain requests whose start times fall inside the displayed interval.

Automated, on a Linux runner (Bun 1.3.14):

  • bun x tsc --noEmit — exit 0
  • bun test tests/api-usage.test.ts tests/usage-summary.test.ts tests/usage-log.test.ts — 75 pass, 0 fail
  • cd gui && bun run lint — exit 0
  • cd gui && bun run lint:i18n — exit 0
  • cd gui && bun test tests — 772 pass, 0 fail
  • cd gui && bun run build — exit 0

Red-before evidence: with only the test diff applied to unmodified dev, 3 of the new API tests fail. Weakening the GUI date guard back to a presence check makes 2 of the GUI tests fail.

One pre-existing flake is worth flagging so it is not mistaken for this change: usage route does not cache a summary whose overlay version changed mid-read fails intermittently. It reproduces on pristine origin/dev files at the same rate, so it is not introduced here.

New coverage asserts the contract rather than the implementation: the window matches independently computed bounds on a truncated read; range and surface filters do not move it; an untruncated read spans the whole fixture; an empty ledger yields null rather than NaN/Infinity; a cached response carries both fields through unchanged; and an out-of-range bound falls back to the generic wording instead of rendering Invalid Date.

The filter-invariance test builds its own fixture rather than reusing the shared one. Review found the first version vacuous: a 256-byte tail retained a single row, so the filters discarded nothing and the comparison held trivially. It now retains three rows that the range filter cuts to two and the surface filter to one, and asserts exact bounds plus numeric types. The cache test now asserts the second request is a real cache hit.

All eight locale modules carry the new string.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Refs #1497, #1008

The usage API reads the newest managementUsageMaxReadBytes and applies
the 7d/30d/all filter afterwards, so on a busy installation both 30d and
Available history summarize the same moving byte tail. A measured case
had 30d omitting 73.6% of in-range requests while still labelled 30d,
and cumulative totals can decrease as older rows fall out of the window.

The response now reports snapshotWindowStart/End: the timestamp bounds of
the rows the reader loaded, before range and surface filtering. It
describes the READ rather than the query, because truncation is a
property of the read.

Deliberately not a completeness claim. usage.jsonl is appended when a
request completes while each row carries the request start time, so a
long-running request can be appended after shorter ones that started
later. The oldest loaded timestamp therefore does not bound what the
dropped prefix contains, and no derived rangeFullyCovered flag would be
sound. The dashboard names the loaded window and downgrades the notice
from ok to warn, since a total that omits in-range rows is a caveat.

This is a partial mitigation, not the fix #1497 asks for: complete
aggregation still needs the rollup work tracked in #1008.

Refs #1497, #1008
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The /api/usage response now includes the minimum and maximum timestamps from the loaded usage snapshot. The Usage page renders localized truncation warnings with valid bounds and generic text for invalid bounds. All supported locales define the new message, with API and GUI coverage.

Changes

Usage history window

Layer / File(s) Summary
Expose usage snapshot bounds
src/server/management/logs-usage-routes.ts
Lines 124-146 compute finite bounds. Lines 235-243 return them. Lines 295-296 return null bounds on read failure.
Render localized truncation details
gui/src/pages/Usage.tsx, gui/src/i18n/*.ts
UsageResponse adds optional bounds at lines 91-95. Lines 824-841 render localized dates when both timestamps are valid. Lines 860-868 reject invalid dates. Eight locale catalogs add usage.historyTruncatedWindow.
Validate API and GUI behavior
tests/api-usage.test.ts, gui/tests/usage-layout.test.ts
Tests cover truncation, filtering, empty ledgers, caching, locale coverage, warning styling, valid date ranges, and fallback behavior.

Estimated code review effort: 2 (Simple) | ~15 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UsageClient
  participant UsageAPI
  participant UsagePage
  participant LocaleCatalog
  UsageClient->>UsageAPI: request /api/usage
  UsageAPI-->>UsageClient: snapshot bounds and usage data
  UsagePage->>LocaleCatalog: resolve truncation message
  UsagePage-->>UsageClient: render localized warning and date range
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: ingwannu, wibias

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% 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 and concisely describes the main change: reporting the actual time window covered by truncated usage reads.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/1497-usage-truncation-disclosure

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7c7814e18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread gui/src/pages/Usage.tsx Outdated
Comment on lines +835 to +836
start: new Date(data.snapshotWindowStart).toLocaleString(),
end: new Date(data.snapshotWindowEnd).toLocaleString(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the selected dashboard locale for window timestamps

When the dashboard language differs from the browser or operating-system locale, Usage.tsx renders these dates using the browser default, producing a mixed-language warning with the wrong date ordering and punctuation. Pass the existing locale value to both toLocaleString(locale) calls, as this page already does for its other localized values.

Useful? React with 👍 / 👎.

Comment on lines +242 to +243
snapshotWindowStart: window.start,
snapshotWindowEnd: window.end,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Document the new usage snapshot-window contract

These fields add a user-visible management API contract whose pre-filter semantics and nullable bounds are not documented in docs-site/src/content/docs/reference/management-api.md or its translated versions, leaving API consumers unable to discover or correctly interpret them. Update the management API reference alongside this response change.

AGENTS.md reference: AGENTS.md:L234-L235

Useful? React with 👍 / 👎.

@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 `@gui/src/pages/Usage.tsx`:
- Around line 833-838: Update both date formatters in the history truncation
message within Usage to pass the active locale from useI18n() toLocaleString,
preserving the existing snapshotWindowStart and snapshotWindowEnd handling while
ensuring both dates follow the selected GUI locale.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 742f13fc-05cf-4b11-8726-1014c5bca1dd

📥 Commits

Reviewing files that changed from the base of the PR and between 567a6d4 and f7c7814.

📒 Files selected for processing (12)
  • gui/src/i18n/de.ts
  • gui/src/i18n/en.ts
  • gui/src/i18n/ja.ts
  • gui/src/i18n/ko.ts
  • gui/src/i18n/ru.ts
  • gui/src/i18n/tr.ts
  • gui/src/i18n/zh-TW.ts
  • gui/src/i18n/zh.ts
  • gui/src/pages/Usage.tsx
  • gui/tests/usage-layout.test.ts
  • src/server/management/logs-usage-routes.ts
  • tests/api-usage.test.ts

Comment thread gui/src/pages/Usage.tsx Outdated
Comment on lines +833 to +838
{data.snapshotWindowStart != null && data.snapshotWindowEnd != null
? t("usage.historyTruncatedWindow", {
start: new Date(data.snapshotWindowStart).toLocaleString(),
end: new Date(data.snapshotWindowEnd).toLocaleString(),
})
: t("usage.historyTruncated")}

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

Pass the active locale into the date formatter.

new Date(data.snapshotWindowStart).toLocaleString() and the matching call for snapshotWindowEnd do not pass a locale argument. Intl-based toLocaleString() falls back to the browser/OS default locale when no argument is given. Every other formatter in this file (formatTokens(summary.totalTokens, locale) at Line 289, formatUsdEstimate(summary.estimatedCostUsd, locale) at Line 306) explicitly threads the locale value from useI18n(). If a user selects a GUI locale that differs from their OS locale (for example, tr GUI on an en-US OS), the truncation window dates render in the wrong locale while every other number on the page respects the selected locale.

Pass locale explicitly to keep the date format consistent with the rest of the page.

🛡️ Proposed fix
               {data.snapshotWindowStart != null && data.snapshotWindowEnd != null
                 ? t("usage.historyTruncatedWindow", {
-                  start: new Date(data.snapshotWindowStart).toLocaleString(),
-                  end: new Date(data.snapshotWindowEnd).toLocaleString(),
+                  start: new Date(data.snapshotWindowStart).toLocaleString(locale),
+                  end: new Date(data.snapshotWindowEnd).toLocaleString(locale),
                 })
                 : t("usage.historyTruncated")}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{data.snapshotWindowStart != null && data.snapshotWindowEnd != null
? t("usage.historyTruncatedWindow", {
start: new Date(data.snapshotWindowStart).toLocaleString(),
end: new Date(data.snapshotWindowEnd).toLocaleString(),
})
: t("usage.historyTruncated")}
{data.snapshotWindowStart != null && data.snapshotWindowEnd != null
? t("usage.historyTruncatedWindow", {
start: new Date(data.snapshotWindowStart).toLocaleString(locale),
end: new Date(data.snapshotWindowEnd).toLocaleString(locale),
})
: t("usage.historyTruncated")}
🤖 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 `@gui/src/pages/Usage.tsx` around lines 833 - 838, Update both date formatters
in the history truncation message within Usage to pass the active locale from
useI18n() toLocaleString, preserving the existing snapshotWindowStart and
snapshotWindowEnd handling while ensuring both dates follow the selected GUI
locale.

Three review findings, all correct.

The notice said totals cover usage 'from start to end' and that older
entries exceed the read limit. Both claims overstate what the data
supports, and they overstate it in exactly the way this PR exists to
stop: usage.jsonl is appended on request completion while rows carry the
request start time, so retained rows can have gaps and the dropped
prefix can contain requests whose start times fall inside the displayed
interval. The wording now describes the bounds as what they are — the
range of request start times among loaded rows — in all eight locales.

The filter-invariance test was vacuous. A 256-byte tail retained a
single row, so the surface and range filters discarded nothing and the
comparisons held trivially; on unmodified dev every compared field was
undefined, which also compared equal. It now builds its own fixture
where the retained set is 3 rows that the filters cut to 2 and then 1,
asserts exact independently computed bounds, and asserts the fields are
numeric. The cache test now asserts the second request is a cache hit.

Presence-checking the bounds prevented an omitted field from rendering
'Invalid Date' but not an out-of-range one, and the reader preserves
whatever a hand-edited row carries. Both bounds now have to round-trip
through Date before the detailed wording is used, with a rendered test
covering the unrenderable case.

@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 `@gui/tests/usage-layout.test.ts`:
- Around line 164-173: Update the global-state setup and teardown around
globalKeys to save each property’s original descriptor with
Object.getOwnPropertyDescriptor(globalThis, key), rather than only its value. In
the cleanup logic, restore existing descriptors with Object.defineProperty and
delete keys whose descriptors were undefined, preserving the original
own-property shape for document, window, navigator, localStorage, and
IS_REACT_ACT_ENVIRONMENT.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d9e7e781-db43-4759-a6d6-244d32e2f27f

📥 Commits

Reviewing files that changed from the base of the PR and between f7c7814 and d65bb0c.

📒 Files selected for processing (11)
  • gui/src/i18n/de.ts
  • gui/src/i18n/en.ts
  • gui/src/i18n/ja.ts
  • gui/src/i18n/ko.ts
  • gui/src/i18n/ru.ts
  • gui/src/i18n/tr.ts
  • gui/src/i18n/zh-TW.ts
  • gui/src/i18n/zh.ts
  • gui/src/pages/Usage.tsx
  • gui/tests/usage-layout.test.ts
  • tests/api-usage.test.ts

Comment on lines +164 to +173
const globalKeys = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
const previous = Object.fromEntries(globalKeys.map(key => [key, Reflect.get(globalThis, key)]));
const originalFetch = globalThis.fetch;
const testWindow = new Window({ url: "http://localhost/" });
Object.defineProperties(globalThis, {
document: { configurable: true, value: testWindow.document },
window: { configurable: true, value: testWindow },
navigator: { configurable: true, value: testWindow.navigator },
localStorage: { configurable: true, value: testWindow.localStorage },
});

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore the original global-property shape.

Lines 164-165 save only values. Lines 231-233 then define every global key, even when that key did not exist before this test. This leaks own window, document, navigator, localStorage, or IS_REACT_ACT_ENVIRONMENT properties into later tests.

Save each original property descriptor. Restore the descriptor when it exists. Delete the property when it did not exist.

Proposed fix
-  const previous = Object.fromEntries(globalKeys.map(key => [key, Reflect.get(globalThis, key)]));
+  const previous = Object.fromEntries(
+    globalKeys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
+  );
...
   for (const key of globalKeys) {
-    Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] });
+    const descriptor = previous[key];
+    if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+    else Reflect.deleteProperty(globalThis, key);
   }

Also applies to: 231-233

🤖 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 `@gui/tests/usage-layout.test.ts` around lines 164 - 173, Update the
global-state setup and teardown around globalKeys to save each property’s
original descriptor with Object.getOwnPropertyDescriptor(globalThis, key),
rather than only its value. In the cleanup logic, restore existing descriptors
with Object.defineProperty and delete keys whose descriptors were undefined,
preserving the original own-property shape for document, window, navigator,
localStorage, and IS_REACT_ACT_ENVIRONMENT.

@lidge-jun
lidge-jun merged commit 9e777fb into dev Aug 12, 2026
46 of 48 checks passed
@lidge-jun
lidge-jun deleted the codex/1497-usage-truncation-disclosure branch August 12, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant