Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3508,6 +3508,96 @@ describe("UsageService", () => {
}),
);

it.effect(
"reuses old live transcripts across all-time refreshes without retaining deleted history",
() =>
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse("2026-12-01T00:00:00Z"));
const { transcript, settings, home } = yield* setup;
const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000;
yield* Effect.promise(async () => {
await NodeFSP.writeFile(transcript, claudeLine(1, 5));
await NodeFSP.utimes(transcript, lastWrite, lastWrite);
});
yield* Effect.gen(function* () {
const service = yield* UsageService.make;
const allTime = {
...WINDOW,
sinceDay: UsageDay.make("1970-01-01"),
untilDay: UsageDay.make("2026-12-01"),
};
const open = vi.spyOn(NodeFSP, "open");
yield* Effect.addFinalizer(() => Effect.sync(() => open.mockRestore()));
open.mockClear();
assert.strictEqual(totalOutputTokens(yield* service.readSummary(allTime)), 5);
assert.isTrue(open.mock.calls.some(([path]) => path === transcript));
open.mockClear();
for (const sinceDay of ["2026-11-02", "2026-09-03"]) {
const recent = { ...allTime, sinceDay: UsageDay.make(sinceDay) };
assert.deepStrictEqual((yield* service.readSummary(recent)).buckets, []);
}
assert.strictEqual(
totalOutputTokens(yield* service.readSummary({ ...allTime, awaitRefresh: true })),
5,
);
assert.isFalse(open.mock.calls.some(([path]) => path === transcript));

yield* Effect.promise(async () => {
await NodeFSP.appendFile(transcript, claudeLine(2, 7));
await NodeFSP.utimes(transcript, lastWrite + 86400, lastWrite + 86400);
});
assert.strictEqual(totalOutputTokens(yield* service.readSummary(allTime)), 12);
yield* Effect.promise(async () => {
await NodeFSP.writeFile(transcript, claudeLine(3, 9));
await NodeFSP.utimes(transcript, lastWrite + 172800, lastWrite + 172800);
});
assert.strictEqual(totalOutputTokens(yield* service.readSummary(allTime)), 9);

yield* Effect.promise(() => NodeFSP.rm(transcript));
assert.deepStrictEqual((yield* service.readSummary(allTime)).buckets, []);
yield* service.awaitPersisted;
const restarted = yield* UsageService.make;
assert.deepStrictEqual((yield* restarted.readSummary(allTime)).buckets, []);
}).pipe(
Effect.provide(layerService({ prefix: "usage-service-all-time-test", home, settings })),
);
}).pipe(Effect.scoped),
);

it.effect("answers a provisional all-time read with old and recent transcripts", () =>
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse("2026-12-01T00:00:00Z"));
const { transcript, settings, home } = yield* setup;
const recent = NodePath.join(NodePath.dirname(transcript), "recent.jsonl");
yield* Effect.promise(async () => {
const oldWrite = Date.parse("2026-08-01T10:00:00Z") / 1000;
const recentWrite = Date.parse("2026-11-30T10:00:00Z") / 1000;
await NodeFSP.writeFile(transcript, claudeLine(1, 5));
await NodeFSP.utimes(transcript, oldWrite, oldWrite);
await NodeFSP.writeFile(recent, claudeLine(2, 3));
await NodeFSP.utimes(recent, recentWrite, recentWrite);
});
yield* Effect.gen(function* () {
const service = yield* UsageService.make;
const allTime = {
...WINDOW,
sinceDay: UsageDay.make("1970-01-01"),
untilDay: UsageDay.make("2026-12-01"),
};
assert.strictEqual(totalOutputTokens(yield* service.readSummary(allTime)), 8);
// The recent file keeps the source refreshing, so this read cannot fall back to an exact scan.
assert.strictEqual(
totalOutputTokens(yield* service.readSummary({ ...allTime, provisional: true })),
8,
);
}).pipe(
Effect.provide(
layerService({ prefix: "usage-service-all-time-provisional", home, settings }),
),
);
}).pipe(Effect.scoped),
);

