From 0d27920a784e57717508d24d80eb75c41e1c4b1b Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 15:04:51 +0200 Subject: [PATCH] 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,