From 328f6e00bf398e26b7601200f6a5d4e72c42944e Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 14:58:25 +0200 Subject: [PATCH 1/2] perf(web): add dashboard hydration observability --- apps/web/src/App.tsx | 6 + .../src/lib/client/dashboard-refresh.test.ts | 39 +++++- apps/web/src/lib/client/dashboard-refresh.ts | 55 +++++++- apps/web/src/server/routes/dashboard.test.ts | 129 ++++++++++++++++- apps/web/src/server/routes/dashboard.ts | 132 ++++++++++++++++-- 5 files changed, 339 insertions(+), 22 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 16e5c5e..ac3b31b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,6 +10,7 @@ import type { DashboardRuntimeState } from "$lib/server/dashboard"; import { attachDashboardRefreshLifecycle, collectVisibleDashboardModelIds, + createDashboardPerformanceMarks, createDashboardRefreshDelay, createDashboardTileSnapshotStore, createDashboardTileBackoff, @@ -174,6 +175,7 @@ export default function App() { let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs; let hydrationRun = 0; const requestAborter = createDashboardRequestAborter(); + const performanceMarks = createDashboardPerformanceMarks(); const refreshDelay = createDashboardRefreshDelay(); const tileBackoff = createDashboardTileBackoff(); const tileSnapshotStore = createDashboardTileSnapshotStore( @@ -222,6 +224,7 @@ export default function App() { if (cancelled || shellSignal.aborted || refreshPaused()) return; refreshDelay.recordSuccess(); + performanceMarks.markShellLoad(); const restored = restoreDashboardTileSnapshots( nextDashboard, nextDashboard.state === "ready" @@ -319,6 +322,8 @@ export default function App() { } }, items: tiles, + onAllItemsSettled: () => performanceMarks.markAllTilesSettled(), + onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(), signal, waitForIdle: () => waitForDashboardHydrationIdle({ @@ -367,6 +372,7 @@ export default function App() { ...snapshotContext, response: readyTileResponse, }); + performanceMarks.markFirstTileReady(); return "ready"; } catch (error) { if (!isAbortError(error) && !cancelled) { diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts index d94b0c4..2a2f25d 100644 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "vitest"; import { attachDashboardRefreshLifecycle, collectVisibleDashboardModelIds, + createDashboardPerformanceMarks, createDashboardRefreshDelay, + dashboardPerformanceMarks, createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, @@ -131,6 +133,8 @@ describe("dashboard refresh lifecycle", () => { calls.push(item); }, items: ["a", "b", "c"], + onAllItemsSettled: () => calls.push("all-settled"), + onVisibleItemsSettled: () => calls.push("visible-settled"), signal: new AbortController().signal, waitForIdle: async () => { calls.push("idle"); @@ -139,11 +143,18 @@ describe("dashboard refresh lifecycle", () => { }); await waitFor(() => calls.includes("idle")); - expect(calls).toEqual(["b", "idle"]); + expect(calls).toEqual(["b", "visible-settled", "idle"]); idleReleases.shift()?.(); await hydration; - expect(calls).toEqual(["b", "idle", "a", "c"]); + expect(calls).toEqual([ + "b", + "visible-settled", + "idle", + "a", + "c", + "all-settled", + ]); }); test("splits visible hydration items while preserving document order", () => { @@ -247,6 +258,30 @@ describe("dashboard refresh lifecycle", () => { expect(delay.nextDelayMs(1_000)).toBe(1_100); }); + test("marks dashboard performance milestones once per shell run", () => { + const marks: string[] = []; + const performanceMarks = createDashboardPerformanceMarks({ + mark: (name) => marks.push(name), + }); + + performanceMarks.markShellLoad(); + performanceMarks.markFirstTileReady(); + performanceMarks.markFirstTileReady(); + performanceMarks.markVisibleTilesReady(); + performanceMarks.markAllTilesSettled(); + performanceMarks.markShellLoad(); + performanceMarks.markFirstTileReady(); + + expect(marks).toEqual([ + dashboardPerformanceMarks.shellLoad, + dashboardPerformanceMarks.firstTileReady, + dashboardPerformanceMarks.visibleTilesReady, + dashboardPerformanceMarks.allTilesSettled, + dashboardPerformanceMarks.shellLoad, + dashboardPerformanceMarks.firstTileReady, + ]); + }); + test("backs off failed tile keys and resets after success", () => { const backoff = createDashboardTileBackoff(); diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts index a77fdd1..a9aa3a5 100644 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -157,6 +157,8 @@ export interface DashboardViewportHydrationQueueOptions { getModelId: (item: TItem) => string; hydrate: (item: TItem) => Promise | void; items: TItem[]; + onAllItemsSettled?: () => void; + onVisibleItemsSettled?: () => void; signal: AbortSignal; waitForIdle: () => Promise; } @@ -179,8 +181,13 @@ export async function runViewportAwareDashboardHydrationQueue( items: visible, signal: options.signal, }); + if (options.signal.aborted) return; + options.onVisibleItemsSettled?.(); - if (!deferred.length || options.signal.aborted) return; + if (!deferred.length) { + options.onAllItemsSettled?.(); + return; + } await options.waitForIdle(); if (options.signal.aborted) return; @@ -191,6 +198,7 @@ export async function runViewportAwareDashboardHydrationQueue( items: deferred, signal: options.signal, }); + options.onAllItemsSettled?.(); } export function splitDashboardHydrationItemsByVisibility(options: { @@ -408,6 +416,51 @@ export function createDashboardRefreshDelay( }; } +export interface DashboardPerformanceMarkOptions { + mark?: (name: string) => void; +} + +export const dashboardPerformanceMarks = { + allTilesSettled: "dashboard:all-tiles-settled", + firstTileReady: "dashboard:first-tile-ready", + shellLoad: "dashboard:shell-load", + visibleTilesReady: "dashboard:visible-tiles-ready", +} as const; + +export function createDashboardPerformanceMarks( + options: DashboardPerformanceMarkOptions = {}, +) { + const mark = options.mark || + globalThis.performance?.mark?.bind(globalThis.performance); + let firstTileReadyMarked = false; + + function safeMark(name: string) { + try { + mark?.(name); + } catch { + // Performance marks are diagnostics only. + } + } + + return { + markAllTilesSettled(): void { + safeMark(dashboardPerformanceMarks.allTilesSettled); + }, + markFirstTileReady(): void { + if (firstTileReadyMarked) return; + firstTileReadyMarked = true; + safeMark(dashboardPerformanceMarks.firstTileReady); + }, + markShellLoad(): void { + firstTileReadyMarked = false; + safeMark(dashboardPerformanceMarks.shellLoad); + }, + markVisibleTilesReady(): void { + safeMark(dashboardPerformanceMarks.visibleTilesReady); + }, + }; +} + const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000]; export function createDashboardTileBackoff() { diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts index 9b11079..a143517 100644 --- a/apps/web/src/server/routes/dashboard.test.ts +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterAll, afterEach, describe, expect, test, vi } from "vitest"; import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; import { createDashboardTileCache, @@ -6,9 +6,20 @@ import { handleDashboardTileRoute, loadDashboardResponse, loadDashboardTileResponse, + type DashboardTileResolutionLogEvent, } from "./dashboard"; describe("dashboard API route", () => { + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); + + afterEach(() => { + consoleInfo.mockClear(); + }); + + afterAll(() => { + consoleInfo.mockRestore(); + }); + test("returns the ready dashboard shell without hydrating live datasources", async () => { const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( new Error("live datasource fetch should not run for the shell response"), @@ -154,12 +165,13 @@ describe("dashboard API route", () => { test("caches ready tile responses until the tile ttl expires", async () => { const cache = createDashboardTileCache(); let now = 1_000; - const fetch = vi.fn(async () => - jsonResponse({ + const fetch = vi.fn(async () => { + now += 7; + return jsonResponse({ status: "UP", ping: 42, - }), - ); + }); + }); const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; const first = await loadDashboardTileResponse(tile, { @@ -190,6 +202,68 @@ describe("dashboard API route", () => { expect(fetch).toHaveBeenCalledTimes(2); }); + test("logs tile duration and cache hit or miss metadata", async () => { + let now = 1_000; + const logs: DashboardTileResolutionLogEvent[] = []; + const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; + const service = dimensionLabDashboardFixture.serviceGroups + .flatMap((group) => group.services) + .find((item) => item.id === tile.id); + if (!service) throw new Error("missing service fixture"); + + let cacheCalls = 0; + const tileCache = { + async resolve() { + cacheCalls += 1; + if (cacheCalls === 1) now += 7; + const cacheState = cacheCalls === 1 ? "miss" as const : "hit" as const; + + return { + cache: cacheState, + coalesced: false, + response: { + state: "ready" as const, + tile, + item: service, + }, + }; + }, + }; + + await loadDashboardTileResponse(tile, { + logTileResolution: (event) => logs.push(event), + now: () => now, + refreshSeedDocument: true, + seedIfEmpty: true, + tileCache, + }); + now += 10; + await loadDashboardTileResponse(tile, { + logTileResolution: (event) => logs.push(event), + now: () => now, + refreshSeedDocument: true, + seedIfEmpty: true, + tileCache, + }); + + expect(logs).toEqual([ + expect.objectContaining({ + cache: "miss", + coalesced: false, + durationMs: 7, + status: "ready", + tileKey: dashboardTileCacheKey(tile), + }), + expect.objectContaining({ + cache: "hit", + coalesced: false, + durationMs: 0, + status: "ready", + tileKey: dashboardTileCacheKey(tile), + }), + ]); + }); + test("keeps telemetry tiles cached for fifteen seconds", async () => { const cache = createDashboardTileCache(); let now = 1_000; @@ -268,6 +342,7 @@ describe("dashboard API route", () => { test("coalesces concurrent tile requests for the same cache key", async () => { const cache = createDashboardTileCache(); + const logs: DashboardTileResolutionLogEvent[] = []; let resolveFetch: ((response: Response) => void) | undefined; const fetch = vi.fn(() => new Promise((resolve) => { @@ -278,6 +353,7 @@ describe("dashboard API route", () => { const first = loadDashboardTileResponse(tile, { fetch, + logTileResolution: (event) => logs.push(event), now: () => 1_000, refreshSeedDocument: true, seedIfEmpty: true, @@ -285,6 +361,7 @@ describe("dashboard API route", () => { }); const second = loadDashboardTileResponse(tile, { fetch, + logTileResolution: (event) => logs.push(event), now: () => 1_000, refreshSeedDocument: true, seedIfEmpty: true, @@ -296,6 +373,48 @@ describe("dashboard API route", () => { resolveFetch?.(jsonResponse({ status: "UP", ping: 42 })); expect(await first).toEqual(await second); + expect(logs).toEqual([ + expect.objectContaining({ + cache: "miss", + coalesced: false, + status: "ready", + tileKey: dashboardTileCacheKey(tile), + }), + expect.objectContaining({ + cache: "miss", + coalesced: true, + status: "ready", + tileKey: dashboardTileCacheKey(tile), + }), + ]); + }); + + test("logs error categories for failed tile cache resolution", async () => { + const logs: DashboardTileResolutionLogEvent[] = []; + const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; + + await expect( + loadDashboardTileResponse(tile, { + logTileResolution: (event) => logs.push(event), + refreshSeedDocument: true, + seedIfEmpty: true, + tileCache: { + async resolve() { + throw new TypeError("cache failed"); + }, + }, + }), + ).rejects.toThrow("cache failed"); + + expect(logs).toEqual([ + expect.objectContaining({ + cache: "miss", + coalesced: false, + errorCategory: "TypeError", + status: "error", + tileKey: dashboardTileCacheKey(tile), + }), + ]); }); test("uses structured tile cache keys when identifiers contain delimiters", () => { diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts index ce9c4e6..9c32d26 100644 --- a/apps/web/src/server/routes/dashboard.ts +++ b/apps/web/src/server/routes/dashboard.ts @@ -20,6 +20,7 @@ export interface LoadDashboardResponseOptions DatasourceResolutionOptions { disableLiveDatasources?: boolean; hydrateLiveDatasources?: boolean; + logTileResolution?: (event: DashboardTileResolutionLogEvent) => void; now?: () => number; tileCache?: DashboardTileCache; } @@ -35,7 +36,7 @@ export interface DashboardTileCache { ttlMs: number, now: number, load: () => Promise, - ): Promise; + ): Promise; } const dashboardTileResponseHeaders = { @@ -44,6 +45,21 @@ const dashboardTileResponseHeaders = { const defaultDashboardTileCache = createDashboardTileCache(); +interface DashboardTileCacheResult { + cache: "hit" | "miss"; + coalesced: boolean; + response: DashboardTileResolution; +} + +export interface DashboardTileResolutionLogEvent { + cache: "bypass" | "hit" | "miss"; + coalesced: boolean; + durationMs: number; + errorCategory?: string; + status: DashboardTileResolution["state"] | "error"; + tileKey: string; +} + export function createDashboardTileCache(): DashboardTileCache { const entries = new Map(); const inFlight = new Map>(); @@ -52,11 +68,21 @@ export function createDashboardTileCache(): DashboardTileCache { async resolve(key, ttlMs, now, load) { const cached = entries.get(key); if (cached && cached.expiresAt > now) { - return cached.response; + return { + cache: "hit", + coalesced: false, + response: cached.response, + }; } const active = inFlight.get(key); - if (active) return active; + if (active) { + return active.then((response) => ({ + cache: "miss", + coalesced: true, + response, + })); + } const request = load() .then((response) => { @@ -74,7 +100,11 @@ export function createDashboardTileCache(): DashboardTileCache { }); inFlight.set(key, request); - return request; + return request.then((response) => ({ + cache: "miss", + coalesced: false, + response, + })); }, }; } @@ -125,13 +155,26 @@ export async function loadDashboardTileResponse( options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1" ) { - return { + const tileKey = dashboardTileCacheKey(tile); + const startedAt = options.now?.() ?? Date.now(); + const response = { state: "disabled", tile, message: "Live datasource hydration is disabled.", - }; + } satisfies DashboardTileResolution; + logDashboardTileResolution(options, { + cache: "bypass", + coalesced: false, + durationMs: elapsedDashboardTileMs(startedAt, options), + status: response.state, + tileKey, + }); + return response; } + const tileKey = dashboardTileCacheKey(tile); + const startedAt = options.now?.() ?? Date.now(); + const dashboard = loadDashboardRuntime(undefined, { refreshSeedDocument: options.refreshSeedDocument ?? true, seedIfEmpty: options.seedIfEmpty ?? true, @@ -139,22 +182,50 @@ export async function loadDashboardTileResponse( }); if (dashboard.state !== "ready") { - return { + const response = { state: "not_found", tile, message: `Dashboard is not ready: ${dashboard.state}`, - }; + } satisfies DashboardTileResolution; + logDashboardTileResolution(options, { + cache: "bypass", + coalesced: false, + durationMs: elapsedDashboardTileMs(startedAt, options), + status: response.state, + tileKey, + }); + return response; } const cache = options.tileCache || defaultDashboardTileCache; const now = options.now?.() ?? Date.now(); - return cache.resolve( - dashboardTileCacheKey(tile), - dashboardTileTtlMs(dashboard.document, tile), - now, - () => resolveDashboardTile(dashboard.document, tile, options), - ); + try { + const result = await cache.resolve( + tileKey, + dashboardTileTtlMs(dashboard.document, tile), + now, + () => resolveDashboardTile(dashboard.document, tile, options), + ); + logDashboardTileResolution(options, { + cache: result.cache, + coalesced: result.coalesced, + durationMs: elapsedDashboardTileMs(startedAt, options), + status: result.response.state, + tileKey, + }); + return result.response; + } catch (error) { + logDashboardTileResolution(options, { + cache: "miss", + coalesced: false, + durationMs: elapsedDashboardTileMs(startedAt, options), + errorCategory: dashboardTileErrorCategory(error), + status: "error", + tileKey, + }); + throw error; + } } export async function handleDashboardRoute(): Promise { @@ -184,6 +255,39 @@ export function dashboardTileCacheKey(tile: DashboardTileReference): string { return JSON.stringify(tile); } +function logDashboardTileResolution( + options: LoadDashboardResponseOptions, + event: DashboardTileResolutionLogEvent, +): void { + if (options.logTileResolution) { + options.logTileResolution(event); + return; + } + + console.info("dashboard.tile", event); +} + +function elapsedDashboardTileMs( + startedAt: number, + options: LoadDashboardResponseOptions, +): number { + const now = options.now?.() ?? Date.now(); + return Math.max(0, now - startedAt); +} + +function dashboardTileErrorCategory(error: unknown): string { + if ( + typeof error === "object" && + error !== null && + "name" in error && + typeof error.name === "string" + ) { + return error.name; + } + + return "unknown"; +} + function dashboardTileTtlMs( document: DashboardDocument, tile: DashboardTileReference, -- 2.49.1 From 0d27920a784e57717508d24d80eb75c41e1c4b1b Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 15:04:51 +0200 Subject: [PATCH 2/2] fix(web): preserve coalesced tile error logs --- apps/web/src/server/routes/dashboard.test.ts | 56 +++++++++++++++ apps/web/src/server/routes/dashboard.ts | 73 +++++++++++++++++--- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts index a143517..d40e263 100644 --- a/apps/web/src/server/routes/dashboard.test.ts +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -389,6 +389,62 @@ describe("dashboard API route", () => { ]); }); + test("logs coalesced metadata when shared tile requests fail", async () => { + const cache = createDashboardTileCache(); + let rejectLoad: ((error: Error) => void) | undefined; + const load = vi.fn(() => + new Promise((_resolve, reject) => { + rejectLoad = reject; + }) + ); + + const first = cache.resolve("tile-a", 30_000, 1_000, load); + const second = cache.resolve("tile-a", 30_000, 1_000, load); + + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(1); + + rejectLoad?.(new TypeError("upstream failed")); + await expect(first).rejects.toThrow("upstream failed"); + await expect(second).rejects.toMatchObject({ + cache: "miss", + coalesced: true, + cause: expect.any(TypeError), + }); + }); + + test("logs coalesced metadata for failed tile cache resolution", async () => { + const logs: DashboardTileResolutionLogEvent[] = []; + const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; + + await expect( + loadDashboardTileResponse(tile, { + logTileResolution: (event) => logs.push(event), + refreshSeedDocument: true, + seedIfEmpty: true, + tileCache: { + async resolve() { + throw { + cache: "miss", + cause: new TypeError("coalesced cache failed"), + coalesced: true, + }; + }, + }, + }), + ).rejects.toThrow("coalesced cache failed"); + + expect(logs).toEqual([ + expect.objectContaining({ + cache: "miss", + coalesced: true, + errorCategory: "TypeError", + status: "error", + tileKey: dashboardTileCacheKey(tile), + }), + ]); + }); + test("logs error categories for failed tile cache resolution", async () => { const logs: DashboardTileResolutionLogEvent[] = []; const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts index 9c32d26..42c4088 100644 --- a/apps/web/src/server/routes/dashboard.ts +++ b/apps/web/src/server/routes/dashboard.ts @@ -77,11 +77,18 @@ export function createDashboardTileCache(): DashboardTileCache { const active = inFlight.get(key); if (active) { - return active.then((response) => ({ - cache: "miss", - coalesced: true, - response, - })); + return active + .then((response) => ({ + cache: "miss" as const, + coalesced: true, + response, + })) + .catch((error) => { + throw new DashboardTileCacheResolutionError(error, { + cache: "miss", + coalesced: true, + }); + }); } const request = load() @@ -101,7 +108,7 @@ export function createDashboardTileCache(): DashboardTileCache { inFlight.set(key, request); return request.then((response) => ({ - cache: "miss", + cache: "miss" as const, coalesced: false, response, })); @@ -216,15 +223,16 @@ export async function loadDashboardTileResponse( }); return result.response; } catch (error) { + const cacheError = dashboardTileCacheResolutionError(error); logDashboardTileResolution(options, { - cache: "miss", - coalesced: false, + cache: cacheError?.cache ?? "miss", + coalesced: cacheError?.coalesced ?? false, durationMs: elapsedDashboardTileMs(startedAt, options), - errorCategory: dashboardTileErrorCategory(error), + errorCategory: dashboardTileErrorCategory(cacheError?.cause ?? error), status: "error", tileKey, }); - throw error; + throw cacheError?.cause ?? error; } } @@ -288,6 +296,51 @@ function dashboardTileErrorCategory(error: unknown): string { return "unknown"; } +class DashboardTileCacheResolutionError extends Error { + readonly cache: "hit" | "miss"; + readonly coalesced: boolean; + override readonly cause: unknown; + + constructor( + cause: unknown, + metadata: Pick, + ) { + super("Dashboard tile cache resolution failed"); + this.name = "DashboardTileCacheResolutionError"; + this.cause = cause; + this.cache = metadata.cache; + this.coalesced = metadata.coalesced; + } +} + +function dashboardTileCacheResolutionError( + error: unknown, +): DashboardTileCacheResolutionFailure | null { + if ( + typeof error === "object" && + error !== null && + "cache" in error && + (error.cache === "hit" || error.cache === "miss") && + "coalesced" in error && + typeof error.coalesced === "boolean" && + "cause" in error + ) { + return { + cache: error.cache, + cause: error.cause, + coalesced: error.coalesced, + }; + } + + return null; +} + +interface DashboardTileCacheResolutionFailure { + cache: "hit" | "miss"; + cause: unknown; + coalesced: boolean; +} + function dashboardTileTtlMs( document: DashboardDocument, tile: DashboardTileReference, -- 2.49.1