Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ jobs:
test:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 8
# The Windows full suite now completes near the old 8-minute ceiling; leave
# enough room for the privacy/build smokes instead of cancelling a green run.
timeout-minutes: 12
strategy:
fail-fast: false
matrix:
Expand Down
22 changes: 9 additions & 13 deletions gui/src/pages/dashboard-core-poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export type DashboardCorePoll = {
startupHealthSeed: SettingsData["startupHealth"] | null | undefined;
sidecar: SidecarData | null;
shadowCall: ShadowCallData | null | undefined;
usage30d: UsageSummary30d | null;
maMode: "v1" | "default" | "v2";
maModeResolved: boolean;
/** Absent when the optional endpoint failed — callers must keep prior UI state. */
Expand Down Expand Up @@ -89,6 +88,14 @@ export async function fetchDashboardModels(apiBase: string, signal: AbortSignal)
return requireJson<ModelInfo[]>(response);
}

export async function fetchDashboardUsage(apiBase: string, signal: AbortSignal): Promise<UsageSummary30d> {
const response = await fetch(`${apiBase}/api/usage?range=30d`, { signal });
// Usage can be expensive on an older server. Keeping it in its own resource means
// it cannot delay health/provider/settings commits, and a failed refresh retains
// the last good usage snapshot.
return requireJson<UsageSummary30d>(response);
}

