perf(web): add dashboard hydration observability #50
5 changed files with 448 additions and 22 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -157,6 +157,8 @@ export interface DashboardViewportHydrationQueueOptions<TItem> {
|
|||
getModelId: (item: TItem) => string;
|
||||
hydrate: (item: TItem) => Promise<void> | void;
|
||||
items: TItem[];
|
||||
onAllItemsSettled?: () => void;
|
||||
onVisibleItemsSettled?: () => void;
|
||||
signal: AbortSignal;
|
||||
waitForIdle: () => Promise<void>;
|
||||
}
|
||||
|
|
@ -179,8 +181,13 @@ export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
|||
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<TItem>(
|
|||
items: deferred,
|
||||
signal: options.signal,
|
||||
});
|
||||
options.onAllItemsSettled?.();
|
||||
}
|
||||
|
||||
export function splitDashboardHydrationItemsByVisibility<TItem>(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() {
|
||||
|
|
|
|||
|
|
@ -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<Response>((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,104 @@ 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 coalesced metadata when shared tile requests fail", async () => {
|
||||
const cache = createDashboardTileCache();
|
||||
let rejectLoad: ((error: Error) => void) | undefined;
|
||||
const load = vi.fn(() =>
|
||||
new Promise<never>((_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;
|
||||
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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<DashboardTileResolution>,
|
||||
): Promise<DashboardTileResolution>;
|
||||
): Promise<DashboardTileCacheResult>;
|
||||
}
|
||||
|
||||
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<string, DashboardTileCacheEntry>();
|
||||
const inFlight = new Map<string, Promise<DashboardTileResolution>>();
|
||||
|
|
@ -52,11 +68,28 @@ 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" as const,
|
||||
coalesced: true,
|
||||
response,
|
||||
}))
|
||||
.catch((error) => {
|
||||
throw new DashboardTileCacheResolutionError(error, {
|
||||
cache: "miss",
|
||||
coalesced: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const request = load()
|
||||
.then((response) => {
|
||||
|
|
@ -74,7 +107,11 @@ export function createDashboardTileCache(): DashboardTileCache {
|
|||
});
|
||||
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
return request.then((response) => ({
|
||||
cache: "miss" as const,
|
||||
coalesced: false,
|
||||
response,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -125,13 +162,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 +189,51 @@ 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) {
|
||||
const cacheError = dashboardTileCacheResolutionError(error);
|
||||
logDashboardTileResolution(options, {
|
||||
cache: cacheError?.cache ?? "miss",
|
||||
coalesced: cacheError?.coalesced ?? false,
|
||||
durationMs: elapsedDashboardTileMs(startedAt, options),
|
||||
errorCategory: dashboardTileErrorCategory(cacheError?.cause ?? error),
|
||||
status: "error",
|
||||
tileKey,
|
||||
});
|
||||
throw cacheError?.cause ?? error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDashboardRoute(): Promise<Response> {
|
||||
|
|
@ -184,6 +263,84 @@ 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";
|
||||
}
|
||||
|
||||
class DashboardTileCacheResolutionError extends Error {
|
||||
readonly cache: "hit" | "miss";
|
||||
readonly coalesced: boolean;
|
||||
override readonly cause: unknown;
|
||||
|
||||
constructor(
|
||||
cause: unknown,
|
||||
metadata: Pick<DashboardTileCacheResult, "cache" | "coalesced">,
|
||||
) {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue