Skip to content

Commit aa02366

Browse files
Merge pull request #69 from SpeedCurve-Metrics/hotfix/post-beacon-init
POST beacon and LoAF tweaks
2 parents 06db0f4 + efac265 commit aa02366

File tree

6 files changed

+180
-38
lines changed

6 files changed

+180
-38
lines changed

src/beacon.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type BeaconOptions = {
1919
};
2020

2121
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22-
type CollectorFunction = (config: UserConfig) => any;
22+
export type CollectorFunction = (config: UserConfig) => any;
2323

2424
const sendBeaconFallback = (url: string | URL, data?: BodyInit | null) => {
2525
const xhr = new XMLHttpRequest();

src/lux.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { Beacon, BeaconMetricKey, fitUserTimingEntries, shouldReportValue } from "./beacon";
1+
import {
2+
Beacon,
3+
BeaconMetricKey,
4+
CollectorFunction,
5+
fitUserTimingEntries,
6+
shouldReportValue,
7+
} from "./beacon";
28
import onPageLoad from "./beacon-triggers/page-load";
39
import * as Config from "./config";
410
import { BOOLEAN_TRUE, END_MARK, START_MARK } from "./constants";
@@ -121,17 +127,7 @@ LUX = (function () {
121127
// on the same page.
122128
let _thisCustomerId = LUX.customerid;
123129

124-
const initPostBeacon = () => {
125-
return new Beacon({
126-
config: globalConfig,
127-
logger,
128-
customerId: getCustomerId(),
129-
sessionId: gUid,
130-
pageId: gSyncId,
131-
});
132-
};
133-
134-
let beacon = initPostBeacon();
130+
const beaconCollectors: [BeaconMetricKey, CollectorFunction][] = [];
135131

136132
const logEntry = <T extends PerformanceEntry>(entry: T) => {
137133
logger.logEvent(LogEvent.PerformanceEntryReceived, [entry]);
@@ -157,7 +153,7 @@ LUX = (function () {
157153
LCP.processEntry(entry);
158154
})
159155
) {
160-
beacon.addCollector(BeaconMetricKey.LCP, LCP.getData);
156+
beaconCollectors.push([BeaconMetricKey.LCP, LCP.getData]);
161157
}
162158

163159
if (
@@ -166,7 +162,7 @@ LUX = (function () {
166162
logEntry(entry);
167163
})
168164
) {
169-
beacon.addCollector(BeaconMetricKey.CLS, CLS.getData);
165+
beaconCollectors.push([BeaconMetricKey.CLS, CLS.getData]);
170166
}
171167

172168
if (
@@ -175,7 +171,7 @@ LUX = (function () {
175171
logEntry(entry);
176172
})
177173
) {
178-
beacon.addCollector(BeaconMetricKey.LoAF, LoAF.getData);
174+
beaconCollectors.push([BeaconMetricKey.LoAF, LoAF.getData]);
179175
}
180176

181177
const handleINPEntry = (entry: PerformanceEventTiming) => {
@@ -220,12 +216,30 @@ LUX = (function () {
220216
{ durationThreshold: 0 },
221217
)
222218
) {
223-
beacon.addCollector(BeaconMetricKey.INP, INP.getData);
219+
beaconCollectors.push([BeaconMetricKey.INP, INP.getData]);
224220
}
225221
} catch (e) {
226222
logger.logEvent(LogEvent.PerformanceObserverError, [e]);
227223
}
228224

225+
const initPostBeacon = () => {
226+
const b = new Beacon({
227+
config: globalConfig,
228+
logger,
229+
customerId: getCustomerId(),
230+
sessionId: gUid,
231+
pageId: gSyncId,
232+
});
233+
234+
beaconCollectors.forEach(([metric, collector]) => {
235+
b.addCollector(metric, collector);
236+
});
237+
238+
return b;
239+
};
240+
241+
let beacon = initPostBeacon();
242+
229243
if (_sample()) {
230244
logger.logEvent(LogEvent.SessionIsSampled, [globalConfig.samplerate]);
231245
} else {

src/metric/LoAF.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,17 @@ export function getData(config: UserConfig): LoAFSummary {
7979
totalEntries: entries.length,
8080
totalStyleAndLayoutDuration: floor(totalStyleAndLayoutDuration),
8181
totalWorkDuration: floor(totalWorkDuration),
82-
entries: summarizedEntries.slice(0, config.maxAttributionEntries),
82+
8383
scripts: summarizeLoAFScripts(
8484
entries.flatMap((entry) => entry.scripts),
8585
config,
8686
),
87+
88+
// Only keep the slowest LoAF entries
89+
entries: summarizedEntries
90+
.sort((a, b) => b.duration - a.duration)
91+
.slice(0, config.maxAttributionEntries)
92+
.sort((a, b) => a.startTime - b.startTime),
8793
};
8894
}
8995

tests/integration/post-beacon/cls.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ test.describe("POST beacon CLS", () => {
2222
});
2323

2424
test("CLS is reset between SPA page transitions", async ({ page }) => {
25+
const layoutShiftsSupported = await entryTypeSupported(page, "layout-shift");
2526
const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/");
2627
await page.goto("/layout-shifts.html?noShiftDelay&injectScript=LUX.auto=false;");
2728
await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send()));
2829
let b = luxRequests.get(0)!.postDataJSON() as BeaconPayload;
29-
const layoutShiftsSupported = await entryTypeSupported(page, "layout-shift");
3030

3131
if (layoutShiftsSupported) {
3232
const responseEnd = await getNavigationTimingMs(page, "responseEnd");
@@ -41,6 +41,12 @@ test.describe("POST beacon CLS", () => {
4141
await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send()));
4242

4343
b = luxRequests.get(1)!.postDataJSON() as BeaconPayload;
44-
expect(b.cls).toBeUndefined();
44+
45+
if (layoutShiftsSupported) {
46+
expect(b.cls!.value).toEqual(0);
47+
expect(b.cls!.startTime).toEqual(null);
48+
} else {
49+
expect(b.cls).toBeUndefined();
50+
}
4551
});
4652
});