export async function fetchDashboardCore(
apiBase: string,
signal: AbortSignal,
Expand All @@ -112,7 +119,6 @@ export async function fetchDashboardCore(
startupHealthSeed: undefined,
sidecar: null,
shadowCall: undefined,
usage30d: null,
maMode: "default",
maModeResolved: true,
injection: undefined,
Expand All @@ -121,13 +127,12 @@ export async function fetchDashboardCore(
};

try {
const [hRes, pRes, sRes, scRes, shRes, uRes] = await Promise.all([
const [hRes, pRes, sRes, scRes, shRes] = await Promise.all([
fetch(`${apiBase}/healthz`, { signal }),
fetch(`${apiBase}/api/providers`, { signal }),
fetch(`${apiBase}/api/settings`, { signal }),
fetch(`${apiBase}/api/sidecar-settings`, { signal }),
fetch(`${apiBase}/api/shadow-call-settings`, { signal }),
fetch(`${apiBase}/api/usage?range=30d`, { signal }),
]);

const health = await requireJson<HealthData>(hRes);
Expand Down Expand Up @@ -176,14 +181,6 @@ export async function fetchDashboardCore(
}
}

// Usage is best-effort: a malformed/empty body must not mark the whole dashboard offline.
let usage30d: UsageSummary30d | null = null;
try {
usage30d = (await readJsonIfOk<UsageSummary30d>(uRes)) ?? null;
} catch {
usage30d = null;
}

let maMode: "v1" | "default" | "v2" = "default";
let maModeResolved = false;
try {
Expand Down Expand Up @@ -236,7 +233,6 @@ export async function fetchDashboardCore(
startupHealthSeed,
sidecar,
shadowCall,
usage30d,
maMode,
maModeResolved,
injection,
Expand Down
13 changes: 12 additions & 1 deletion gui/src/pages/use-dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../startup-health-ui";
import {
fetchDashboardCore,
fetchDashboardUsage,
fetchDashboardModels,
fetchProjectConfigDiagnostics,
fetchStartupHealth,
Expand Down Expand Up @@ -147,6 +148,13 @@ export function useDashboardData(apiBase: string) {
{ pollMs: 5000 },
);

const usagePoll = useKeyedClientResource(
`dashboard-usage:${apiBase}`,
[apiBase],
(signal) => fetchDashboardUsage(apiBase, signal),
{ pollMs: 60_000 },
);

const diagnosticsPoll = useKeyedClientResource(
`dashboard-diagnostics:${apiBase}`,
[apiBase],
Expand Down Expand Up @@ -188,7 +196,6 @@ export function useDashboardData(apiBase: string) {
}
if (data.sidecar) setSidecar(data.sidecar);
if (data.shadowCall !== undefined) setShadowCall(data.shadowCall);
setUsage30d(data.usage30d);
setMaMode(data.maMode);
setMaModeResolved(data.maModeResolved);
if (data.injection) {
Expand All @@ -205,6 +212,10 @@ export function useDashboardData(apiBase: string) {
setError(data.error);
}, [corePoll.data]);

useEffect(() => {
if (usagePoll.data !== undefined) setUsage30d(usagePoll.data);
}, [usagePoll.data]);

useEffect(() => {
if (diagnosticsPoll.data) setProjectConfigWarnings(diagnosticsPoll.data);
}, [diagnosticsPoll.data]);
Expand Down
13 changes: 13 additions & 0 deletions gui/tests/dashboard-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ test("Dashboard wires a single project-config diagnostics owner outside the sett
expect(coreBody).not.toContain("diagnostics/project-config");
});

test("Dashboard usage polling cannot delay core health and settings", async () => {
const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text();
const hook = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text();
const coreFnStart = core.indexOf("export async function fetchDashboardCore");
const usageFnStart = core.indexOf("export async function fetchDashboardUsage");
expect(coreFnStart).toBeGreaterThan(-1);
expect(usageFnStart).toBeGreaterThan(-1);
expect(core.slice(coreFnStart)).not.toContain("/api/usage?range=30d");
expect(hook).toContain("dashboard-usage:${apiBase}");
expect(hook).toContain("fetchDashboardUsage(apiBase, signal)");
expect(hook).toMatch(/dashboard-usage:\$\{apiBase\}[\s\S]*pollMs: 60_000/);
});

test("Dashboard workspace pane is a labelled section, not a nested main landmark", async () => {
const src = await Bun.file(new URL("../src/pages/Dashboard.tsx", import.meta.url)).text();
expect(src).toContain("dashboard-workspace-main");
Expand Down
66 changes: 63 additions & 3 deletions src/server/management/logs-usage-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api";
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
import { resolveCodexHomeDir } from "../../codex/home";
import { scanStorage } from "../../storage/scanner";
import { readUsageEntries } from "../../usage/log";
import {
currentUsageLogRevision,
readUsageSnapshotForManagement,
usageLogRevisionKey,
type PersistedUsageEntry,
} from "../../usage/log";
import { getUsageDebugLogEntries } from "../../usage/debug";
import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
import { parseRange, parseUsageSurface, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
import { getProviderRegistryEntry } from "../../providers/registry";
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
Expand All @@ -60,6 +65,48 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
import type { ManagementContext } from "./context";

const USAGE_DAY_MS = 86_400_000;
const usageSummaryCache = new Map<string, {
revisionKey: string;
expiresAt: number;
summary: UsageSummary;
}>();

function usageEntryMatchesSurface(entry: PersistedUsageEntry, surface: UsageSurface): boolean {
if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop";
if (surface === "grok") return entry.surface === "grok";
if (surface === "codex") return entry.surface === undefined;
return true;
}

function nextLocalMidnight(now: number): number {
const next = new Date(now);
next.setHours(24, 0, 0, 0);
return next.getTime();
}

function usageSummaryExpiresAt(
entries: PersistedUsageEntry[],
range: UsageRange,
surface: UsageSurface,
now: number,
): number {
let expiresAt = nextLocalMidnight(now);
const windowMs = range === "7d" ? 7 * USAGE_DAY_MS : range === "30d" ? 30 * USAGE_DAY_MS : null;
if (windowMs === null) return expiresAt;
for (const entry of entries) {
if (!usageEntryMatchesSurface(entry, surface)) continue;
const expiry = entry.timestamp + windowMs;
if (expiry > now && expiry < expiresAt) expiresAt = expiry;
}
return expiresAt;
}

function refreshedUsageSummary(summary: UsageSummary, range: UsageRange, now: number): UsageSummary {
const since = range === "7d" ? now - 7 * USAGE_DAY_MS : range === "30d" ? now - 30 * USAGE_DAY_MS : null;
return { ...summary, since, generatedAt: now };
}

export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Response | null> {
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;

Expand Down Expand Up @@ -123,7 +170,20 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
const surface = parseUsageSurface(url.searchParams.get("surface"));
const now = Date.now();
try {
return jsonResponse(summarizeUsage(readUsageEntries(), range, now, surface));
const cacheKey = `${range}:${surface}`;
const observedRevisionKey = usageLogRevisionKey(currentUsageLogRevision());
const cached = usageSummaryCache.get(cacheKey);
if (cached && cached.revisionKey === observedRevisionKey && now < cached.expiresAt) {
return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
}
const snapshot = await readUsageSnapshotForManagement();
const summary = summarizeUsage(snapshot.entries, range, now, surface);
usageSummaryCache.set(cacheKey, {
revisionKey: usageLogRevisionKey(snapshot.revision),
expiresAt: usageSummaryExpiresAt(snapshot.entries, range, surface, now),
summary,
});
return jsonResponse(summary);
} catch {
return jsonResponse({
range,
Expand Down
139 changes: 139 additions & 0 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,145 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void {
try { chmodSync(path, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
}

export type UsageLogRevision = {
path: string;
dev: number;
ino: number;
birthtimeMs: number;
size: number;
mtimeMs: number;
ctimeMs: number;
};

let usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
let managementUsageReadInflight: {
key: string;
promise: Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }>;
} | null = null;

/** Test-only observability for proving that unchanged prefixes are not reparsed. */
export function usageReadCacheStatsForTests(): Readonly<typeof usageReadCacheStats> {
return { ...usageReadCacheStats };
}

export function resetUsageReadCacheForTests(): void {
usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
managementUsageReadInflight = null;
}

function readExactly(fd: number, length: number, position: number): Buffer | null {
const output = Buffer.allocUnsafe(length);
let offset = 0;
while (offset < length) {
const read = readSync(fd, output, offset, length - offset, position + offset);
if (read === 0) return null;
offset += read;
}
return output;
}

function usageLogRevision(path: string, stat: ReturnType<typeof fstatSync>): UsageLogRevision {
if (!stat.isFile()) throw new Error("usage log is not a regular file");
return {
path,
dev: Number(stat.dev),
ino: Number(stat.ino),
birthtimeMs: Number(stat.birthtimeMs),
size: Number(stat.size),
mtimeMs: Number(stat.mtimeMs),
ctimeMs: Number(stat.ctimeMs),
};
}

export function usageLogRevisionKey(revision: UsageLogRevision | null): string {
if (!revision) return "missing";
return [
revision.path,
revision.dev,
revision.ino,
revision.birthtimeMs,
revision.size,
revision.mtimeMs,
revision.ctimeMs,
].join("\0");
}

export function currentUsageLogRevision(): UsageLogRevision | null {
const path = usageLogPath();
if (!existsSync(path)) return null;
let fd: number | undefined;
try {
fd = openSync(path, "r");
return usageLogRevision(path, fstatSync(fd));
} finally {
if (fd !== undefined) closeSync(fd);
}
}

async function parseUsageTextCooperatively(text: string): Promise<PersistedUsageEntry[]> {
const lines = text.split(/\r?\n/);
usageReadCacheStats.parsedLines += lines.filter(line => line.trim()).length;
const entries: PersistedUsageEntry[] = [];
const batchSize = 1_000;
for (let offset = 0; offset < lines.length; offset += batchSize) {
entries.push(...parseUsageLines(lines.slice(offset, offset + batchSize)));
if (offset + batchSize < lines.length) {
// JSON parsing dominates large-log startup. Yield between bounded batches so
// Bun can continue serving health and settings requests on the same thread.
await new Promise<void>(resolve => setTimeout(resolve, 0));
}
}
return entries;
}

async function readUsageEntriesFullCooperatively(
path: string,
): Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }> {
let fd: number | undefined;
try {
fd = openSync(path, "r");
const stat = fstatSync(fd);
const size = Number(stat.size);
const bytes = readExactly(fd, size, 0);
if (bytes === null) throw new Error("usage log changed while it was being read");
const entries = await parseUsageTextCooperatively(bytes.toString("utf-8"));
usageReadCacheStats.fullReads += 1;
return { entries, revision: usageLogRevision(path, stat) };
} finally {
if (fd !== undefined) closeSync(fd);
}
}

/**
* Management API reader: full parses yield between bounded batches and concurrent
* callers share work only when they observed the same exact file revision. Parsed rows
* are returned to the request and never retained in module state.
*/
export async function readUsageSnapshotForManagement(): Promise<{
entries: PersistedUsageEntry[];
revision: UsageLogRevision | null;
}> {
const path = usageLogPath();
if (!existsSync(path)) return { entries: [], revision: null };
const observed = currentUsageLogRevision();
const key = usageLogRevisionKey(observed);
if (managementUsageReadInflight?.key === key) {
const shared = await managementUsageReadInflight.promise;
return { entries: shared.entries.slice(), revision: shared.revision };
}
const promise = readUsageEntriesFullCooperatively(path);
managementUsageReadInflight = { key, promise };
try {
return await promise;
} finally {
if (managementUsageReadInflight?.promise === promise) managementUsageReadInflight = null;
}
}

export async function readUsageEntriesForManagement(): Promise<PersistedUsageEntry[]> {
return (await readUsageSnapshotForManagement()).entries;
}

export function readUsageEntries(): PersistedUsageEntry[] {
const path = usageLogPath();
if (!existsSync(path)) return [];
Expand Down
15 changes: 15 additions & 0 deletions structure/05_gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ Missing usage is never treated as zero. The dashboard Usage tab renders the same
main Dashboard surfaces a 30d token / coverage summary. The in-memory `requestLog` is capped at
200 entries and is **not** the source of truth for aggregation — the JSONL on disk is.

The management API caches only the compact summary for an exact file revision and query; it never
retains normalized per-request rows after a response. The cache invalidates on any identity, size, or
timestamp change and at the next range expiry or local-day boundary. Rebuilds parse in bounded
batches and yield between them, so unrelated management requests remain serviceable even for a large
existing log. The Dashboard polls its 30-day usage summary independently once per minute, so usage
work cannot delay health/provider/settings state or run every five seconds.

[Decision Log]
- 목적과 의도: Keep dashboard and management requests responsive as `usage.jsonl` grows.
- 기존 구현 및 제약 조건: The JSONL file remains the durable source of truth and may be truncated, replaced, or hand-edited.
- 검토한 주요 대안: Retain normalized rows, maintain a second database, or cache only revision-keyed summaries and cooperatively rebuild them.
- 선택한 방식: Keep only bounded summary results, share full reads by exact file identity, yield during parsing, and poll usage separately at a slower cadence.
- 다른 대안 대신 이 방식을 선택한 이유: It bounds resident heap and avoids a second persistence format while keeping unrelated endpoints responsive.
- 장점, 단점 및 영향: Unchanged queries are cheap and memory stays bounded; a changed large log still consumes rebuild CPU, but cooperatively and at most once per observed revision/query.

Comment thread
Ingwannu marked this conversation as resolved.
For diagnosing upstream-shape / usage-extraction issues run `ocx debug usage on` (or set
`OPENCODEX_USAGE_DEBUG=1` before start). The proxy then writes a rolling debug record per finalized
request to `~/.opencodex/usage-debug.jsonl` (mode `0o600`, auto-trimmed to the most-recent 100 lines
Expand Down
Loading
Loading