feat(FN-5389): add resume event instrumentation with SSE, hooks, and diagno

FN-5389 adds dashboard resume event instrumentation: a `resumeInstrumentation` utility captures SSE resume signals, wired through `useChat`, `useChatRooms`, and `useTasks` hooks, with remount markers in `Board` and `ChatView`; diagnostics routes expose resume events for observability, documented in

Fusion-Task-Id: FN-5389
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 19:55:46 -07:00
committed by gsxdsm
parent 12582414da
commit 1913cb59fe
16 changed files with 885 additions and 4 deletions

View File

@@ -0,0 +1,169 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { request as performRequest } from "../../test-request.js";
import {
__resetResumeDiagnosticsForTests,
__setResumeDiagnosticsCapForTests,
registerDiagnosticsRoutes,
} from "../register-diagnostics-routes.js";
function createApp(getProjectContext = vi.fn(async () => ({ projectId: "proj-1", store: {} }))) {
const router = express.Router();
const rethrowAsApiError = vi.fn((error: unknown) => {
throw error;
});
registerDiagnosticsRoutes({
router,
store: {} as never,
runtimeLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
planningLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
chatLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
getProjectIdFromRequest: vi.fn(() => "proj-1"),
getScopedStore: vi.fn(async () => ({}) as never),
getProjectContext,
prioritizeProjectsForCurrentDirectory: vi.fn((projects: Array<{ path: string }>) => projects),
emitRemoteRouteDiagnostic: vi.fn(),
emitAuthSyncAuditLog: vi.fn(),
parseScopeParam: vi.fn(),
resolveAutomationStore: vi.fn() as never,
resolveRoutineStore: vi.fn() as never,
resolveRoutineRunner: vi.fn() as never,
registerDispose: vi.fn(),
dispose: vi.fn(),
rethrowAsApiError,
});
const app = express();
app.use(express.json());
app.use("/api", router);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? 500).json({ error: err?.message ?? String(err) });
});
return { app, getProjectContext, rethrowAsApiError };
}
const validEvent = {
ts: "2026-05-20T12:00:00.000Z",
view: "useTasks",
trigger: "visibility",
replayAttempted: false,
detail: { reason: "debounced-refresh" },
};
describe("register-diagnostics-routes", () => {
beforeEach(() => {
__resetResumeDiagnosticsForTests();
});
it("accepts valid POST payload", async () => {
const { app } = createApp();
const response = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: [validEvent] }),
{ "Content-Type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true, accepted: 1 });
});
it("rejects invalid payloads", async () => {
const { app } = createApp();
const oversized = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: new Array(101).fill(validEvent) }),
{ "Content-Type": "application/json" },
);
expect(oversized.status).toBe(400);
const badTrigger = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: [{ ...validEvent, trigger: "unknown" }] }),
{ "Content-Type": "application/json" },
);
expect(badTrigger.status).toBe(400);
const hugeDetail = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: [{ ...validEvent, detail: { blob: "x".repeat(5000) } }] }),
{ "Content-Type": "application/json" },
);
expect(hugeDetail.status).toBe(400);
});
it("supports GET filters by since and view", async () => {
const { app } = createApp();
await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({
events: [
validEvent,
{ ...validEvent, ts: "2026-05-20T12:10:00.000Z", view: "useChatRooms", trigger: "sse-reconnect" },
],
}),
{ "Content-Type": "application/json" },
);
const response = await performRequest(app, "GET", "/api/diagnostics/resume-events?since=2026-05-20T12:05:00.000Z&view=useChatRooms");
expect(response.status).toBe(200);
expect(response.body.events).toHaveLength(1);
expect(response.body.events[0]).toMatchObject({ view: "useChatRooms" });
});
it("tracks ring overflow and returns droppedSinceLastRead", async () => {
const { app } = createApp();
__setResumeDiagnosticsCapForTests(200);
const chunk = new Array(100).fill(null).map((_, idx) => ({
...validEvent,
ts: new Date(Date.UTC(2026, 4, 20, 12, 0, 0, idx)).toISOString(),
detail: { idx },
}));
for (let i = 0; i < 3; i += 1) {
await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: chunk }),
{ "Content-Type": "application/json" },
);
}
const response = await performRequest(app, "GET", "/api/diagnostics/resume-events?limit=5000");
expect(response.status).toBe(200);
expect(response.body.events).toHaveLength(200);
expect(response.body.droppedSinceLastRead).toBe(100);
const secondRead = await performRequest(app, "GET", "/api/diagnostics/resume-events?limit=1");
expect(secondRead.body.droppedSinceLastRead).toBe(0);
});
it("uses getProjectContext / rethrow flow on auth errors", async () => {
const getProjectContext = vi.fn(async () => {
throw new Error("unauthorized");
});
const { app, rethrowAsApiError } = createApp(getProjectContext);
const response = await performRequest(app, "GET", "/api/diagnostics/resume-events");
expect(response.status).toBe(500);
expect(rethrowAsApiError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,138 @@
import type { ApiRouteRegistrar } from "./types.js";
let resumeRingCap = 5_000;
const ACCEPT_CAP = 100;
const DETAIL_CAP_BYTES = 4 * 1024;
const triggers = new Set([
"visibility",
"pageshow",
"sse-error",
"sse-reconnect",
"sse-open",
"remount",
"route-active",
"route-inactive",
"project-context-change",
]);
type ResumeEvent = {
ts: string;
view: string;
trigger: string;
projectId?: string;
gapMs?: number;
replayAttempted: boolean;
replayFromEventId?: number | null;
lastEventId?: number | null;
sseChannel?: string;
reason?: string;
detail?: Record<string, unknown>;
};
const resumeEvents: ResumeEvent[] = [];
let droppedCount = 0;
function isIsoDate(value: unknown): value is string {
if (typeof value !== "string") return false;
if (Number.isNaN(Date.parse(value))) return false;
return new Date(value).toISOString() === value;
}
function isJsonSafe(value: unknown): boolean {
try {
JSON.stringify(value);
return true;
} catch {
return false;
}
}
function validateEvent(event: unknown): event is ResumeEvent {
if (!event || typeof event !== "object") return false;
const candidate = event as Record<string, unknown>;
if (!isIsoDate(candidate.ts)) return false;
if (typeof candidate.view !== "string" || candidate.view.length === 0 || candidate.view.length > 64) return false;
if (typeof candidate.trigger !== "string" || !triggers.has(candidate.trigger)) return false;
if (typeof candidate.replayAttempted !== "boolean") return false;
if (candidate.detail !== undefined) {
if (!isJsonSafe(candidate.detail)) return false;
if (Buffer.byteLength(JSON.stringify(candidate.detail), "utf8") > DETAIL_CAP_BYTES) return false;
}
return true;
}
function appendEvents(events: ResumeEvent[]): void {
resumeEvents.push(...events);
if (resumeEvents.length > resumeRingCap) {
const overflow = resumeEvents.length - resumeRingCap;
droppedCount += overflow;
resumeEvents.splice(0, overflow);
}
}
export const registerDiagnosticsRoutes: ApiRouteRegistrar = (ctx) => {
const { router } = ctx;
router.post("/diagnostics/resume-events", async (req, res) => {
try {
await ctx.getProjectContext(req);
const body = req.body as { events?: unknown };
const events = body?.events;
if (!Array.isArray(events) || events.length > ACCEPT_CAP) {
res.status(400).json({ error: "Invalid events payload" });
return;
}
if (!events.every(validateEvent)) {
res.status(400).json({ error: "Invalid resume event entry" });
return;
}
appendEvents(events);
res.json({ ok: true, accepted: events.length });
} catch (error) {
ctx.rethrowAsApiError(error, "Failed to store resume diagnostics events");
}
});
router.get("/diagnostics/resume-events", async (req, res) => {
try {
await ctx.getProjectContext(req);
const limit = Math.max(1, Math.min(Number(req.query.limit ?? 100) || 100, resumeRingCap));
const since = typeof req.query.since === "string" ? Date.parse(req.query.since) : NaN;
const view = typeof req.query.view === "string" ? req.query.view : undefined;
let filtered = resumeEvents;
if (!Number.isNaN(since)) {
filtered = filtered.filter((event) => Date.parse(event.ts) >= since);
}
if (view) {
filtered = filtered.filter((event) => event.view === view);
}
const events = filtered.slice(-limit);
const droppedSinceLastRead = droppedCount;
droppedCount = 0;
res.json({ events, droppedSinceLastRead });
} catch (error) {
ctx.rethrowAsApiError(error, "Failed to read resume diagnostics events");
}
});
};
export function __resetResumeDiagnosticsForTests(): void {
resumeEvents.length = 0;
droppedCount = 0;
resumeRingCap = 5_000;
}
export function __setResumeDiagnosticsCapForTests(cap: number): void {
resumeRingCap = Math.max(1, Math.floor(cap));
}