Skip to content

Commit e9d476a

Browse files
authored
Merge pull request #7946 from Couchers-org/frontend/feature/metrics-infra
Implement frontend metrics infra
2 parents baef233 + cdb96be commit e9d476a

11 files changed

Lines changed: 2329 additions & 12 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
/**
2+
* Tests for the event collector.
3+
*
4+
* We reset module state before each test to avoid state leaking between tests.
5+
*/
6+
import {
7+
_resetCollectorState,
8+
destroyCollector,
9+
logEvent,
10+
} from "./eventCollector";
11+
12+
const mockReportDiagnostics = jest.fn();
13+
14+
jest.mock("service/diagnostics", () => ({
15+
reportDiagnostics: (...args: unknown[]) => mockReportDiagnostics(...args),
16+
}));
17+
18+
beforeEach(() => {
19+
jest.useFakeTimers();
20+
mockReportDiagnostics.mockReset();
21+
mockReportDiagnostics.mockResolvedValue(undefined);
22+
global.fetch = jest.fn().mockResolvedValue({ ok: true });
23+
_resetCollectorState();
24+
});
25+
26+
afterEach(() => {
27+
destroyCollector();
28+
jest.useRealTimers();
29+
});
30+
31+
describe("event-collector", () => {
32+
describe("initial flush delay", () => {
33+
it("batches initial events with a 150ms delay instead of flushing immediately", () => {
34+
logEvent("session.started", { landing_page: "/" });
35+
logEvent("page.viewed", { path: "/" });
36+
37+
expect(mockReportDiagnostics).not.toHaveBeenCalled();
38+
39+
jest.advanceTimersByTime(150);
40+
41+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(1);
42+
const events = mockReportDiagnostics.mock.calls[0][0];
43+
expect(events).toHaveLength(2);
44+
expect(events[0].tag).toBe("session.started");
45+
expect(events[1].tag).toBe("page.viewed");
46+
});
47+
48+
it("does not start a second timer if events arrive within the initial delay", () => {
49+
logEvent("session.started");
50+
51+
jest.advanceTimersByTime(50);
52+
logEvent("page.viewed", { path: "/" });
53+
54+
jest.advanceTimersByTime(50);
55+
logEvent("some.other.event");
56+
57+
expect(mockReportDiagnostics).not.toHaveBeenCalled();
58+
59+
jest.advanceTimersByTime(50);
60+
61+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(1);
62+
expect(mockReportDiagnostics.mock.calls[0][0]).toHaveLength(3);
63+
});
64+
});
65+
66+
describe("normal flush scheduling", () => {
67+
it("schedules subsequent events on a 3s interval after the first flush", () => {
68+
logEvent("session.started");
69+
jest.advanceTimersByTime(150);
70+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(1);
71+
72+
logEvent("button.clicked", { id: "cta" });
73+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(1);
74+
75+
jest.advanceTimersByTime(3000);
76+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(2);
77+
expect(mockReportDiagnostics.mock.calls[1][0][0].tag).toBe(
78+
"button.clicked",
79+
);
80+
});
81+
});
82+
83+
describe("event properties and value", () => {
84+
it("serialises properties as JSON and passes through the value", () => {
85+
logEvent("search.performed", { query: "paris", filters: 3 }, 42);
86+
jest.advanceTimersByTime(150);
87+
88+
const event = mockReportDiagnostics.mock.calls[0][0][0];
89+
expect(event.tag).toBe("search.performed");
90+
expect(JSON.parse(event.propertiesJson)).toEqual({
91+
query: "paris",
92+
filters: 3,
93+
});
94+
expect(event.value).toBe(42);
95+
expect(event.occurred).toBeInstanceOf(Date);
96+
});
97+
98+
it("uses default empty properties and value=1 when not provided", () => {
99+
logEvent("page.viewed");
100+
jest.advanceTimersByTime(150);
101+
102+
const event = mockReportDiagnostics.mock.calls[0][0][0];
103+
expect(JSON.parse(event.propertiesJson)).toEqual({});
104+
expect(event.value).toBe(1);
105+
});
106+
});
107+
108+
describe("retry backoff (item 8)", () => {
109+
it("re-queues events and retries with exponential backoff on failure", async () => {
110+
mockReportDiagnostics.mockRejectedValueOnce(new Error("network error"));
111+
112+
logEvent("session.started");
113+
114+
// Initial flush at 150ms
115+
jest.advanceTimersByTime(150);
116+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(1);
117+
118+
// Flush the rejected promise
119+
await Promise.resolve();
120+
await Promise.resolve();
121+
122+
// After 1 failure: backoff = 3000 * 2^1 = 6000ms
123+
jest.advanceTimersByTime(5999);
124+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(1);
125+
126+
jest.advanceTimersByTime(1);
127+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(2);
128+
129+
// The same event should have been retried
130+
expect(mockReportDiagnostics.mock.calls[1][0][0].tag).toBe(
131+
"session.started",
132+
);
133+
});
134+
135+
it("resets backoff to 0 on success", async () => {
136+
mockReportDiagnostics
137+
.mockRejectedValueOnce(new Error("fail"))
138+
.mockResolvedValueOnce(undefined);
139+
140+
logEvent("event.one");
141+
jest.advanceTimersByTime(150);
142+
await Promise.resolve();
143+
await Promise.resolve();
144+
145+
// First retry at 6s backoff
146+
jest.advanceTimersByTime(6000);
147+
await Promise.resolve();
148+
await Promise.resolve();
149+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(2);
150+
151+
// Now log a new event — should use normal 3s interval (no backoff)
152+
logEvent("event.two");
153+
jest.advanceTimersByTime(3000);
154+
expect(mockReportDiagnostics).toHaveBeenCalledTimes(3);
155+
});
156+
157+
it("caps backoff at 60 seconds", async () => {
158+
mockReportDiagnostics.mockRejectedValue(new Error("fail"));
159+
160+
logEvent("event.one");
161+
jest.advanceTimersByTime(150);
162+
163+
for (let i = 0; i < 10; i++) {
164+
await Promise.resolve();
165+
await Promise.resolve();
166+
jest.advanceTimersByTime(60_000);
167+
}
168+
169+
// Retries happened at the 60s cap, not at 3000 * 2^10 = 3,072,000ms
170+
const callCount = mockReportDiagnostics.mock.calls.length;
171+
expect(callCount).toBeGreaterThan(2);
172+
});
173+
});
174+
175+
describe("beacon flush on unload (item 1)", () => {
176+
it("uses fetch with keepalive on visibilitychange hidden", () => {
177+
logEvent("page.viewed", { path: "/about" });
178+
179+
Object.defineProperty(document, "visibilityState", {
180+
value: "hidden",
181+
writable: true,
182+
configurable: true,
183+
});
184+
document.dispatchEvent(new Event("visibilitychange"));
185+
186+
expect(global.fetch).toHaveBeenCalledTimes(1);
187+
const [url, options] = (global.fetch as jest.Mock).mock.calls[0];
188+
expect(url).toContain("/org.couchers.bugs.Bugs/ReportDiagnostics");
189+
expect(options.method).toBe("POST");
190+
expect(options.keepalive).toBe(true);
191+
expect(options.credentials).toBe("include");
192+
expect(options.headers["Content-Type"]).toBe("application/json");
193+
194+
const body = JSON.parse(options.body);
195+
expect(body.infos).toHaveLength(1);
196+
expect(body.infos[0].tag).toBe("page.viewed");
197+
expect(typeof body.infos[0].occurred).toBe("string");
198+
expect(() => new Date(body.infos[0].occurred)).not.toThrow();
199+
200+
Object.defineProperty(document, "visibilityState", {
201+
value: "visible",
202+
writable: true,
203+
configurable: true,
204+
});
205+
});
206+
207+
it("uses fetch with keepalive on beforeunload", () => {
208+
logEvent("session.ended");
209+
210+
window.dispatchEvent(new Event("beforeunload"));
211+
212+
expect(global.fetch).toHaveBeenCalledTimes(1);
213+
const [, options] = (global.fetch as jest.Mock).mock.calls[0];
214+
expect(options.keepalive).toBe(true);
215+
});
216+
217+
it("uses beacon flush in destroy()", () => {
218+
logEvent("final.event");
219+
destroyCollector();
220+
221+
expect(global.fetch).toHaveBeenCalledTimes(1);
222+
const [, options] = (global.fetch as jest.Mock).mock.calls[0];
223+
expect(options.keepalive).toBe(true);
224+
});
225+
226+
it("does not beacon if queue is empty", () => {
227+
destroyCollector();
228+
expect(global.fetch).not.toHaveBeenCalled();
229+
});
230+
231+
it("re-queues events if fetch throws synchronously", () => {
232+
(global.fetch as jest.Mock).mockImplementation(() => {
233+
throw new Error("fetch unavailable");
234+
});
235+
236+
logEvent("important.event");
237+
238+
// Beacon via beforeunload — fetch throws, events re-queued
239+
window.dispatchEvent(new Event("beforeunload"));
240+
241+
// Reset fetch so destroy's beacon works
242+
(global.fetch as jest.Mock).mockResolvedValue({ ok: true });
243+
destroyCollector();
244+
245+
// The event should have been sent on destroy after the re-queue
246+
const lastCall = (global.fetch as jest.Mock).mock.calls.at(-1);
247+
const body = JSON.parse(lastCall[1].body);
248+
expect(
249+
body.infos.some(
250+
(info: { tag: string }) => info.tag === "important.event",
251+
),
252+
).toBe(true);
253+
});
254+
});
255+
256+
describe("destroy", () => {
257+
it("ignores events logged after destroy", () => {
258+
destroyCollector();
259+
logEvent("should.be.ignored");
260+
261+
jest.advanceTimersByTime(10_000);
262+
expect(mockReportDiagnostics).not.toHaveBeenCalled();
263+
});
264+
265+
it("removes event listeners on destroy", () => {
266+
const removeSpy = jest.spyOn(document, "removeEventListener");
267+
const removeWindowSpy = jest.spyOn(window, "removeEventListener");
268+
269+
destroyCollector();
270+
271+
expect(removeSpy).toHaveBeenCalledWith(
272+
"visibilitychange",
273+
expect.any(Function),
274+
);
275+
expect(removeWindowSpy).toHaveBeenCalledWith(
276+
"beforeunload",
277+
expect.any(Function),
278+
);
279+
280+
removeSpy.mockRestore();
281+
removeWindowSpy.mockRestore();
282+
});
283+
});
284+
});

0 commit comments

Comments
 (0)