it.effect("reports saved usage of a removed directory only for windows it reaches", () =>
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse("2026-08-12T00:00:00Z"));
Expand Down
34 changes: 29 additions & 5 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ export const make = Effect.gen(function* () {
const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>();

const fileCache: ScanCache = new Map();
// Old live files stay warm without enlarging persisted history or normal-window cache walks.
const historicalFileCache: ScanCache = new Map();
// Reads from concurrent windows can still hold the original record after it is corrected.
const resolvedRecords = new WeakMap<UsageRecord, UsageRecord>();
const correctedRecord = (record: UsageRecord) => resolvedRecords.get(record) ?? record;
Expand Down Expand Up @@ -518,7 +520,7 @@ export const make = Effect.gen(function* () {
readonly failed?: true;
readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined };
}> {
const cached = fileCache.get(filePath);
const cached = fileCache.get(filePath) ?? historicalFileCache.get(filePath);
// Provider is part of the identity: if both providers were ever pointed
// at one directory, a hit parsed by the other parser must not be reused.
if (
Expand Down Expand Up @@ -647,6 +649,7 @@ export const make = Effect.gen(function* () {
const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (
source: TranscriptSource,
windowStartMs: number,
retentionCutoffMs: number,
fromMemory: boolean,
) {
const { provider, format, dir, volumeId, fileName } = source;
Expand All @@ -662,6 +665,17 @@ export const make = Effect.gen(function* () {
const { files, walked, failedPaths } = yield* Effect.promise(() =>
listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
);
if (windowStartMs < retentionCutoffMs && failedPaths === 0) {
const livePaths = new Set(files.map((file) => file.path));
for (const [filePath, entry] of historicalFileCache) {
if (
entry.mtimeMs >= windowStartMs &&
isWithinDirectory(filePath, dir) &&
!livePaths.has(filePath)
)
historicalFileCache.delete(filePath);
}
}
const read = yield* Effect.forEach(
files,
(file) =>
Expand All @@ -675,18 +689,21 @@ export const make = Effect.gen(function* () {
// A scan of another window may have cached its own read of this file
// meanwhile. Then keep whichever read saw the later file, so a slower
// scan never replaces newer usage with older.
const current = fileCache.get(path);
const current = fileCache.get(path) ?? historicalFileCache.get(path);
if (
current === update.replaces ||
current === undefined ||
isLaterRead(update.entry, current)
) {
fileCache.set(path, {
const cache = update.entry.mtimeMs < retentionCutoffMs ? historicalFileCache : fileCache;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (cache === fileCache) historicalFileCache.delete(path);
else if (fileCache.delete(path)) cacheDirty = true;
cache.set(path, {
...update.entry,
records: correctedBatch(update.entry.records),
tailRecords: correctedBatch(update.entry.tailRecords),
});
cacheDirty = true;
if (cache === fileCache) cacheDirty = true;
}
return { path, records, malformedRecords };
});
Expand Down Expand Up @@ -755,7 +772,12 @@ export const make = Effect.gen(function* () {

// Scan independent sources together, directories sequentially to bound open files; keep order, since the first copy decides a duplicate's bucket.
const [transcripts, scanDirs] = yield* Effect.all(
[Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs, fromMemory)), scans],
[
Effect.forEach(dirs, (dir) =>
scanTranscriptDir(dir, windowStartMs, retentionCutoffMs, fromMemory),
),
scans,
],
{ concurrency: "unbounded" },
);
const scanned: readonly ScannedDir[] = [...transcripts, ...pending, ...scanDirs.flat()];
Expand Down Expand Up @@ -820,8 +842,10 @@ export const make = Effect.gen(function* () {
);
// A window no complete scan has read would show partial totals, so every directory must cover it.
// Saved files are keyed by path, so two providers reading one directory keep only one parse.
// Windows reaching past retention need the in-memory historical files, which a saved snapshot lacks.
const fromMemory =
provisional &&
windowStartMs >= retentionCutoffMs &&
resolved.dirs.length > 0 &&
new Set(resolved.dirs.map((source) => source.dir)).size === resolved.dirs.length &&
resolved.dirs.every(
Expand Down
21 changes: 8 additions & 13 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ import {
formatTokens,
formatUsageContractMismatch,
formatUsd,
makeWindow,
usageSourceMessages,
} from "@t3tools/shared/usageFormat";
import { Button, InlineButton } from "../ui/button";
Expand Down Expand Up @@ -80,6 +79,7 @@ import { WorkspacePageContainer } from "../WorkspacePageContainer";
import { UsageLimitsSection } from "./UsageLimits";
import { UsagePriceOverrides } from "./UsagePriceOverrides";
import { UsageProviderChart } from "./UsageProviderChart";
import { makeUsageWindow, trimUsageWindow } from "./usageWindow";
import { SpeedPremium, UsageModelDialog } from "./UsageModelDialog";
import { UsageShareBar } from "./UsageShareBar";
import {
Expand Down Expand Up @@ -188,11 +188,7 @@ export function UsagePage() {
};
const [windowSelection, setWindowSelection] = useState(() => ({
days: preferences.windowDays,
window: makeWindow(
preferences.windowDays,
undefined,
preferences.windowDays === 1 ? "hour" : "day",
),
window: makeUsageWindow(preferences.windowDays),
}));
const metric = preferences.metric;
const showingLimits = metric === "limits";
Expand All @@ -204,7 +200,6 @@ export function UsagePage() {
useState<ReadonlySet<EnvironmentId> | null>(null);
const hiddenProviders = new Set(preferences.hiddenProviders);
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
const {
merged: answeredUsage,
environments,
Expand All @@ -214,7 +209,7 @@ export function UsagePage() {
refresh,
} = useUsage(window, selectedEnvironmentIds, hiddenProviders);
const merged = shown?.merged ?? answeredUsage;
const shownWindow = shown?.window ?? window;
const shownWindow = trimUsageWindow(shown?.window ?? window, merged.daily);
const shownHourly = shownWindow.resolution === "hour";
const refreshingUsage = isRefreshing && !showingLimits;
const showingKept = shown !== null && shown.window !== window;
Expand All @@ -240,7 +235,7 @@ export function UsagePage() {
updatePreferences({ windowDays: days });
setWindowSelection({
days,
window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
window: makeUsageWindow(days),
});
};
const selectMetric = (nextMetric: UsageMetric) => {
Expand Down Expand Up @@ -305,7 +300,7 @@ export function UsagePage() {
});
return;
}
const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day");
const nextWindow = makeUsageWindow(windowDays);
if (
nextWindow.sinceDay !== window.sinceDay ||
nextWindow.untilDay !== window.untilDay ||
Expand Down Expand Up @@ -357,7 +352,7 @@ export function UsagePage() {
? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone, hour12)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone, hour12)}`
: `${formatDayShort(shownWindow.sinceDay)} to ${formatDayShort(shownWindow.untilDay)}`;
const topbarContent = (
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 @4xl/usage-header:flex">
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 @5xl/usage-header:flex">
<WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
<WorkspaceBreadcrumbItem>
<h1>Usage</h1>
Expand Down Expand Up @@ -390,7 +385,7 @@ export function UsagePage() {
{windowLabel}
</span>
) : null}
<div className="ms-auto hidden shrink-0 items-center justify-end gap-2 @4xl/usage-header:flex">
<div className="ms-auto hidden shrink-0 items-center justify-end gap-2 @5xl/usage-header:flex">
{/* Limits are always current, so they have no period. The period sits before the metric
tabs so dropping it never moves them. */}
{showingLimits ? null : (
Expand Down Expand Up @@ -436,7 +431,7 @@ export function UsagePage() {
<RefreshIcon size="sm" refreshing={isRefreshing} />
</Button>
</div>
<div className="ms-auto flex shrink-0 items-center justify-end gap-1 @4xl/usage-header:hidden">
<div className="ms-auto flex shrink-0 items-center justify-end gap-1 @5xl/usage-header:hidden">
{showingLimits ? null : (
<Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}>
<SelectTrigger
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/usage/UsageProviderChart.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vite-plus/test";

import { buildPeriodColumns } from "./UsageProviderChart";
import { buildPeriodColumns, formatChartDay } from "./UsageProviderChart";

describe("formatChartDay", () => {
it.each([
{ days: ["2026-01-01", "2026-12-31"], label: "Jan 1" },
{ days: ["2025-12-31", "2026-01-01"], label: "Jan 1, 2026" },
{ days: ["2024-01-01", "2026-01-01"], label: "Jan 1, 2026" },
{ days: ["2026-01-01"], label: "Jan 1" },
])("formats dates across $days", ({ days, label }) => {
expect(formatChartDay("2026-01-01", days)).toBe(label);
});
});

describe("buildPeriodColumns", () => {
const days = ["2026-08-01", "2026-08-02", "2026-08-03"];
Expand Down
24 changes: 15 additions & 9 deletions apps/web/src/components/usage/UsageProviderChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const NONE_LOADING: ReadonlySet<UsageProviderKind> = new Set();

export type UsageChartMetric = "tokens" | "cost";

export function formatChartDay(day: string, days: readonly string[]): string {
const label = formatDayShort(day);
return days[0]?.slice(0, 4) !== days.at(-1)?.slice(0, 4) ? `${label}, ${day.slice(0, 4)}` : label;
}

interface UsageProviderChartProps {
readonly providers: readonly UsageProviderKind[];
readonly loadingProviders?: ReadonlySet<UsageProviderKind>;
Expand Down Expand Up @@ -201,14 +206,13 @@ function buildChart(
const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1);
const paths = providers.map((provider) => {
const slot = PROVIDER_ORDER.indexOf(provider);
const line = curvePath(
smoothCurve(
columns.map((column, periodIndex) => ({
x: periodIndex * stepX,
y: valueToY(column.bands[slot]?.value ?? 0, scale.max),
})),
),
);
const points = columns.map((column, periodIndex) => ({
x: periodIndex * stepX,
y: valueToY(column.bands[slot]?.value ?? 0, scale.max),
}));
const first = points[0];
if (points.length === 1 && first !== undefined) points.push({ ...first, x: VIEW_WIDTH });
const line = curvePath(smoothCurve(points));
return {
provider,
loading: loadingProviders.has(provider),
Expand Down Expand Up @@ -436,7 +440,9 @@ export function UsageProviderChart({
cn("transition-opacity", loading && "opacity-40 delay-150");
const format = metric === "tokens" ? formatTokens : formatUsd;
const formatPeriod = (period: string) =>
resolution === "hour" ? formatHourShort(period, timeZone, hour12) : formatDayShort(period);
resolution === "hour"
? formatHourShort(period, timeZone, hour12)
: formatChartDay(period, days);
const formatTooltipPeriod = (period: string) =>
resolution === "hour" && referenceTime !== undefined
? formatRelativeHourShort(period, referenceTime, timeZone, hour12)
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/usage/usagePagePreferences.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { readUsagePagePreferences, saveUsagePagePreferences } from "./usagePagePreferences";
import { ALL_TIME_WINDOW_DAYS } from "./usageWindow";

const key = "t3code:usage-page-preferences:v1";
let values: Map<string, string>;
Expand Down Expand Up @@ -28,8 +29,16 @@ describe("Usage page preferences", () => {
saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] });
expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]);

values.set(key, '{"metric":"tokens","windowDays":30}');
expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 30 });
for (const windowDays of [1, 7, 30, 90]) {
values.set(key, JSON.stringify({ metric: "tokens", windowDays }));
expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays });
}

saveUsagePagePreferences({ metric: "cost", windowDays: ALL_TIME_WINDOW_DAYS });
expect(readUsagePagePreferences()).toEqual({
metric: "cost",
windowDays: ALL_TIME_WINDOW_DAYS,
});
});

// regression: #10189 an invalid saved usage selection was never replaced
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/usage/usagePagePreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { UsageProviderKind } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage";
import { ALL_TIME_WINDOW_DAYS } from "./usageWindow";

const STORAGE_KEY = "t3code:usage-page-preferences:v1";
const UsagePagePreferencesSchema = Schema.Struct({
metric: Schema.Literals(["cost", "tokens", "limits"]),
windowDays: Schema.Literals([1, 7, 30, 90]),
windowDays: Schema.Literals([1, 7, 30, 90, ALL_TIME_WINDOW_DAYS]),
hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)),
});
export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;
Expand Down
Loading
Loading