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
1 change: 1 addition & 0 deletions content/docs/user-dashboard-stats.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,6 @@ Cost data is visualized directly within the Usage Graph and Composition views, f
The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available.

It is backed by:
- `GET /api/stats/header-throughput?projectId=...&window=...` — compact app and selected-project token throughput for dashboard header metrics.
- `GET /api/projects/:projectId/stats?window=...` — aggregated metrics for charts and summaries.
- `GET /api/projects/:projectId/execution/invocations` — raw MCP invocation log.
1 change: 1 addition & 0 deletions docs-web/content/docs/user-dashboard-stats.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,6 @@ Cost data is visualized directly within the Usage Graph and Composition views, f
The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available.

It is backed by:
- `GET /api/stats/header-throughput?projectId=...&window=...` — compact app and selected-project token throughput for dashboard header metrics.
- `GET /api/projects/:projectId/stats?window=...` — aggregated metrics for charts and summaries.
- `GET /api/projects/:projectId/execution/invocations` — raw MCP invocation log.
1 change: 1 addition & 0 deletions docs-web/user/dashboard/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,6 @@ Cost data is visualized directly within the Usage Graph and Composition views, f
The page remains live and uses project realtime invalidation channels to stay current during active sprint execution, falling back to background polling when websocket updates aren't available.

It is backed by:
- `GET /api/stats/header-throughput?projectId=...&window=...` — compact app and selected-project token throughput for dashboard header metrics.
- `GET /api/projects/:projectId/stats?window=...` — aggregated metrics for charts and summaries.
- `GET /api/projects/:projectId/execution/invocations` — raw MCP invocation log.
4 changes: 4 additions & 0 deletions docs/architecture/usage-telemetry-and-stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,13 @@ Usage data now appears in two read models:

- `GET /api/projects/:projectId/execution`
- task and sprint execution summaries now include usage rollups
- `GET /api/stats/header-throughput?projectId=<id>&window=1h|24h|7d|30d|all`
- app-wide token-throughput snapshot for the dashboard header, with an optional selected-project section when `projectId` is supplied. The endpoint reads directly from `provider_invocations`, returns zero-filled numeric aggregates for empty windows, and rejects empty, unknown, or malformed `projectId` values instead of returning a misleading project subtotal. It is intentionally small: generated time, aligned range metadata, app totals, nullable project totals, token anatomy, invocation count, active provider time, and tokens per active minute.
- `GET /api/projects/:projectId/stats?window=1h|24h|7d|30d|all|custom&from=YYYY-MM-DD&to=YYYY-MM-DD`
- project-scoped statistics snapshot for the Stats page. Custom ranges must be parseable dates, where `from <= to`, and invalid or incomplete ranges fail with validation errors.

The header throughput endpoint is a compact read model for shell chrome, not a replacement for the project stats snapshot. It shares the same normalized token columns and preset window semantics, but it avoids per-project fan-out by aggregating the whole app and the selected project in one backend call. The full project stats route remains the source for bucketed charts, task/sprint/provider/model ledgers, git rollups, pricing, status counts, custom date ranges, and Stats page behavior.

Historical Docker-backed CLI invocations that were persisted as `unavailable` before container telemetry fallback support are backfilled at startup when they have prompt or transcript character counts. The backfill marks them as `estimated` using the same conservative character heuristic, preserving rows that already have provider-reported or provider-specific estimated usage.

Historical provider-reported rows created before the v2 token-accounting contract are normalized once at startup via `provider_invocations.token_accounting_version`. Legacy Codex/OpenCode rows have cached tokens subtracted from `input_tokens` while leaving total token volume intact; legacy Gemini, Claude Code, and Antigravity rows have `total_tokens` raised to include their already-separate cached input bucket. Rows are marked version `2` after the migration so restart recovery never subtracts or adds the cached bucket twice.
Expand Down
1 change: 1 addition & 0 deletions src/app/lifecycle/dashboard-lifecycle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise<DashboardS
// `/api/projects/:id/execution` (sprints/overview/chat) — feed-less.
getProjectExecutionSnapshot: cache.getProjectExecutionSnapshotLean,
getProjectStatsSnapshot: cache.getProjectStatsSnapshot,
getHeaderTokenThroughputSnapshot: cache.getHeaderTokenThroughputSnapshot,
setPreferredWorker: (projectId, input) => {
requireProject(deps, projectId);
const assignments = deps.projectWorkerAssignmentService.setProjectPreferredWorker(projectId, input);
Expand Down
16 changes: 15 additions & 1 deletion src/app/lifecycle/dashboard-snapshot-cache-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ProjectStatsQuery } from "../../contracts/app-types.js";
import type { HeaderTokenThroughputQuery, ProjectStatsQuery } from "../../contracts/app-types.js";
import type { ProjectExecutionSnapshotOptions } from "../../repositories/execution/project-execution-snapshot-query.js";

