Skip to content

Commit 57bc5c8

Browse files
committed
Surface replay block decode and fetch failures via a frontend diagnostic store
The notification bell only carried backend diagnostics. Replay block decode failures and HTTP failures of the block loader were visible only in the browser console — operators saw the result (no replay) but had no notification explaining why. Adds a frontend-side store that mirrors the backend's (channel, code, scope) identity model and merges into the existing notification panel: - frontend.replay.block_decode_failed when fzstd or JSON.parse rejects a block body (singleton entry rolling its message forward across blocks). - frontend.replay.block_load_failed when the fetch itself fails or returns non-2xx. Wiring lives in loadReplayBlock; the existing error throw path is unchanged so callers still surface the per-load error on the timeline. Frontend codes use the `frontend.*` channel namespace so identity collisions with backend codes are impossible — straight concat-then-sort merge with the existing backend snapshot. Store details: TTL-refreshed-by-re-emission (default 30s), defensive cap of 50 entries, monotonic tie-break for same-millisecond emits, lazy expiry on read. Reactive snapshot via a memoized hook so StatusBar's render path adds no work between mutations. Also nudges the README Caddy example to set Content-Type: application/json on the zstd path (per RFC, Content-Type describes the resource after decoding); the octet-stream fallback still applies when the client lacks zstd Accept-Encoding.
1 parent 95145b8 commit 57bc5c8

6 files changed

