diff --git a/content/docs/user-dashboard-stats.mdx b/content/docs/user-dashboard-stats.mdx index 24af5bd4ba..3d1c0d13fa 100644 --- a/content/docs/user-dashboard-stats.mdx +++ b/content/docs/user-dashboard-stats.mdx @@ -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. diff --git a/docs-web/content/docs/user-dashboard-stats.mdx b/docs-web/content/docs/user-dashboard-stats.mdx index 24af5bd4ba..3d1c0d13fa 100644 --- a/docs-web/content/docs/user-dashboard-stats.mdx +++ b/docs-web/content/docs/user-dashboard-stats.mdx @@ -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. diff --git a/docs-web/user/dashboard/stats.md b/docs-web/user/dashboard/stats.md index 24af5bd4ba..3d1c0d13fa 100644 --- a/docs-web/user/dashboard/stats.md +++ b/docs-web/user/dashboard/stats.md @@ -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. diff --git a/docs/architecture/usage-telemetry-and-stats.md b/docs/architecture/usage-telemetry-and-stats.md index c63e828a7a..acc9d34e68 100644 --- a/docs/architecture/usage-telemetry-and-stats.md +++ b/docs/architecture/usage-telemetry-and-stats.md @@ -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=&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. diff --git a/src/app/lifecycle/dashboard-lifecycle-service.ts b/src/app/lifecycle/dashboard-lifecycle-service.ts index cc61c277a0..aa2fc07803 100644 --- a/src/app/lifecycle/dashboard-lifecycle-service.ts +++ b/src/app/lifecycle/dashboard-lifecycle-service.ts @@ -442,6 +442,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise { requireProject(deps, projectId); const assignments = deps.projectWorkerAssignmentService.setProjectPreferredWorker(projectId, input); diff --git a/src/app/lifecycle/dashboard-snapshot-cache-policy.ts b/src/app/lifecycle/dashboard-snapshot-cache-policy.ts index a102dbbe09..29e72f8c1c 100644 --- a/src/app/lifecycle/dashboard-snapshot-cache-policy.ts +++ b/src/app/lifecycle/dashboard-snapshot-cache-policy.ts @@ -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 = @@ -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; @@ -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}:`); + } } diff --git a/src/app/lifecycle/dashboard-snapshot-cache.ts b/src/app/lifecycle/dashboard-snapshot-cache.ts index 73a9a66447..1f10a9fe69 100644 --- a/src/app/lifecycle/dashboard-snapshot-cache.ts +++ b/src/app/lifecycle/dashboard-snapshot-cache.ts @@ -1,5 +1,6 @@ import type { BootDashboardDeps } from "./dashboard-lifecycle-service.js"; import type { + HeaderTokenThroughputQuery, ProjectStatsQuery, ExecutionConnectionSummary, ExecutionAssignedWorkerSummary, @@ -116,6 +117,8 @@ export class DashboardSnapshotCache { private leanExecutionSnapshotKeysByProject = new Map>(); private projectStatsSnapshotCache = new Map; expiresAt: number }>(); private projectStatsSnapshotKeysByProject = new Map>(); + private headerTokenThroughputSnapshotCache = new Map; expiresAt: number }>(); + private headerTokenThroughputSnapshotKeysByProject = new Map>(); private overviewTelemetryCache: { snapshot: ReturnType; expiresAt: number } | null = null; private projectsSnapshotCache: { snapshot: ReturnType; expiresAt: number } | null = null; @@ -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) { @@ -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 { @@ -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; } diff --git a/src/contracts/app-types.ts b/src/contracts/app-types.ts index 466ac0f169..5714a578cc 100644 --- a/src/contracts/app-types.ts +++ b/src/contracts/app-types.ts @@ -537,6 +537,37 @@ export interface ProjectExecutionStatsSnapshot { chartSeries: ProjectExecutionStatsChartSeries[]; } +export type HeaderTokenThroughputWindow = Exclude; + +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; diff --git a/src/repositories/execution-repository.ts b/src/repositories/execution-repository.ts index 29bcf32bf9..0939d8ef81 100644 --- a/src/repositories/execution-repository.ts +++ b/src/repositories/execution-repository.ts @@ -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"; @@ -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, @@ -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 { requireProject(this.db, projectId); const rows = this.db.prepare(` diff --git a/src/repositories/execution/header-token-throughput-query.ts b/src/repositories/execution/header-token-throughput-query.ts new file mode 100644 index 0000000000..c5c3144050 --- /dev/null +++ b/src/repositories/execution/header-token-throughput-query.ts @@ -0,0 +1,307 @@ +import type { + HeaderTokenThroughputProjectSnapshot, + HeaderTokenThroughputQuery, + HeaderTokenThroughputSnapshot, + HeaderTokenThroughputTotals, + HeaderTokenThroughputWindow, + ProjectStatsRangeSummary, + ProjectStatsResolution, +} from "../../contracts/app-types.js"; +import type { DatabaseAdapter } from "../db/database-adapter.js"; +import { toNumber, ValidationError } from "../repository-utils.js"; +import { startOfHour, startOfUtcDay } from "./project-stats-query.js"; + +interface ThroughputAggregateRow { + invocationCount: number | string | null; + activeTimeMs: number | string | null; + inputTokens: number | string | null; + cachedInputTokens: number | string | null; + outputTokens: number | string | null; + reasoningTokens: number | string | null; + totalTokens: number | string | null; +} + +interface ProjectIdentityRow { + id: string; + name: string; +} + +export function queryHeaderTokenThroughputSnapshot( + db: DatabaseAdapter, + input: HeaderTokenThroughputQuery = { window: "24h" }, +): HeaderTokenThroughputSnapshot { + const window = normalizeHeaderTokenThroughputWindow(input.window); + const projectId = normalizeProjectId(input.projectId); + const projectRow = projectId ? getRequiredProject(db, projectId) : null; + const now = new Date(); + const range = normalizeHeaderTokenThroughputRange(db, window, now); + + const app = queryThroughputTotals(db, range.from, range.to); + const project = projectRow + ? { + projectId: projectRow.id, + projectName: projectRow.name, + ...queryThroughputTotals(db, range.from, range.to, projectRow.id), + } satisfies HeaderTokenThroughputProjectSnapshot + : null; + + return { + generatedAt: now.toISOString(), + window, + range, + app, + project, + }; +} + +export function normalizeHeaderTokenThroughputWindow(window: unknown): HeaderTokenThroughputWindow { + if ( + window === "1h" + || window === "24h" + || window === "7d" + || window === "30d" + || window === "all" + ) { + return window; + } + throw new ValidationError("Invalid header throughput window. Expected one of: 1h, 24h, 7d, 30d, all."); +} + +function normalizeProjectId(projectId: string | null | undefined): string | null { + if (projectId === null || projectId === undefined) { + return null; + } + const trimmed = projectId.trim(); + if (!trimmed) { + throw new ValidationError("Missing required projectId when projectId is provided."); + } + return trimmed; +} + +function getRequiredProject(db: DatabaseAdapter, projectId: string): ProjectIdentityRow { + const row = db.prepare(` + SELECT id, name + FROM projects + WHERE id = ? + `).get(projectId) as ProjectIdentityRow | undefined; + if (!row) { + throw new ValidationError(`Invalid projectId: ${projectId}`); + } + return row; +} + +function normalizeHeaderTokenThroughputRange( + db: DatabaseAdapter, + window: HeaderTokenThroughputWindow, + now: Date, +): ProjectStatsRangeSummary { + if (window === "1h") { + return buildPresetRange({ + window, + from: new Date(startOfUtcBucket(now, 5 * 60 * 1000).getTime() - 11 * 5 * 60 * 1000), + bucketSizeMs: 5 * 60 * 1000, + bucketCount: 12, + resolution: "5min", + label: "Last 1 hour", + resolutionLabel: "5-minute telemetry buckets", + }); + } + + if (window === "24h") { + return buildPresetRange({ + window, + from: new Date(startOfHour(now).getTime() - 23 * 60 * 60 * 1000), + bucketSizeMs: 60 * 60 * 1000, + bucketCount: 24, + resolution: "hour", + label: "Last 24 hours", + resolutionLabel: "Hourly telemetry buckets", + }); + } + + if (window === "7d" || window === "30d") { + const bucketCount = window === "7d" ? 7 : 30; + return buildPresetRange({ + window, + from: new Date(startOfUtcDay(now).getTime() - (bucketCount - 1) * 24 * 60 * 60 * 1000), + bucketSizeMs: 24 * 60 * 60 * 1000, + bucketCount, + resolution: "day", + label: window === "7d" ? "Last 7 days" : "Last 30 days", + resolutionLabel: "Daily telemetry buckets", + }); + } + + const firstInvocationRow = db.prepare(` + SELECT MIN(started_at) AS first_started_at + FROM provider_invocations + `).get() as { first_started_at: string | null } | undefined; + const firstInvocation = parseDate(firstInvocationRow?.first_started_at) || now; + return buildRangeFromBounds(window, startOfUtcDay(firstInvocation), endOfUtcDay(now)); +} + +function buildRangeFromBounds( + window: HeaderTokenThroughputWindow, + fromDate: Date, + toDate: Date, +): ProjectStatsRangeSummary { + const spanMs = Math.max(1, toDate.getTime() - fromDate.getTime()); + const spanHours = Math.ceil(spanMs / (60 * 60 * 1000)); + const spanDays = Math.ceil(spanMs / (24 * 60 * 60 * 1000)); + + if (spanHours <= 48) { + const bucketSizeMs = 60 * 60 * 1000; + const start = startOfHour(fromDate); + const end = new Date(startOfHour(toDate).getTime() + bucketSizeMs); + return buildRange({ + window, + from: start, + to: end, + bucketSizeMs, + resolution: "hour", + label: "All time", + resolutionLabel: "Hourly telemetry buckets", + }); + } + + if (spanDays <= 90) { + const bucketSizeMs = 24 * 60 * 60 * 1000; + const start = startOfUtcDay(fromDate); + const end = new Date(startOfUtcDay(toDate).getTime() + bucketSizeMs); + return buildRange({ + window, + from: start, + to: end, + bucketSizeMs, + resolution: "day", + label: "All time", + resolutionLabel: "Daily telemetry buckets", + }); + } + + const bucketSizeMs = 7 * 24 * 60 * 60 * 1000; + const start = startOfUtcWeek(fromDate); + const end = new Date(startOfUtcWeek(toDate).getTime() + bucketSizeMs); + return buildRange({ + window, + from: start, + to: end, + bucketSizeMs, + resolution: "week", + label: "All time", + resolutionLabel: "Weekly telemetry buckets", + }); +} + +function buildPresetRange(input: { + window: HeaderTokenThroughputWindow; + from: Date; + bucketSizeMs: number; + bucketCount: number; + resolution: ProjectStatsResolution; + label: string; + resolutionLabel: string; +}): ProjectStatsRangeSummary { + const rangeStart = new Date(input.from); + return { + window: input.window, + label: input.label, + resolution: input.resolution, + resolutionLabel: input.resolutionLabel, + from: rangeStart.toISOString(), + to: new Date(rangeStart.getTime() + input.bucketSizeMs * input.bucketCount).toISOString(), + bucketCount: input.bucketCount, + isCustom: false, + }; +} + +function buildRange(input: { + window: HeaderTokenThroughputWindow; + from: Date; + to: Date; + bucketSizeMs: number; + resolution: ProjectStatsResolution; + label: string; + resolutionLabel: string; +}): ProjectStatsRangeSummary { + return { + window: input.window, + label: input.label, + resolution: input.resolution, + resolutionLabel: input.resolutionLabel, + from: input.from.toISOString(), + to: input.to.toISOString(), + bucketCount: Math.max(1, Math.ceil((input.to.getTime() - input.from.getTime()) / input.bucketSizeMs)), + isCustom: false, + }; +} + +function queryThroughputTotals( + db: DatabaseAdapter, + rangeStartIso: string, + rangeEndIso: string, + projectId?: string, +): HeaderTokenThroughputTotals { + const projectPredicate = projectId ? "AND project_id = ?" : ""; + const params = projectId ? [rangeStartIso, rangeEndIso, projectId] : [rangeStartIso, rangeEndIso]; + const row = db.prepare(` + SELECT + COUNT(*) as invocationCount, + COALESCE(SUM(COALESCE(duration_ms, 0)), 0) as activeTimeMs, + COALESCE(SUM(input_tokens), 0) as inputTokens, + COALESCE(SUM(cached_input_tokens), 0) as cachedInputTokens, + COALESCE(SUM(output_tokens), 0) as outputTokens, + COALESCE(SUM(reasoning_output_tokens), 0) as reasoningTokens, + COALESCE(SUM(total_tokens), 0) as totalTokens + FROM provider_invocations + WHERE started_at >= ? AND started_at < ? + ${projectPredicate} + `).get(...params) as ThroughputAggregateRow | undefined; + + const activeTimeMs = toNumber(row?.activeTimeMs); + const totalTokens = toNumber(row?.totalTokens); + return { + totalTokens, + inputTokens: toNumber(row?.inputTokens), + cachedInputTokens: toNumber(row?.cachedInputTokens), + outputTokens: toNumber(row?.outputTokens), + reasoningTokens: toNumber(row?.reasoningTokens), + invocationCount: toNumber(row?.invocationCount), + activeTimeMs, + tokensPerMinute: calculateTokensPerMinute(totalTokens, activeTimeMs), + }; +} + +function calculateTokensPerMinute(totalTokens: number, activeTimeMs: number): number { + if (totalTokens <= 0 || activeTimeMs <= 0) { + return 0; + } + return Math.round((totalTokens / (activeTimeMs / 60_000)) * 100) / 100; +} + +function parseDate(value: string | null | undefined): Date | null { + if (!value) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function startOfUtcBucket(date: Date, bucketSizeMs: number): Date { + return new Date(Math.floor(date.getTime() / bucketSizeMs) * bucketSizeMs); +} + +function endOfUtcDay(date: Date): Date { + const next = startOfUtcDay(date); + next.setUTCDate(next.getUTCDate() + 1); + next.setUTCMilliseconds(next.getUTCMilliseconds() - 1); + return next; +} + +function startOfUtcWeek(date: Date): Date { + const next = startOfUtcDay(date); + const day = next.getUTCDay(); + const offset = day === 0 ? 6 : day - 1; + next.setUTCDate(next.getUTCDate() - offset); + return next; +} diff --git a/src/server/dashboard-server.ts b/src/server/dashboard-server.ts index 9d2c4e6ef4..e3156a2df3 100644 --- a/src/server/dashboard-server.ts +++ b/src/server/dashboard-server.ts @@ -24,6 +24,8 @@ import type { FileBrowserFileContent, FileBrowserChangeSet, FileBrowserDiff, + HeaderTokenThroughputQuery, + HeaderTokenThroughputSnapshot, } from "../contracts/app-types.js"; import type { OnboardingStateRecord } from "../domain/user/onboarding-state.js"; import type { @@ -155,6 +157,7 @@ export interface DashboardServerOptions { getExecutionSnapshot: () => ExecutionDashboardSnapshot; getProjectExecutionSnapshot: (projectId: string) => ExecutionDashboardSnapshot; getProjectStatsSnapshot: (projectId: string, query?: ProjectStatsQuery) => ProjectExecutionStatsSnapshot; + getHeaderTokenThroughputSnapshot: (query?: HeaderTokenThroughputQuery) => HeaderTokenThroughputSnapshot; setPreferredWorker?: ( projectId: string, input?: { diff --git a/src/server/request-parsers.ts b/src/server/request-parsers.ts index 1c7dfd73b7..06b61c2741 100644 --- a/src/server/request-parsers.ts +++ b/src/server/request-parsers.ts @@ -1,4 +1,6 @@ import type { + HeaderTokenThroughputQuery, + HeaderTokenThroughputWindow, ProjectStatsQuery, ProjectStatsWindow, } from "../contracts/app-types.js"; @@ -704,3 +706,36 @@ export function parseProjectStatsQuery(query: Record): ProjectS return { window, from, to, limit }; } + +export function parseHeaderTokenThroughputQuery(query: Record): HeaderTokenThroughputQuery { + const requestedWindow = typeof query.window === "string" ? query.window.trim() : ""; + const window: HeaderTokenThroughputWindow = requestedWindow.length === 0 + ? "24h" + : parseHeaderTokenThroughputWindow(requestedWindow); + + if (!Object.prototype.hasOwnProperty.call(query, "projectId")) { + return { window, projectId: null }; + } + + if (typeof query.projectId !== "string") { + throw new Error("Invalid projectId query parameter."); + } + const projectId = query.projectId.trim(); + if (!projectId) { + throw new Error("Missing required projectId when projectId is provided."); + } + return { window, projectId }; +} + +function parseHeaderTokenThroughputWindow(window: string): HeaderTokenThroughputWindow { + if ( + window === "1h" + || window === "24h" + || window === "7d" + || window === "30d" + || window === "all" + ) { + return window; + } + throw new Error("Invalid header throughput window. Expected one of: 1h, 24h, 7d, 30d, all."); +} diff --git a/src/server/runtime-routes.ts b/src/server/runtime-routes.ts index ca585075f8..3db176ba37 100644 --- a/src/server/runtime-routes.ts +++ b/src/server/runtime-routes.ts @@ -1,7 +1,7 @@ import type { Express } from "express"; import type { DashboardDependencies } from "./dashboard-server.js"; import { asyncRoute, syncRoute } from "./route-utils.js"; -import { parseProjectStatsQuery, parseTrimmedString, requireTrimmedString, parsePreferredWorkerAssignment, parseClaimAttentionItemPayload, parseResolveAttentionItemPayload } from "./request-parsers.js"; +import { parseHeaderTokenThroughputQuery, parseProjectStatsQuery, parseTrimmedString, requireTrimmedString, parsePreferredWorkerAssignment, parseClaimAttentionItemPayload, parseResolveAttentionItemPayload } from "./request-parsers.js"; import type { ProjectStatsQuery, ProjectStatsWindow } from "../contracts/app-types.js"; export function registerRuntimeRoutes(app: Express, options: DashboardDependencies): void { @@ -23,6 +23,10 @@ export function registerRuntimeRoutes(app: Express, options: DashboardDependenci res.json(options.getOverviewTelemetrySnapshot()); })); + app.get("/api/stats/header-throughput", syncRoute((req, res) => { + res.json(options.getHeaderTokenThroughputSnapshot(parseHeaderTokenThroughputQuery(req.query as Record))); + })); + app.get("/api/projects/:projectId/execution", syncRoute((req, res) => { res.json(options.getProjectExecutionSnapshot(requireTrimmedString(req.params.projectId, "projectId"))); })); diff --git a/tests/backend/repositories/execution/header-token-throughput-query.test.ts b/tests/backend/repositories/execution/header-token-throughput-query.test.ts new file mode 100644 index 0000000000..3bd90b17a8 --- /dev/null +++ b/tests/backend/repositories/execution/header-token-throughput-query.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryHeaderTokenThroughputSnapshot } from "../../../../src/repositories/execution/header-token-throughput-query.js"; +import { ValidationError } from "../../../../src/repositories/repository-utils.js"; +import { SqliteDatabaseAdapter } from "../../../../src/repositories/db/sqlite-database-adapter.js"; + +describe("header-token-throughput-query", () => { + let db: SqliteDatabaseAdapter; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-02T12:17:33.000Z")); + db = new SqliteDatabaseAdapter(":memory:"); + db.exec(` + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE provider_invocations ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + started_at TEXT NOT NULL, + duration_ms INTEGER, + input_tokens INTEGER NOT NULL DEFAULT 0, + cached_input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_output_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0 + ); + `); + db.prepare("INSERT INTO projects (id, name) VALUES (?, ?)").run("project-a", "Project A"); + db.prepare("INSERT INTO projects (id, name) VALUES (?, ?)").run("project-b", "Project B"); + }); + + afterEach(() => { + db.close(); + vi.useRealTimers(); + }); + + it("aggregates app totals and selected project totals for the requested window", () => { + insertInvocation({ + id: "a-1", + projectId: "project-a", + startedAt: "2026-01-02T11:25:00.000Z", + durationMs: 60_000, + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 50, + reasoningTokens: 5, + totalTokens: 170, + }); + insertInvocation({ + id: "a-2", + projectId: "project-a", + startedAt: "2026-01-02T12:10:00.000Z", + durationMs: 30_000, + inputTokens: 30, + cachedInputTokens: 0, + outputTokens: 20, + reasoningTokens: 10, + totalTokens: 50, + }); + insertInvocation({ + id: "b-1", + projectId: "project-b", + startedAt: "2026-01-02T12:12:00.000Z", + durationMs: 120_000, + inputTokens: 200, + cachedInputTokens: 100, + outputTokens: 100, + reasoningTokens: 0, + totalTokens: 400, + }); + insertInvocation({ + id: "old-a", + projectId: "project-a", + startedAt: "2026-01-02T10:00:00.000Z", + durationMs: 60_000, + inputTokens: 999, + cachedInputTokens: 999, + outputTokens: 999, + reasoningTokens: 999, + totalTokens: 999, + }); + + const snapshot = queryHeaderTokenThroughputSnapshot(db, { window: "1h", projectId: "project-a" }); + + expect(snapshot.generatedAt).toBe("2026-01-02T12:17:33.000Z"); + expect(snapshot.window).toBe("1h"); + expect(snapshot.range).toMatchObject({ + window: "1h", + from: "2026-01-02T11:20:00.000Z", + to: "2026-01-02T12:20:00.000Z", + bucketCount: 12, + isCustom: false, + }); + expect(snapshot.app).toEqual({ + totalTokens: 620, + inputTokens: 330, + cachedInputTokens: 120, + outputTokens: 170, + reasoningTokens: 15, + invocationCount: 3, + activeTimeMs: 210_000, + tokensPerMinute: 177.14, + }); + expect(snapshot.project).toEqual({ + projectId: "project-a", + projectName: "Project A", + totalTokens: 220, + inputTokens: 130, + cachedInputTokens: 20, + outputTokens: 70, + reasoningTokens: 15, + invocationCount: 2, + activeTimeMs: 90_000, + tokensPerMinute: 146.67, + }); + }); + + it("returns app totals with a null project when projectId is omitted", () => { + insertInvocation({ + id: "b-1", + projectId: "project-b", + startedAt: "2026-01-02T12:12:00.000Z", + durationMs: 120_000, + inputTokens: 200, + cachedInputTokens: 100, + outputTokens: 100, + reasoningTokens: 0, + totalTokens: 400, + }); + + const snapshot = queryHeaderTokenThroughputSnapshot(db, { window: "1h" }); + + expect(snapshot.app.totalTokens).toBe(400); + expect(snapshot.project).toBeNull(); + }); + + it("returns numeric zeroes for empty aggregates", () => { + const snapshot = queryHeaderTokenThroughputSnapshot(db, { window: "24h", projectId: "project-a" }); + + expect(snapshot.app).toEqual({ + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + invocationCount: 0, + activeTimeMs: 0, + tokensPerMinute: 0, + }); + expect(snapshot.project).toMatchObject({ + projectId: "project-a", + projectName: "Project A", + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + invocationCount: 0, + activeTimeMs: 0, + tokensPerMinute: 0, + }); + }); + + it("throws validation errors for unknown or invalid project ids", () => { + expect(() => queryHeaderTokenThroughputSnapshot(db, { window: "24h", projectId: "missing" })) + .toThrow(ValidationError); + expect(() => queryHeaderTokenThroughputSnapshot(db, { window: "24h", projectId: " " })) + .toThrow(ValidationError); + }); + + it("supports all required preset windows", () => { + for (const window of ["1h", "24h", "7d", "30d", "all"] as const) { + expect(queryHeaderTokenThroughputSnapshot(db, { window }).window).toBe(window); + } + }); + + function insertInvocation(input: { + id: string; + projectId: string; + startedAt: string; + durationMs: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + reasoningTokens: number; + totalTokens: number; + }): void { + db.prepare(` + INSERT INTO provider_invocations ( + id, + project_id, + started_at, + duration_ms, + input_tokens, + cached_input_tokens, + output_tokens, + reasoning_output_tokens, + total_tokens + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.id, + input.projectId, + input.startedAt, + input.durationMs, + input.inputTokens, + input.cachedInputTokens, + input.outputTokens, + input.reasoningTokens, + input.totalTokens, + ); + } +}); diff --git a/tests/backend/server/dashboard-chat-api.test.ts b/tests/backend/server/dashboard-chat-api.test.ts index 5903ea918d..0ed243fba8 100644 --- a/tests/backend/server/dashboard-chat-api.test.ts +++ b/tests/backend/server/dashboard-chat-api.test.ts @@ -90,6 +90,7 @@ describe("Dashboard Chat API", () => { getExecutionSnapshot: () => ({} as any), getProjectExecutionSnapshot: () => ({} as any), getProjectStatsSnapshot: () => ({} as any), + getHeaderTokenThroughputSnapshot: () => ({} as any), getOverviewTelemetrySnapshot: () => ({} as any), getLiveActivities: async () => ({}), getGitStatus: async () => ({} as any), @@ -248,6 +249,7 @@ describe("Dashboard Chat API", () => { getExecutionSnapshot: () => ({} as any), getProjectExecutionSnapshot: () => ({} as any), getProjectStatsSnapshot: () => ({} as any), + getHeaderTokenThroughputSnapshot: () => ({} as any), getOverviewTelemetrySnapshot: () => ({} as any), getLiveActivities: async () => ({}), getGitStatus: async () => ({} as any), @@ -349,6 +351,7 @@ describe("Dashboard Chat API", () => { getExecutionSnapshot: () => ({} as any), getProjectExecutionSnapshot: () => ({} as any), getProjectStatsSnapshot: () => ({} as any), + getHeaderTokenThroughputSnapshot: () => ({} as any), getOverviewTelemetrySnapshot: () => ({} as any), getLiveActivities: async () => ({}), getGitStatus: async () => ({} as any), diff --git a/tests/backend/server/dashboard-execution-invocation-api.test.ts b/tests/backend/server/dashboard-execution-invocation-api.test.ts index 53846585e4..d061f875e5 100644 --- a/tests/backend/server/dashboard-execution-invocation-api.test.ts +++ b/tests/backend/server/dashboard-execution-invocation-api.test.ts @@ -27,6 +27,7 @@ describe("Dashboard Execution Invocation API", () => { getExecutionSnapshot: vi.fn(), getProjectExecutionSnapshot: vi.fn(), getProjectStatsSnapshot: vi.fn(), + getHeaderTokenThroughputSnapshot: vi.fn(), getOverviewTelemetrySnapshot: vi.fn(), getLiveActivities: vi.fn(), getGitStatus: vi.fn(), diff --git a/tests/backend/server/dashboard-project-api.test.ts b/tests/backend/server/dashboard-project-api.test.ts index 9ac92f97da..c3532e59f7 100644 --- a/tests/backend/server/dashboard-project-api.test.ts +++ b/tests/backend/server/dashboard-project-api.test.ts @@ -213,6 +213,7 @@ async function createServerHandle(): Promise<{ attentionItems: mapAttentionItems(projectAttentionRepository, projectId), }), getProjectStatsSnapshot: (projectId, window) => executionRepository.getProjectStatsSnapshot(projectId, window), + getHeaderTokenThroughputSnapshot: (query) => executionRepository.getHeaderTokenThroughputSnapshot(query), setPreferredWorker: (projectId, input) => mapAssignedWorkers( projectWorkerAssignmentRepository, projectId, diff --git a/tests/backend/server/dashboard-routes-error.test.ts b/tests/backend/server/dashboard-routes-error.test.ts index 3616824552..5ef1dc83b3 100644 --- a/tests/backend/server/dashboard-routes-error.test.ts +++ b/tests/backend/server/dashboard-routes-error.test.ts @@ -228,6 +228,7 @@ describe("dashboard route handlers", () => { getExecutionSnapshot: () => ({ projectId: null }), getLiveSnapshot: async () => ({ projectId: null }), getOverviewTelemetrySnapshot: () => ({ updatedAt: null }), + getHeaderTokenThroughputSnapshot: (query: { window: string; projectId?: string | null }) => query, getProjectExecutionSnapshot: () => ({ projectId: "project-1" }), getProjectStatsSnapshot: (_projectId: string, query: { window: string; from?: string; to?: string }) => query, setPreferredWorker: (_projectId: string, payload: unknown) => payload, @@ -246,6 +247,11 @@ describe("dashboard route handlers", () => { expect((await request(app).get("/api/execution")).status).toBe(200); expect((await request(app).get("/api/live")).status).toBe(200); expect((await request(app).get("/api/telemetry/overview")).status).toBe(200); + const headerThroughput = await request(app).get("/api/stats/header-throughput?projectId=project-1&window=1h"); + expect(headerThroughput.status).toBe(200); + expect(headerThroughput.body).toEqual({ window: "1h", projectId: "project-1" }); + expect((await request(app).get("/api/stats/header-throughput?window=bogus")).status).toBe(400); + expect((await request(app).get("/api/stats/header-throughput?projectId=%20%20")).status).toBe(400); expect((await request(app).get("/api/projects/project-1/execution")).status).toBe(200); expect((await request(app).get("/api/projects/project-1/stats?window=24h")).status).toBe(200); expect((await request(app).get("/api/projects/project-1/stats?window=custom")).status).toBe(400); diff --git a/tests/backend/server/dashboard-server.test.ts b/tests/backend/server/dashboard-server.test.ts index f24406b7c1..969889b30e 100644 --- a/tests/backend/server/dashboard-server.test.ts +++ b/tests/backend/server/dashboard-server.test.ts @@ -250,6 +250,31 @@ function buildDashboardTestOptions( purposes: [], tokenSources: [], }), + getHeaderTokenThroughputSnapshot: () => ({ + generatedAt: "2026-07-03T00:00:00.000Z", + window: "24h", + range: { + window: "24h", + label: "Last 24 hours", + resolution: "hour", + resolutionLabel: "Hourly telemetry buckets", + from: "2026-07-02T00:00:00.000Z", + to: "2026-07-03T00:00:00.000Z", + bucketCount: 24, + isCustom: false, + }, + app: { + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + invocationCount: 0, + activeTimeMs: 0, + tokensPerMinute: 0, + }, + project: null, + }), getOverviewTelemetrySnapshot: () => ({ activeProjects: [], attentionProjects: [], recentEvents: [], updatedAt: null }), getLiveActivities: async () => ({}), getGitStatus: async () => ({ branch: "main" }) as any,