tests/integration/post-beacon/loaf.spec.ts

Lines changed: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,113 @@ test.describe("POST beacon LoAF", () => {
2525
}
2626
});
2727

28+
test("LoAFs are reset between SPA page transitions", async ({ page }) => {
29+
const loafSupported = await entryTypeSupported(page, "long-animation-frame");
30+
const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/");
31+
await page.goto("/long-animation-frames.html?injectScript=LUX.auto=false;", {
32+
waitUntil: "networkidle",
33+
});
34+
await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send()));
35+
let b = luxRequests.get(0)!.postDataJSON() as BeaconPayload;
36+
37+
// First beacon has LoAFs
38+
if (loafSupported) {
39+
const loaf = b.loaf!;
40+
expect(loaf.totalBlockingDuration).toBeGreaterThan(0);
41+
expect(loaf.totalDuration).toBeGreaterThan(0);
42+
expect(loaf.totalEntries).toBeGreaterThan(0);
43+
expect(loaf.totalStyleAndLayoutDuration).toBeGreaterThan(0);
44+
expect(loaf.totalWorkDuration).toBeGreaterThan(0);
45+
expect(loaf.entries.length).toBeGreaterThan(0);
46+
expect(loaf.scripts.length).toBeGreaterThan(0);
47+
} else {
48+
expect(b.loaf).toBeUndefined();
49+
}
50+
51+
// Second beacon has no LoAFs
52+
await page.evaluate(() => LUX.init());
53+
await page.waitForTimeout(200);
54+
await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send()));
55+
b = luxRequests.get(1)!.postDataJSON() as BeaconPayload;
56+
57+
if (loafSupported) {
58+
const loaf = b.loaf!;
59+
expect(loaf.totalDuration).toEqual(0);
60+
expect(loaf.entries.length).toEqual(0);
61+
expect(loaf.scripts.length).toEqual(0);
62+
} else {
63+
expect(b.loaf).toBeUndefined();
64+
}
65+
66+
// Third beacon has LoAFs again
67+
await page.evaluate(() => LUX.init());
68+
await page.locator("#create-long-task").click();
69+
await page.waitForTimeout(50);
70+
await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send()));
71+
b = luxRequests.get(2)!.postDataJSON() as BeaconPayload;
72+
73+
if (loafSupported) {
74+
const loaf = b.loaf!;
75+
expect(loaf.totalDuration).toBeGreaterThan(0);
76+
expect(loaf.entries.length).toBeGreaterThan(0);
77+
expect(loaf.scripts.length).toBeGreaterThan(0);
78+
} else {
79+
expect(b.loaf).toBeUndefined();
80+
}
81+
});
82+
83+
test("Only the slowest LoAFs are collected", async ({ page }) => {
84+
const MAX_ENTRIES = 3;
85+
const loafSupported = await entryTypeSupported(page, "long-animation-frame");
86+
const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/");
87+
await page.goto(
88+
`/long-animation-frames.html?injectScript=LUX.maxAttributionEntries=${MAX_ENTRIES};`,
89+
{
90+
waitUntil: "networkidle",
91+
},
92+
);
93+
94+
// Create a mixture of short and long LoAFs
95+
// Short
96+
await page.locator("#create-long-task").click();
97+
await page.locator("#create-long-task").click();
98+
99+
// Long
100+
await page.locator("#long-task-duration").fill("100");
101+
await page.locator("#create-long-task").click();
102+
103+
// Short
104+
await page.locator("#long-task-duration").fill("50");
105+
await page.locator("#create-long-task").click();
106+
await page.locator("#create-long-task").click();
107+
108+
// Long
109+
await page.locator("#long-task-duration").fill("150");
110+
await page.locator("#create-long-task").click();
111+
await page.locator("#create-long-task").click();
112+
113+
await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send()));
114+
const b = luxRequests.get(0)!.postDataJSON() as BeaconPayload;
115+
116+
if (loafSupported) {
117+
const loaf = b.loaf!;
118+
119+
expect(loaf.entries.length).toEqual(MAX_ENTRIES);
120+
121+
// The entries should all be the longer LoAFs. Note the total duration is the value from the
122+
// #long-task-duration input, plus a hard-coded 50ms long task in external-long.task.js.
123+
expect(loaf.entries[0].duration).toBeGreaterThanOrEqual(150);
124+
expect(loaf.entries[1].duration).toBeGreaterThanOrEqual(150);
125+
expect(loaf.entries[2].duration).toBeGreaterThanOrEqual(100);
126+
127+
// The entries should be ordered by start time
128+
expect(loaf.entries[0].startTime).toBeLessThanOrEqual(loaf.entries[1].startTime);
129+
expect(loaf.entries[1].startTime).toBeLessThanOrEqual(loaf.entries[2].startTime);
130+
} else {
131+
expect(b.loaf).toBeUndefined();
132+
}
133+
});
134+
28135
test("LoAFs are collected as INP attribution", async ({ page }) => {
29136
const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/");
30137
await page.goto("/long-animation-frames.html", { waitUntil: "networkidle" });
@@ -46,32 +153,37 @@ test.describe("POST beacon LoAF", () => {
46153
if (loafSupported) {
47154
expect(loafScripts.length).toEqual(2);
48155

49-
const external = loafScripts[0];
50-
const externalUrl = new URL(external.sourceUrl);
51-
const [externalStartTime, externalDuration] = external.timings[0];
156+
const documentScript = loafScripts.find((script) =>
157+
script.sourceUrl.endsWith("/long-animation-frames.html"),
158+
)!;
159+
const externalScript = loafScripts.find((script) =>
160+
script.sourceUrl.endsWith("/external-long-task.js"),
161+
)!;
162+
163+
const externalUrl = new URL(externalScript.sourceUrl);
164+
const [externalStartTime, externalDuration] = externalScript.timings[0];
52165
expect(externalUrl.pathname).toEqual("/external-long-task.js");
53166
// Invoker has been removed to try and reduce the number of LoAF entries
54167
// expect(external.invoker).toEqual(external.sourceUrl);
55-
expect(external.invoker).toEqual("");
56-
expect(external.sourceFunctionName).toEqual("");
57-
expect(external.totalEntries).toEqual(1);
58-
expect(external.totalDuration).toBeBetween(49, 59);
168+
expect(externalScript.invoker).toEqual("");
169+
expect(externalScript.sourceFunctionName).toEqual("");
170+
expect(externalScript.totalEntries).toEqual(1);
171+
expect(externalScript.totalDuration).toBeBetween(49, 59);
59172
expect(externalStartTime).toBeGreaterThanOrEqual(inp.startTime);
60173
expect(externalDuration).toBeBetween(49, 59);
61174

62-
const onload = loafScripts[1];
63-
const onloadUrl = new URL(onload.sourceUrl);
64-
const [onloadStartTime, onloadDuration] = onload.timings[0];
175+
const documentUrl = new URL(documentScript.sourceUrl);
176+
const [documentStartTime, documentDuration] = documentScript.timings[0];
65177

66-
expect(onloadUrl.pathname).toEqual("/long-animation-frames.html");
178+
expect(documentUrl.pathname).toEqual("/long-animation-frames.html");
67179
// Invoker has been removed to try and reduce the number of LoAF entries
68180
// expect(onload.invoker).toEqual("SCRIPT[src=external-long-task.js].onload");
69-
expect(onload.invoker).toEqual("");
70-
expect(onload.sourceFunctionName).toEqual("");
71-
expect(onload.totalEntries).toEqual(1);
72-
expect(onload.totalDuration).toBeBetween(49, 59);
73-
expect(onloadStartTime).toBeGreaterThanOrEqual(inp.startTime);
74-
expect(onloadDuration).toBeBetween(49, 59);
181+
expect(documentScript.invoker).toEqual("");
182+
expect(documentScript.sourceFunctionName).toEqual("");
183+
expect(documentScript.totalEntries).toEqual(1);
184+
expect(documentScript.totalDuration).toBeBetween(49, 59);
185+
expect(documentStartTime).toBeGreaterThanOrEqual(inp.startTime);
186+
expect(documentDuration).toBeBetween(49, 59);
75187
} else {
76188
expect(loafScripts.length).toEqual(0);
77189
}

tests/test-pages/long-animation-frames.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ <h1>LUX long animation frames test page</h1>
1313

1414
<img src="eve.jpg" elementtiming="eve-image">
1515

16+
<input type="text" id="long-task-duration" value="50">
1617
<button id="create-long-task" type="button">Make a delayed paint</button>
1718

1819
<div style="margin-top: 2000px;">
@@ -21,12 +22,15 @@ <h1>LUX long animation frames test page</h1>
2122

2223
<script src="app.js"></script>
2324
<script>
25+
const durationInput = document.getElementById("long-task-duration");
2426
document.getElementById("create-long-task").addEventListener("click", globalClickHandler);
2527

2628
function globalClickHandler() {
29+
const duration = Number(durationInput.value) || 50;
2730
const script = document.createElement("script");
2831
script.src = "external-long-task.js";
29-
script.onload = () => externalLongTask(50);
32+
script.onload = () => externalLongTask(duration);
33+
3034
document.body.appendChild(script);
3135

3236
const firstImage = document.querySelector("img");

0 commit comments

Comments
 (0)