Lines changed: 311 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ directory behind the reverse proxy and let the proxy serve finalized
372372
handle_path /api/replay/blocks/* {
373373
root * /var/lib/ident/replay/blocks
374374
header Content-Type application/octet-stream
375+
header @accepts_zstd Content-Type application/json
375376
header @accepts_zstd Content-Encoding zstd
376377
header Cache-Control "public, max-age=31536000, immutable"
377378
file_server
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import {
3+
__resetFrontendDiagnosticsForTest,
4+
clearFrontendDiagnostic,
5+
DEFAULT_FRONTEND_DIAGNOSTIC_TTL_MS,
6+
emitFrontendDiagnostic,
7+
FRONTEND_DIAGNOSTIC_CAP,
8+
snapshotFrontendDiagnostics,
9+
} from "./frontendDiagnostics";
10+
11+
afterEach(() => {
12+
__resetFrontendDiagnosticsForTest();
13+
});
14+
15+
describe("frontendDiagnostics", () => {
16+
it("emit + snapshot returns the entry with seenAtEpochMs set", () => {
17+
const before = Date.now();
18+
emitFrontendDiagnostic({
19+
severity: "warning",
20+
channel: "frontend.replay",
21+
code: "replay.block_decode_failed",
22+
message: "Could not decode block /api/replay/blocks/1-2.json.zst",
23+
});
24+
const after = Date.now();
25+
26+
const snap = snapshotFrontendDiagnostics();
27+
expect(snap).toHaveLength(1);
28+
const [entry] = snap;
29+
expect(entry).toMatchObject({
30+
severity: "warning",
31+
channel: "frontend.replay",
32+
code: "replay.block_decode_failed",
33+
message: "Could not decode block /api/replay/blocks/1-2.json.zst",
34+
});
35+
expect(entry.seenAtEpochMs).toBeGreaterThanOrEqual(before);
36+
expect(entry.seenAtEpochMs).toBeLessThanOrEqual(after);
37+
});
38+
39+
it("re-emitting the same identity replaces mutable fields and refreshes seenAt", () => {
40+
emitFrontendDiagnostic({
41+
severity: "info",
42+
channel: "frontend.network",
43+
code: "network.websocket_dropped",
44+
message: "first",
45+
});
46+
const firstSeen = snapshotFrontendDiagnostics()[0].seenAtEpochMs;
47+
48+
// Tiny delay so seenAt can move forward measurably.
49+
const later = firstSeen + 5;
50+
emitFrontendDiagnostic({
51+
severity: "error",
52+
channel: "frontend.network",
53+
code: "network.websocket_dropped",
54+
message: "second",
55+
});
56+
57+
const snap = snapshotFrontendDiagnostics(later);
58+
expect(snap).toHaveLength(1);
59+
expect(snap[0].severity).toBe("error");
60+
expect(snap[0].message).toBe("second");
61+
expect(snap[0].seenAtEpochMs).toBeGreaterThanOrEqual(firstSeen);
62+
});
63+
64+
it("treats different scopes as distinct identities", () => {
65+
emitFrontendDiagnostic({
66+
severity: "warning",
67+
channel: "frontend.replay",
68+
code: "replay.block_decode_failed",
69+
scope: "block-a",
70+
message: "a",
71+
});
72+
emitFrontendDiagnostic({
73+
severity: "warning",
74+
channel: "frontend.replay",
75+
code: "replay.block_decode_failed",
76+
scope: "block-b",
77+
message: "b",
78+
});
79+
80+
const snap = snapshotFrontendDiagnostics();
81+
expect(snap).toHaveLength(2);
82+
expect(snap.map((d) => d.scope).sort()).toEqual(["block-a", "block-b"]);
83+
});
84+
85+
it("snapshot omits entries whose TTL has elapsed", () => {
86+
emitFrontendDiagnostic({
87+
severity: "warning",
88+
channel: "frontend.replay",
89+
code: "code.a",
90+
message: "stale",
91+
ttlMs: 1000,
92+
});
93+
94+
const seenAt = snapshotFrontendDiagnostics()[0].seenAtEpochMs;
95+
const expired = snapshotFrontendDiagnostics(seenAt + 1001);
96+
expect(expired).toHaveLength(0);
97+
});
98+
99+
it("snapshot still returns entries before TTL elapses", () => {
100+
emitFrontendDiagnostic({
101+
severity: "warning",
102+
channel: "frontend.replay",
103+
code: "code.a",
104+
message: "fresh",
105+
ttlMs: 1000,
106+
});
107+
const seenAt = snapshotFrontendDiagnostics()[0].seenAtEpochMs;
108+
expect(snapshotFrontendDiagnostics(seenAt + 999)).toHaveLength(1);
109+
});
110+
111+
it("defaults ttlMs to DEFAULT_FRONTEND_DIAGNOSTIC_TTL_MS when omitted", () => {
112+
emitFrontendDiagnostic({
113+
severity: "info",
114+
channel: "frontend.x",
115+
code: "code.a",
116+
message: "m",
117+
});
118+
const seenAt = snapshotFrontendDiagnostics()[0].seenAtEpochMs;
119+
// Just under the default cap → still present.
120+
expect(
121+
snapshotFrontendDiagnostics(
122+
seenAt + DEFAULT_FRONTEND_DIAGNOSTIC_TTL_MS - 1,
123+
),
124+
).toHaveLength(1);
125+
// Just over → expired.
126+
expect(
127+
snapshotFrontendDiagnostics(
128+
seenAt + DEFAULT_FRONTEND_DIAGNOSTIC_TTL_MS + 1,
129+
),
130+
).toHaveLength(0);
131+
});
132+
133+
it("clear removes an entry by identity", () => {
134+
emitFrontendDiagnostic({
135+
severity: "warning",
136+
channel: "frontend.x",
137+
code: "code.a",
138+
message: "m",
139+
});
140+
clearFrontendDiagnostic("frontend.x", "code.a");
141+
expect(snapshotFrontendDiagnostics()).toHaveLength(0);
142+
});
143+
144+
it("clear is a no-op when nothing matches the identity", () => {
145+
expect(() =>
146+
clearFrontendDiagnostic("frontend.nope", "code.absent"),
147+
).not.toThrow();
148+
emitFrontendDiagnostic({
149+
severity: "warning",
150+
channel: "frontend.x",
151+
code: "code.a",
152+
message: "m",
153+
});
154+
clearFrontendDiagnostic("frontend.x", "code.a", "wrong-scope");
155+
expect(snapshotFrontendDiagnostics()).toHaveLength(1);
156+
});
157+
158+
it("clear respects scope (matches only the identity with that scope)", () => {
159+
emitFrontendDiagnostic({
160+
severity: "warning",
161+
channel: "frontend.x",
162+
code: "code.a",
163+
scope: "s1",
164+
message: "m1",
165+
});
166+
emitFrontendDiagnostic({
167+
severity: "warning",
168+
channel: "frontend.x",
169+
code: "code.a",
170+
scope: "s2",
171+
message: "m2",
172+
});
173+
clearFrontendDiagnostic("frontend.x", "code.a", "s1");
174+
const snap = snapshotFrontendDiagnostics();
175+
expect(snap).toHaveLength(1);
176+
expect(snap[0].scope).toBe("s2");
177+
});
178+
179+
it("evicts the oldest entry when exceeding FRONTEND_DIAGNOSTIC_CAP", () => {
180+
// Emit one over the cap; the very first entry must be gone.
181+
for (let i = 0; i <= FRONTEND_DIAGNOSTIC_CAP; i++) {
182+
emitFrontendDiagnostic({
183+
severity: "info",
184+
channel: "frontend.x",
185+
code: `code.${i}`,
186+
message: String(i),
187+
});
188+
}
189+
const snap = snapshotFrontendDiagnostics();
190+
expect(snap).toHaveLength(FRONTEND_DIAGNOSTIC_CAP);
191+
const codes = new Set(snap.map((d) => d.code));
192+
expect(codes.has("code.0")).toBe(false);
193+
expect(codes.has(`code.${FRONTEND_DIAGNOSTIC_CAP}`)).toBe(true);
194+
});
195+
196+
it("snapshot returns entries sorted newest-first by seenAtEpochMs", () => {
197+
emitFrontendDiagnostic({
198+
severity: "info",
199+
channel: "frontend.x",
200+
code: "code.first",
201+
message: "first",
202+
});
203+
const firstSeen = snapshotFrontendDiagnostics()[0].seenAtEpochMs;
204+
emitFrontendDiagnostic({
205+
severity: "info",
206+
channel: "frontend.x",
207+
code: "code.second",
208+
message: "second",
209+
});
210+
const snap = snapshotFrontendDiagnostics(firstSeen + 10);
211+
expect(snap.map((d) => d.code)).toEqual(["code.second", "code.first"]);
212+
});
213+
214+
it("omits scope from the snapshot when not provided", () => {
215+
emitFrontendDiagnostic({
216+
severity: "info",
217+
channel: "frontend.x",
218+
code: "code.a",
219+
message: "m",
220+
});
221+
const [entry] = snapshotFrontendDiagnostics();
222+
expect(entry.scope).toBeUndefined();
223+
});
224+
});
5.29 KB
Binary file not shown.

ident/src/data/replay.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
__resetFrontendDiagnosticsForTest,
4+
snapshotFrontendDiagnostics,
5+
} from "./frontendDiagnostics";
26
import {
37
blocksForRange,
48
ensureReplayRange,
@@ -49,13 +53,15 @@ function resetStore() {
4953
describe("replay data loading", () => {
5054
beforeEach(() => {
5155
resetStore();
56+
__resetFrontendDiagnosticsForTest();
5257
window.history.replaceState(null, "", "/ident/#/");
5358
});
5459

5560
afterEach(() => {
5661
globalThis.fetch = originalFetch;
5762
vi.restoreAllMocks();
5863
window.history.replaceState(null, "", "/");
64+
__resetFrontendDiagnosticsForTest();
5965
});
6066

6167
it("loads the manifest from the mounted app path", async () => {
@@ -611,6 +617,49 @@ describe("replay data loading", () => {
611617
],
612618
).toBeTruthy();
613619
});
620+
621+
it("surfaces a frontend diagnostic when a replay block fails to decode", async () => {
622+
vi.spyOn(console, "warn").mockImplementation(() => {});
623+
globalThis.fetch = vi.fn(async (url: string) =>
624+
url.includes("manifest")
625+
? responseJson(manifest())
626+
: responseJson({ version: 2, frames: null }),
627+
) as never;
628+
629+
await refreshReplayManifest();
630+
await ensureReplayRange(120_000, 180_000, { background: true });
631+
632+
const snap = snapshotFrontendDiagnostics();
633+
const decodeDiag = snap.find(
634+
(d) => d.code === "replay.block_decode_failed",
635+
);
636+
expect(decodeDiag).toBeDefined();
637+
expect(decodeDiag?.channel).toBe("frontend.replay");
638+
expect(decodeDiag?.severity).toBe("warning");
639+
expect(decodeDiag?.message).toContain(
640+
"/api/replay/blocks/120000-180000.json.zst",
641+
);
642+
});
643+
644+
it("surfaces a frontend diagnostic when a replay block fetch fails", async () => {
645+
vi.spyOn(console, "warn").mockImplementation(() => {});
646+
globalThis.fetch = vi.fn(async (url: string) => {
647+
if (url.includes("manifest")) return responseJson(manifest());
648+
return { ok: false, status: 502, json: async () => null } as Response;
649+
}) as never;
650+
651+
await refreshReplayManifest();
652+
await ensureReplayRange(120_000, 180_000, { background: true });
653+
654+
const snap = snapshotFrontendDiagnostics();
655+
const loadDiag = snap.find((d) => d.code === "replay.block_load_failed");
656+
expect(loadDiag).toBeDefined();
657+
expect(loadDiag?.channel).toBe("frontend.replay");
658+
expect(loadDiag?.severity).toBe("warning");
659+
expect(loadDiag?.message).toContain(
660+
"/api/replay/blocks/120000-180000.json.zst",
661+
);
662+
});
614663
});
615664

616665
function manifest() {

ident/src/data/replay.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { appPath } from "./basePath";
2+
import { emitFrontendDiagnostic } from "./frontendDiagnostics";
23
import {
34
decodeReplayBlockResponse,
45
ReplayBlockBodyError,
@@ -204,15 +205,33 @@ function loadReplayBlock(block: ReplayBlockIndex): Promise<void> | null {
204205
}
205206
useIdentStore.getState().setReplayBlock(block.url, body);
206207
} catch (err) {
207-
if (
208-
err instanceof ReplayLoadCanceled ||
209-
err instanceof ReplayBlockFormatError
210-
) {
208+
if (err instanceof ReplayLoadCanceled) {
209+
throw err;
210+
}
211+
if (err instanceof ReplayBlockFormatError) {
212+
emitFrontendDiagnostic({
213+
severity: "warning",
214+
channel: "frontend.replay",
215+
code: "replay.block_decode_failed",
216+
message: `Could not decode replay block ${block.url}`,
217+
});
211218
throw err;
212219
}
213220
if (err instanceof ReplayBlockBodyError) {
221+
emitFrontendDiagnostic({
222+
severity: "warning",
223+
channel: "frontend.replay",
224+
code: "replay.block_decode_failed",
225+
message: `Could not decode replay block ${block.url}`,
226+
});
214227
throw new ReplayBlockFormatError(block.url);
215228
}
229+
emitFrontendDiagnostic({
230+
severity: "warning",
231+
channel: "frontend.replay",
232+
code: "replay.block_load_failed",
233+
message: `Could not load replay block ${block.url}`,
234+
});
216235
throw new ReplayBlockLoadError(block.url, err);
217236
} finally {
218237
if (blockLoads.get(block.url)?.promise === load) {

ident/src/statusbar/StatusBar.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import {
33
type CSSProperties,
44
type KeyboardEvent,
55
useEffect,
6+
useMemo,
67
useRef,
78
useState,
89
} from "react";
910
import { match, P } from "ts-pattern";
11+
import { useFrontendDiagnosticsSnapshot } from "../data/frontendDiagnostics";
1012
import {
1113
diagnosticIdentity,
1214
type NotificationSuppression,
@@ -124,7 +126,18 @@ export interface ReceiverDiagnostics {
124126
export function useReceiverDiagnostics(): ReceiverDiagnostics {
125127
const identStatus = useIdentStore((s) => s.identStatus);
126128
const capabilities = useIdentStore((s) => s.capabilities?.capabilities);
127-
const diagnostics = useIdentStore((s) => s.diagnostics);
129+
const backendDiagnostics = useIdentStore((s) => s.diagnostics);
130+
const frontendDiagnostics = useFrontendDiagnosticsSnapshot();
131+
// Merge backend + frontend diagnostics into one list sorted newest-first.
132+
// Frontend codes live under a `frontend.*` channel namespace so identity
133+
// collisions with backend codes are impossible — straight concat is safe.
134+
const diagnostics = useMemo(
135+
() =>
136+
[...backendDiagnostics, ...frontendDiagnostics].sort(
137+
(a, b) => b.seenAtEpochMs - a.seenAtEpochMs,
138+
),
139+
[backendDiagnostics, frontendDiagnostics],
140+
);
128141

129142
const normalizedGain = presentStatusValue(identStatus?.gain)?.db ?? null;
130143
const gainLabel =

0 commit comments

Comments
 (0)