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:
committed by
gsxdsm
parent
12582414da
commit
1913cb59fe
@@ -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();
|
||||
});
|
||||
});
|
||||
138
packages/dashboard/src/routes/register-diagnostics-routes.ts
Normal file
138
packages/dashboard/src/routes/register-diagnostics-routes.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user