export type ProjectExecutionSelectedSprintCacheScope =
Expand All @@ -21,6 +21,7 @@ export type ProjectExecutionSnapshotCacheKey = string & {
export class DashboardSnapshotCachePolicy {
static readonly PROJECT_EXECUTION_CACHE_TTL_MS = 2_000;
static readonly PROJECT_STATS_CACHE_TTL_MS = 2_000;
static readonly HEADER_TOKEN_THROUGHPUT_CACHE_TTL_MS = 2_000;
static readonly OVERVIEW_CACHE_TTL_MS = 500;
static readonly PROJECTS_CACHE_TTL_MS = 500;

Expand Down Expand Up @@ -62,4 +63,17 @@ export class DashboardSnapshotCachePolicy {
static isProjectStatsCacheKeyMatch(key: string, projectId: string): boolean {
return key.startsWith(`${projectId}:`);
}

static getHeaderTokenThroughputCacheKey(query: HeaderTokenThroughputQuery): string {
return `${query.projectId ?? "app"}:${query.window}`;
}

static isHeaderTokenThroughputCacheKeyMatch(key: string, projectId: string): boolean {
return key === "app:1h"
|| key === "app:24h"
|| key === "app:7d"
|| key === "app:30d"
|| key === "app:all"
|| key.startsWith(`${projectId}:`);
}
}
39 changes: 34 additions & 5 deletions src/app/lifecycle/dashboard-snapshot-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { BootDashboardDeps } from "./dashboard-lifecycle-service.js";
import type {
HeaderTokenThroughputQuery,
ProjectStatsQuery,
ExecutionConnectionSummary,
ExecutionAssignedWorkerSummary,
Expand Down Expand Up @@ -116,6 +117,8 @@ export class DashboardSnapshotCache {
private leanExecutionSnapshotKeysByProject = new Map<string, Set<ProjectExecutionSnapshotCacheKey>>();
private projectStatsSnapshotCache = new Map<string, { snapshot: ReturnType<DashboardSnapshotCacheDeps["executionRepository"]["getProjectStatsSnapshot"]>; expiresAt: number }>();
private projectStatsSnapshotKeysByProject = new Map<string, Set<string>>();
private headerTokenThroughputSnapshotCache = new Map<string, { snapshot: ReturnType<DashboardSnapshotCacheDeps["executionRepository"]["getHeaderTokenThroughputSnapshot"]>; expiresAt: number }>();
private headerTokenThroughputSnapshotKeysByProject = new Map<string, Set<string>>();
private overviewTelemetryCache: { snapshot: ReturnType<DashboardSnapshotCacheDeps["executionRepository"]["getOverviewTelemetrySnapshot"]>; expiresAt: number } | null = null;
private projectsSnapshotCache: { snapshot: ReturnType<DashboardSnapshotCacheDeps["projectManagementRepository"]["listProjects"]>; expiresAt: number } | null = null;

Expand Down Expand Up @@ -263,6 +266,24 @@ export class DashboardSnapshotCache {
return snapshot;
};

getHeaderTokenThroughputSnapshot = (query: HeaderTokenThroughputQuery = { window: "24h" }) => {
const now = Date.now();
const cacheKey = DashboardSnapshotCachePolicy.getHeaderTokenThroughputCacheKey(query);
const cached = this.headerTokenThroughputSnapshotCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.snapshot;
}
const snapshot = this.deps.executionRepository.getHeaderTokenThroughputSnapshot(query);
this.headerTokenThroughputSnapshotCache.set(cacheKey, {
snapshot,
expiresAt: now + DashboardSnapshotCachePolicy.HEADER_TOKEN_THROUGHPUT_CACHE_TTL_MS,
});
if (query.projectId) {
this.registerProjectCacheKey(this.headerTokenThroughputSnapshotKeysByProject, query.projectId, cacheKey);
}
return snapshot;
};

invalidateProjectExecution(projectId: string): void {
const executionKeys = this.projectExecutionSnapshotKeysByProject.get(projectId);
if (executionKeys) {
Expand All @@ -283,13 +304,19 @@ export class DashboardSnapshotCache {

invalidateProjectStats(projectId: string): void {
const statsKeys = this.projectStatsSnapshotKeysByProject.get(projectId);
if (!statsKeys) {
return;
if (statsKeys) {
for (const key of statsKeys) {
this.projectStatsSnapshotCache.delete(key);
}
this.projectStatsSnapshotKeysByProject.delete(projectId);
}
for (const key of statsKeys) {
this.projectStatsSnapshotCache.delete(key);

for (const key of this.headerTokenThroughputSnapshotCache.keys()) {
if (DashboardSnapshotCachePolicy.isHeaderTokenThroughputCacheKeyMatch(key, projectId)) {
this.headerTokenThroughputSnapshotCache.delete(key);
}
}
this.projectStatsSnapshotKeysByProject.delete(projectId);
this.headerTokenThroughputSnapshotKeysByProject.delete(projectId);
}

invalidateOverview(): void {
Expand All @@ -307,6 +334,8 @@ export class DashboardSnapshotCache {
this.leanExecutionSnapshotKeysByProject.clear();
this.projectStatsSnapshotCache.clear();
this.projectStatsSnapshotKeysByProject.clear();
this.headerTokenThroughputSnapshotCache.clear();
this.headerTokenThroughputSnapshotKeysByProject.clear();
this.overviewTelemetryCache = null;
this.projectsSnapshotCache = null;
}
Expand Down
31 changes: 31 additions & 0 deletions src/contracts/app-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,37 @@ export interface ProjectExecutionStatsSnapshot {
chartSeries: ProjectExecutionStatsChartSeries[];
}

export type HeaderTokenThroughputWindow = Exclude<ProjectStatsWindow, "custom">;

export interface HeaderTokenThroughputQuery {
window: HeaderTokenThroughputWindow;
projectId?: string | null;
}

export interface HeaderTokenThroughputTotals {
totalTokens: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
reasoningTokens: number;
invocationCount: number;
activeTimeMs: number;
tokensPerMinute: number;
}

export interface HeaderTokenThroughputProjectSnapshot extends HeaderTokenThroughputTotals {
projectId: string;
projectName: string;
}

export interface HeaderTokenThroughputSnapshot {
generatedAt: string;
window: HeaderTokenThroughputWindow;
range: ProjectStatsRangeSummary;
app: HeaderTokenThroughputTotals;
project: HeaderTokenThroughputProjectSnapshot | null;
}

export interface OverviewTelemetryProjectSummary {
projectId: string;
projectName: string;
Expand Down
7 changes: 7 additions & 0 deletions src/repositories/execution-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ import type {
ExecutionSprintRunSummary,
ExecutionTaskDispatchSummary,
ExecutionGitMetrics,
HeaderTokenThroughputQuery,
HeaderTokenThroughputSnapshot,
} from "../contracts/app-types.js";
import type { DashboardRealtimeMutationNotifier } from "../services/dashboard-realtime-service.js";
import type { ProviderId } from "../contracts/app-types.js";
Expand All @@ -103,6 +105,7 @@ import { queryExecutionTaskDispatches } from "./execution/execution-task-dispatc
import { queryExecutionRuntimeEvents } from "./execution/execution-runtime-events-query.js";
import { normalizeProjectStatsQuery } from "./execution/project-stats-query.js";
import { queryProjectStatsSnapshot } from "./execution/project-stats-snapshot-query.js";
import { queryHeaderTokenThroughputSnapshot } from "./execution/header-token-throughput-query.js";
import { createSnapshotPricingResolver } from "./execution/project-stats-aggregation.js";
import {
queryUsageGroupsByTaskId,
Expand Down Expand Up @@ -1160,6 +1163,10 @@ export class ExecutionRepository {
return new OverviewTelemetryQuery(this.db, this.storage).getOverviewTelemetrySnapshot();
}

getHeaderTokenThroughputSnapshot(input: HeaderTokenThroughputQuery = { window: "24h" }): HeaderTokenThroughputSnapshot {
return queryHeaderTokenThroughputSnapshot(this.db, input);
}

countRunningTasksPerProvider(projectId: string): Map<ProviderId, number> {
requireProject(this.db, projectId);
const rows = this.db.prepare(`
Expand Down
Loading