From 7137336f7bb0c72db1eb0019306a7e0d58b64866 Mon Sep 17 00:00:00 2001 From: Joseph Wynn Date: Tue, 2 Sep 2025 13:25:02 +1200 Subject: [PATCH 1/6] Type check tests --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index de84c4e..c6a6211 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "strictNullChecks": true, "strictPropertyInitialization": true }, - "include": ["src/**/*", "docs/**/*"], + "include": ["src/**/*", "tests/**/*", "docs/**/*"], } From 64e966d09582c1b60fc13ea1edfe02c879fe29a3 Mon Sep 17 00:00:00 2001 From: Joseph Wynn Date: Tue, 2 Sep 2025 13:27:11 +1200 Subject: [PATCH 2/6] Build multiple targets for ES5, ES2015, and ES2020 --- package-lock.json | 22 ++++++++ package.json | 4 ++ rollup.config.mjs | 129 ++++++++++++++++++++++++++++++++-------------- tsconfig.json | 3 +- 4 files changed, 119 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 287dbac..8730605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-typescript": "^7.21.5", "@playwright/test": "^1.32.3", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-typescript": "^12.1.4", @@ -2516,6 +2517,27 @@ "node": ">=18" } }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-replace": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz", diff --git a/package.json b/package.json index f635575..f67d837 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "@speedcurve/lux", "version": "4.2.1", + "config": { + "snippetVersion": "2.0.0" + }, "main": "dist/lux.js", "scripts": { "build": "npm run rollup", @@ -24,6 +27,7 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-typescript": "^7.21.5", "@playwright/test": "^1.32.3", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-typescript": "^12.1.4", diff --git a/rollup.config.mjs b/rollup.config.mjs index a755de5..f881515 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,63 +1,115 @@ +import json from "@rollup/plugin-json"; import replace from "@rollup/plugin-replace"; import terser from "@rollup/plugin-terser"; import typescript from "@rollup/plugin-typescript"; +import pkg from "./package.json" with { type: "json" }; -const outputConfig = (file, polyfills, minified) => ({ +const commonPlugins = (target = "es5") => [ + json(), + replace({ + __ENABLE_POLYFILLS: JSON.stringify(target === "es5"), + }), + typescript({ + include: ["src/**"], + compilerOptions: { + target, + }, + }), +]; + +const scriptOutput = (file, minified) => ({ file, format: "iife", - plugins: [ - replace({ - __ENABLE_POLYFILLS: JSON.stringify(polyfills), - }), - minified ? terser() : undefined, - ], + plugins: [minified ? terser() : undefined], sourcemap: true, }); +const snippetOutput = (file, target) => { + const versionString = `${pkg.config.snippetVersion}${target === "es5" ? "" : `-${target}`}`; + const preamble = `/* SpeedCurve RUM Snippet v${versionString} */`; + + return { + file: file, + name: "LUX", + format: "iife", + strict: false, + plugins: [ + terser({ + format: { + preamble: preamble, + }, + }), + + /** + * This is a bit of a hack to ensure that the named export is always in the global scope. + * Rollup formats the export as `var [name] = function() { [default export] }()`, which + * is fine when the snippet is placed in the global scope. However our customers may either + * purposefully or inadvertently place the snippet in a scoped block that results in the + * `LUX` variable not being declared in the global scope. To ensure `LUX` is always in the + * global scope, we use the replace plugin to remove the `var` from the minified snippet. + */ + replace({ + delimiters: ["", ""], + values: { + "var LUX=": "LUX=", + [`${preamble}\n`]: preamble, + __SNIPPET_VERSION: JSON.stringify(versionString), + }, + }), + ], + }; +}; + export default [ + // lux.js script (compat) + { + input: "src/lux.ts", + output: [scriptOutput("dist/lux.js", false), scriptOutput("dist/lux.min.js", true)], + plugins: commonPlugins(), + }, + + // lux.js script (ES2015) { input: "src/lux.ts", output: [ - outputConfig("dist/lux.js", true, false), - outputConfig("dist/lux.min.js", true, true), - outputConfig("dist/lux-no-polyfills.js", false, false), - outputConfig("dist/lux-no-polyfills.min.js", false, true), + scriptOutput("dist/lux.es2015.js", false), + scriptOutput("dist/lux.es2015.min.js", true), ], - plugins: [ - typescript({ - include: "src/**", - }), + plugins: commonPlugins("es2015"), + }, + + // lux.js script (ES2020) + { + input: "src/lux.ts", + output: [ + scriptOutput("dist/lux.es2020.js", false), + scriptOutput("dist/lux.es2020.min.js", true), ], + plugins: commonPlugins("es2020"), }, + // Inline snippet (compat) { input: "src/snippet.ts", - output: { - file: "dist/lux-snippet.js", - name: "LUX", - format: "iife", - strict: false, - plugins: [ - terser(), + output: [snippetOutput("dist/lux-snippet.js")], + plugins: commonPlugins(), + }, - /** - * This is a bit of a hack to ensure that the named export is always in the global scope. - * Rollup formats the export as `var [name] = function() { [default export] }()`, which - * is fine when the snippet is placed in the global scope. However our customers may either - * purposefully or inadvertently place the snippet in a scoped block that results in the - * `LUX` variable not being declared in the global scope. To ensure `LUX` is always in the - * global scope, we use the replace plugin to remove the `var` from the minified snippet. - */ - replace({ "var LUX=": "LUX=" }), - ], - }, - plugins: [ - typescript({ - include: ["src/**"], - }), - ], + // Inline snippet (ES2015) + { + input: "src/snippet.ts", + output: [snippetOutput("dist/lux-snippet.es2015.js", "es2015")], + plugins: commonPlugins("es2015"), + }, + + // Inline snippet (ES2020) + { + input: "src/snippet.ts", + output: [snippetOutput("dist/lux-snippet.es2020.js", "es2020")], + plugins: commonPlugins("es2020"), }, + // Debug parser { input: "docs/debug-parser/index.ts", output: { @@ -66,6 +118,7 @@ export default [ plugins: [terser()], }, plugins: [ + json(), typescript({ include: ["docs/**", "src/**", "tests/helpers/lux.ts"], declaration: false, diff --git a/tsconfig.json b/tsconfig.json index c6a6211..38927ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2022", + "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "skipLibCheck": true, "noEmit": true, @@ -16,6 +16,7 @@ "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, + "resolveJsonModule": true, "strictBindCallApply": true, "strictFunctionTypes": true, "strictNullChecks": true, From e7a55207020cb92c8faaf952dacdceff79f66612 Mon Sep 17 00:00:00 2001 From: Joseph Wynn Date: Tue, 2 Sep 2025 13:28:01 +1200 Subject: [PATCH 3/6] Remove old docs --- docs/examples.md | 0 docs/usage-changes.md | 158 ------------------------------------------ 2 files changed, 158 deletions(-) delete mode 100644 docs/examples.md delete mode 100644 docs/usage-changes.md diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/usage-changes.md b/docs/usage-changes.md deleted file mode 100644 index eb95d4d..0000000 --- a/docs/usage-changes.md +++ /dev/null @@ -1,158 +0,0 @@ -# LUX "v2" usage changes - -## General API changes - -### New method: `LUX.configure` - -It's common for LUX configuration to be specified all at once. This method aims to make the API for configuring LUX more consistent. For example, this mixture of setting properties and calling methods... - -```js -LUX.auto = false; -LUX.label = "Home"; -LUX.addData("tvMode", 1); -LUX.addData("env", "prod"); -``` - -... Could instead be written as one configuration object: - -```js -LUX.configure({ - auto: false, - label: "Home", - data: { - tvMode: 1, - env: "prod", - }, -}); -``` - -### New parameter: `LUX.send(configuration)` - -It's common for things like page labels and customer data to be specified at the same place that `LUX.send` is called. An optional object parameter to `LUX.send` would be the equivalent to calling `LUX.configure(options)` immediately followed by `LUX.send()`. - -```js -LUX.send({ - label: "Home", - data: { - tvMode: 1, - env: "prod", - }, -}); -``` - -### New property: `LUX.measureUntil = ` - -Controls when the beacon is sent in "auto" mode. Possible values are: - -* `pagehidden` - wait until the page's `visibilityState` is `hidden`, with `pagehide` and `unload` fallbacks - -Future versions of LUX might also support `networkidle`, which would employ heuristics to send the beacon when all network requests have completed; `cpuidle`, which would wait until there are no more long tasks; and `idle` which is a combination of both. - -### New property: `LUX.maxTimeAfterOnload = ` - -Controls the maximum time to wait after the onload event before the beacon is sent. Default value is `10000` (10 seconds). Can be set to `0` to wait indefinitely (not recommended). - -### New method: `LUX.markLoadTime()` - -Marks the "onload" time for SPA page views. Enables an accurate onload time to be recorded without sending the beacon too early. - -## "Normal" (non-SPA) usage - -### Current usage in "auto" mode - -No changes. By default LUX will continue to measure until onload. - -### Send the beacon automatically after measuring for as long as possible - -This is a new "mode" for LUX where we continue collecting data. Metrics like LCP, CLS, and long tasks will be affected by this. - -```js -LUX.measureUntil = "pagehidden"; -``` - -## SPA-specific changes - -### Current usage - -No changes. `LUX.init` and `LUX.send` will continue to work as they do now. - -```js -LUX.auto = false; - -// Manually send the beacon as soon as the page has loaded -MyApp.onPageLoaded(() => { - LUX.send(); -}); - -// Manually reset LUX at the point where the user initiates a navigation -MyApp.onUserNavigation(() => { - LUX.init(); - loadNextPage(); -}); -``` - -### Proposal #1 - -### Mark the "onload" time without sending the beacon, then send the beacon as late as possible - -This is similar to the current usage, however the onload time measurement is split from the beacon sending. This allows for data to be collected for as long as possible. Metrics like LCP and long tasks will be affected by this. - -```js -LUX.auto = false; - -// Automatically send the beacon when the page is hidden -LUX.measureUntil = "pagehidden"; - -// Manually mark when the page is loaded -onPageLoaded(() => { - LUX.markLoadTime(); -}); - -// Manually send the beacon and reset LUX at the point where the user initiates a navigation -onUserNavigation(() => { - LUX.send(); - LUX.init(); - loadNextPage(); -}); -``` - -### Mark the "onload" time without sending the beacon, send the beacon as late as possible, and automatically send the beacon - -This is similar to the current usage, however the onload time measurement is split from the beacon sending. This allows for data to be collected for as long as possible. Metrics like LCP and long tasks will be affected by this. - -```js -// Measure until the page is hidden, up to a maximum of 10 seconds after onload -LUX.measureUntil = "pagehidden"; -LUX.maxTimeAfterOnload = 10000; - -LUX.auto = false; - -// Measure until the network is idle for 5 seconds -LUX.measureUntil = "networkidle"; -LUX.idleTime = 5000; - -// Measure until long tasks are idle for 5 seconds -// What would we do for browsers that don't support long tasks? -LUX.measureUntil = "cpuidle"; -LUX.idleTime = 5000; - -// Measure until everything (CPU & network) is idle for 5 seconds -LUX.measureUntil = "idle"; -LUX.idleTime = 5000; - - - -LUX.auto = false; - -// Manually mark when the page is loaded -onPageLoaded(() => { - LUX.markLoadTime(); -}); - -// Manually send the beacon and reset LUX at the point where the user initiates a navigation -onUserNavigation(() => { - LUX.send(); - LUX.init(); - loadNextPage(); -}); -``` From 0627301ab05adec8b4dfbc31e6be2a620e3e0aef Mon Sep 17 00:00:00 2001 From: Joseph Wynn Date: Tue, 2 Sep 2025 13:49:12 +1200 Subject: [PATCH 4/6] Add snippet version to the beacons --- rollup.config.mjs | 2 +- src/beacon.ts | 4 ++++ src/config.ts | 2 ++ src/global.ts | 1 + src/lux.ts | 5 +++++ src/snippet.ts | 1 + src/version.ts | 4 +++- src/window.d.ts | 1 + 8 files changed, 18 insertions(+), 2 deletions(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index f881515..077d7e1 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -24,7 +24,7 @@ const scriptOutput = (file, minified) => ({ sourcemap: true, }); -const snippetOutput = (file, target) => { +const snippetOutput = (file, target = "es5") => { const versionString = `${pkg.config.snippetVersion}${target === "es5" ? "" : `-${target}`}`; const preamble = `/* SpeedCurve RUM Snippet v${versionString} */`; diff --git a/src/beacon.ts b/src/beacon.ts index 771a407..acd99e6 100644 --- a/src/beacon.ts +++ b/src/beacon.ts @@ -204,6 +204,7 @@ export class Beacon { collectionDuration: now() - collectionStart, pageId: this.pageId, scriptVersion: VERSION, + snippetVersion: this.config.snippetVersion, sessionId: this.sessionId, startTime: this.startTime, }, @@ -239,6 +240,9 @@ export type BeaconMetaData = { /** The lux.js version that sent the beacon */ scriptVersion: string; + /** The lux.js snippet version that sent the beacon */ + snippetVersion?: string; + /** How long in milliseconds did this beacon capture page data for */ measureDuration: number; diff --git a/src/config.ts b/src/config.ts index 05837a3..5d02e9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { LuxGlobal } from "./global"; import { ServerTimingConfig } from "./server-timing"; import { UrlPatternMapping } from "./url-matcher"; @@ -26,6 +27,7 @@ export interface ConfigObject { samplerate: number; sendBeaconOnPageHidden: boolean; serverTiming?: ServerTimingConfig; + snippetVersion?: LuxGlobal["snippetVersion"]; trackErrors: boolean; trackHiddenPages: boolean; } diff --git a/src/global.ts b/src/global.ts index bfeec76..e47dff8 100644 --- a/src/global.ts +++ b/src/global.ts @@ -27,5 +27,6 @@ export interface LuxGlobal extends UserConfig { /** Timestamp representing when the LUX snippet was evaluated */ ns?: number; send: () => void; + snippetVersion?: string; version?: string; } diff --git a/src/lux.ts b/src/lux.ts index 045bbbd..d703d93 100644 --- a/src/lux.ts +++ b/src/lux.ts @@ -1435,6 +1435,10 @@ LUX = (function () { queryParams.push("fl=" + gFlags); } + if (LUX.snippetVersion) { + queryParams.push("sv=" + LUX.snippetVersion); + } + const customDataValues = CustomData.valuesToString(customData); if (customDataValues) { @@ -2065,6 +2069,7 @@ LUX = (function () { // Public properties globalLux.version = VERSION; + globalLux.snippetVersion = LUX.snippetVersion; /** * Run a command from the command queue diff --git a/src/snippet.ts b/src/snippet.ts index ad97ffb..fed910c 100644 --- a/src/snippet.ts +++ b/src/snippet.ts @@ -22,6 +22,7 @@ LUX.markLoadTime = () => LUX.cmd(["markLoadTime", msSinceNavigationStart()]); LUX.measure = _measure; LUX.on = (event, callback) => LUX.cmd(["on", event, callback]); LUX.send = () => LUX.cmd(["send"]); +LUX.snippetVersion = __SNIPPET_VERSION; LUX.ns = scriptStartTime; export default LUX; diff --git a/src/version.ts b/src/version.ts index b709449..0b0e095 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,6 +1,8 @@ +import { version as pkgVersion, config as pkgConfig } from "../package.json"; import { padStart } from "./string"; -export const VERSION = "4.2.1"; +export const VERSION = pkgVersion; +export const SNIPPET_VERSION = pkgConfig.snippetVersion; /** * Returns the version of the script as a float to be stored in legacy systems that do not support diff --git a/src/window.d.ts b/src/window.d.ts index f94e36f..95aa475 100644 --- a/src/window.d.ts +++ b/src/window.d.ts @@ -2,6 +2,7 @@ import { LuxGlobal } from "./global"; declare global { declare const __ENABLE_POLYFILLS: boolean; + declare const __SNIPPET_VERSION: string; // LUX globals interface Window { From 652c2a55d69bcb5d082e7934e4a738a6e2a3303c Mon Sep 17 00:00:00 2001 From: Joseph Wynn Date: Tue, 2 Sep 2025 15:15:24 +1200 Subject: [PATCH 5/6] Implement `LUX.spaMode` --- docs/debug-parser/events.ts | 15 +- docs/spa-mode.md | 7 + src/beacon.ts | 11 +- src/config.ts | 13 +- src/{global.ts => global.d.ts} | 29 ++- src/logger.ts | 6 +- src/lux.ts | 139 +++++++++---- src/snippet.ts | 6 +- src/window.d.ts | 6 +- tests/helpers/browsers.ts | 19 +- tests/helpers/lux.ts | 9 +- tests/helpers/shared-tests.ts | 18 ++ tests/helpers/types.d.ts | 1 + tests/integration/bf-cache.spec.ts | 2 +- tests/integration/default-metrics.spec.ts | 6 +- tests/integration/events.spec.ts | 28 ++- tests/integration/page-label.spec.ts | 32 +-- .../post-beacon/beacon-request.spec.ts | 47 +++-- tests/integration/snippet.spec.ts | 44 +++- tests/integration/spa-metrics.spec.ts | 35 +++- tests/integration/spa-mode.spec.ts | 195 ++++++++++++++++++ tests/playwright.d.ts | 4 + tests/request-matcher.ts | 4 +- tests/server.mjs | 66 +++--- tests/test-pages/preact.js | 3 + tests/test-pages/spa.html | 167 +++++++++++++++ tests/unit/CLS.test.ts | 6 + tests/unit/INP.test.ts | 3 +- tests/unit/config.test.ts | 26 +++ 29 files changed, 776 insertions(+), 171 deletions(-) create mode 100644 docs/spa-mode.md rename src/{global.ts => global.d.ts} (63%) create mode 100644 tests/helpers/types.d.ts create mode 100644 tests/integration/spa-mode.spec.ts create mode 100644 tests/test-pages/preact.js create mode 100644 tests/test-pages/spa.html diff --git a/docs/debug-parser/events.ts b/docs/debug-parser/events.ts index ae889c8..c58a82c 100644 --- a/docs/debug-parser/events.ts +++ b/docs/debug-parser/events.ts @@ -154,9 +154,6 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return "POST beacon send failed."; - case LogEvent.PostBeaconAlreadySent: - return "POST beacon cancelled (already sent)."; - case LogEvent.PostBeaconCancelled: return "POST beacon cancelled."; @@ -272,6 +269,14 @@ function getEventName(event: LogEvent) { return "MarkLoadTimeCalled"; case LogEvent.SendCancelledPageHidden: return "SendCancelledPageHidden"; + case LogEvent.TriggerSoftNavigationCalled: + return "TriggerSoftNavigationCalled"; + case LogEvent.SendTriggeredBySoftNavigation: + return "SendTriggeredBySoftNavigation"; + case LogEvent.SendCancelledSpaMode: + return "SendCancelledSpaMode"; + case LogEvent.BfCacheRestore: + return "BfCacheRestore"; case LogEvent.SessionIsSampled: return "SessionIsSampled"; case LogEvent.SessionIsNotSampled: @@ -318,8 +323,6 @@ function getEventName(event: LogEvent) { return "PostBeaconTimeoutReached"; case LogEvent.PostBeaconSent: return "PostBeaconSent"; - case LogEvent.PostBeaconAlreadySent: - return "PostBeaconAlreadySent"; case LogEvent.PostBeaconCancelled: return "PostBeaconCancelled"; case LogEvent.PostBeaconStopRecording: @@ -333,4 +336,6 @@ function getEventName(event: LogEvent) { case LogEvent.PostBeaconCollector: return "PostBeaconCollector"; } + + return "Unknown Event"; } diff --git a/docs/spa-mode.md b/docs/spa-mode.md new file mode 100644 index 0000000..84394d7 --- /dev/null +++ b/docs/spa-mode.md @@ -0,0 +1,7 @@ +# SPA Mode in lux.js + +## Migrating from `LUX.auto = false` + +- Remove `LUX.auto = false`. +- Remove all `LUX.send()` calls. In SPA mode, the beacon is sent automatically. In some very rare cases you may want to send the beacon manually, which can be done by calling `LUX.send(true)` - **this is not recommended**. +- Replace all `LUX.init()` calls with `LUX.startSoftNavigation()`. diff --git a/src/beacon.ts b/src/beacon.ts index acd99e6..0cbe9ba 100644 --- a/src/beacon.ts +++ b/src/beacon.ts @@ -1,5 +1,6 @@ import { ConfigObject, UserConfig } from "./config"; import { wasPrerendered } from "./document"; +import * as Events from "./events"; import Flags, { addFlag } from "./flags"; import { addListener } from "./listeners"; import Logger, { LogEvent } from "./logger"; @@ -160,6 +161,10 @@ export class Beacon { } send() { + if (this.isSent) { + return; + } + this.logger.logEvent(LogEvent.PostBeaconSendCalled); for (const cb of this.onBeforeSendCbs) { @@ -187,11 +192,6 @@ export class Beacon { return; } - if (this.isSent) { - this.logger.logEvent(LogEvent.PostBeaconAlreadySent); - return; - } - // Only clear the max measure timeout if there's data to send. clearTimeout(this.maxMeasureTimeout); @@ -215,6 +215,7 @@ export class Beacon { if (sendBeacon(beaconUrl, JSON.stringify(payload))) { this.isSent = true; this.logger.logEvent(LogEvent.PostBeaconSent, [beaconUrl, payload]); + Events.emit("beacon", payload); } } catch (e) { // Intentionally empty; handled below diff --git a/src/config.ts b/src/config.ts index 5d02e9e..9f48461 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,10 @@ import { LuxGlobal } from "./global"; import { ServerTimingConfig } from "./server-timing"; import { UrlPatternMapping } from "./url-matcher"; +/** + * ConfigObject holds the parsed and normalised lux.js configuration. It is initialised once based + * on the `LUX` global. + */ export interface ConfigObject { allowEmptyPostBeacon: boolean; auto: boolean; @@ -28,6 +32,7 @@ export interface ConfigObject { sendBeaconOnPageHidden: boolean; serverTiming?: ServerTimingConfig; snippetVersion?: LuxGlobal["snippetVersion"]; + spaMode: boolean; trackErrors: boolean; trackHiddenPages: boolean; } @@ -37,7 +42,8 @@ export type UserConfig = Partial; const luxOrigin = "https://lux.speedcurve.com"; export function fromObject(obj: UserConfig): ConfigObject { - const autoMode = getProperty(obj, "auto", true); + const spaMode = getProperty(obj, "spaMode", false); + const autoMode = spaMode ? false : getProperty(obj, "auto", true); return { allowEmptyPostBeacon: getProperty(obj, "allowEmptyPostBeacon", false), @@ -57,13 +63,14 @@ export function fromObject(obj: UserConfig): ConfigObject { maxBeaconUTEntries: getProperty(obj, "maxBeaconUTEntries", 20), maxErrors: getProperty(obj, "maxErrors", 5), maxMeasureTime: getProperty(obj, "maxMeasureTime", 60_000), - measureUntil: getProperty(obj, "measureUntil", "onload"), + measureUntil: getProperty(obj, "measureUntil", spaMode ? "pagehidden" : "onload"), minMeasureTime: getProperty(obj, "minMeasureTime", 0), newBeaconOnPageShow: getProperty(obj, "newBeaconOnPageShow", false), pagegroups: getProperty(obj, "pagegroups"), samplerate: getProperty(obj, "samplerate", 100), - sendBeaconOnPageHidden: getProperty(obj, "sendBeaconOnPageHidden", autoMode), + sendBeaconOnPageHidden: getProperty(obj, "sendBeaconOnPageHidden", spaMode || autoMode), serverTiming: getProperty(obj, "serverTiming"), + spaMode, trackErrors: getProperty(obj, "trackErrors", true), trackHiddenPages: getProperty(obj, "trackHiddenPages", false), }; diff --git a/src/global.ts b/src/global.d.ts similarity index 63% rename from src/global.ts rename to src/global.d.ts index e47dff8..d3f233b 100644 --- a/src/global.ts +++ b/src/global.d.ts @@ -3,30 +3,43 @@ import type { Event } from "./events"; import type { LogEventRecord } from "./logger"; export type Command = [CommandFunction, ...CommandArg[]]; -type CommandFunction = "addData" | "init" | "mark" | "markLoadTime" | "measure" | "on" | "send"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type CommandArg = any; +type CommandFunction = + | "addData" + | "init" + | "mark" + | "markLoadTime" + | "measure" + | "on" + | "send" + | "startSoftNavigation"; + +type CommandArg = unknown; type PerfMarkFn = typeof performance.mark; type PerfMeasureFn = typeof performance.measure; +/** + * LuxGlobal is the global `LUX` object. It is defined and modified by the snippet, lux.js and by + * the implementor. + */ export interface LuxGlobal extends UserConfig { /** Command queue used to store actions that were initiated before the full script loads */ ac?: Command[]; - addData: (name: string, value: unknown) => void; + addData: (name: string, value?: unknown) => void; cmd: (cmd: Command) => void; /** @deprecated */ doUpdate?: () => void; forceSample?: () => void; - getDebug?: () => LogEventRecord[]; + getDebug: () => LogEventRecord[]; getSessionId?: () => void; - init: () => void; + init: (time?: number) => void; mark: (...args: Parameters) => ReturnType | void; - markLoadTime?: (time?: number) => void; + markLoadTime: (time?: number) => void; measure: (...args: Parameters) => ReturnType | void; on: (event: Event, callback: (data?: unknown) => void) => void; /** Timestamp representing when the LUX snippet was evaluated */ ns?: number; - send: () => void; + send: (force?: boolean) => void; snippetVersion?: string; + startSoftNavigation: (time?: number) => void; version?: string; } diff --git a/src/logger.ts b/src/logger.ts index a9bc7a4..9e52ce6 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -15,6 +15,11 @@ export const enum LogEvent { OnloadHandlerTriggered = 11, MarkLoadTimeCalled = 12, SendCancelledPageHidden = 13, + TriggerSoftNavigationCalled = 14, + SendTriggeredBySoftNavigation = 15, + SendCancelledSpaMode = 16, + BfCacheRestore = 17, + InitCallIgnored = 18, // Data collection events SessionIsSampled = 21, @@ -48,7 +53,6 @@ export const enum LogEvent { PostBeaconSendCalled = 81, PostBeaconTimeoutReached = 82, PostBeaconSent = 83, - PostBeaconAlreadySent = 84, PostBeaconCancelled = 85, PostBeaconStopRecording = 86, PostBeaconMetricRejected = 87, diff --git a/src/lux.ts b/src/lux.ts index d703d93..e8ff2f7 100644 --- a/src/lux.ts +++ b/src/lux.ts @@ -14,7 +14,7 @@ import { onVisible, isVisible, wasPrerendered, wasRedirected } from "./document" import { getNodeSelector } from "./dom"; import * as Events from "./events"; import Flags, { addFlag } from "./flags"; -import { Command, LuxGlobal } from "./global"; +import type { Command, LuxGlobal } from "./global"; import { getTrackingParams } from "./integrations/tracking"; import { InteractionInfo } from "./interaction"; import { addListener, removeListener } from "./listeners"; @@ -850,15 +850,6 @@ LUX = (function () { * beginning of a page transition, but is also called internally when the BF cache is restored. */ function _init(startTime?: number, clearFlags = true): void { - // Some customers (incorrectly) call LUX.init on the very first page load of a SPA. This would - // cause some first-page-only data (like paint metrics) to be lost. To prevent this, we silently - // bail from this function when we detect an unnecessary LUX.init call. - const endMark = _getMark(END_MARK); - - if (!endMark) { - return; - } - // Mark the "navigationStart" for this SPA page. A start time can be passed through, for example // to set a page's start time as an event timestamp. if (startTime) { @@ -867,14 +858,6 @@ LUX = (function () { _mark(START_MARK); } - logger.logEvent(LogEvent.InitCalled); - - // This is an edge case where LUX.auto = true but LUX.init() has been called. In this case, the - // POST beacon will not be sent automatically, so we need to send it here. - if (globalConfig.auto && !beacon.isSent) { - beacon.send(); - } - // Clear all interactions from the previous "page". _clearIx(); @@ -1038,11 +1021,7 @@ LUX = (function () { 0 + "fs" + 0 + - "ls" + - end + - "le" + - end + - ""; + (end > 0 ? "ls" + end + "le" + end : ""); } else if (performance.timing) { // Return the real Nav Timing metrics because this is the "main" page view (not a SPA) const navEntry = getNavigationEntry(); @@ -1069,8 +1048,14 @@ LUX = (function () { return ""; }; + // loadEventStart always comes from navigation timing let loadEventStartStr = prefixNTValue("loadEventStart", "ls", true); - let loadEventEndStr = prefixNTValue("loadEventEnd", "le", true); + + // If LUX.markLoadTime() was called in SPA Mode, we allow the custom mark to override loadEventEnd + let loadEventEndStr = + globalConfig.spaMode && endMark + ? "le" + processTimeMetric(endMark.startTime) + : prefixNTValue("loadEventEnd", "le", true); if (getPageRestoreTime() && startMark && endMark) { // For bfcache restores, we set the load time to the time it took for the page to be restored. @@ -1393,8 +1378,10 @@ LUX = (function () { return [curleft, curtop]; } - // Mark the load time of the current page. Intended to be used in SPAs where it is not desirable to - // send the beacon as soon as the page has finished loading. + /** + * Mark the load time of the current page. Intended to be used in SPAs where it is not desirable + * to send the beacon as soon as the page has finished loading. + */ function _markLoadTime(time?: number) { logger.logEvent(LogEvent.MarkLoadTimeCalled, [time]); @@ -1435,8 +1422,8 @@ LUX = (function () { queryParams.push("fl=" + gFlags); } - if (LUX.snippetVersion) { - queryParams.push("sv=" + LUX.snippetVersion); + if (globalLux.snippetVersion) { + queryParams.push("sv=" + globalLux.snippetVersion); } const customDataValues = CustomData.valuesToString(customData); @@ -1473,10 +1460,20 @@ LUX = (function () { const startMark = _getMark(START_MARK); const endMark = _getMark(END_MARK); - if (!startMark || (endMark && endMark.startTime < startMark.startTime)) { - // Record the synthetic loadEventStart time for this page, unless it was already recorded - // with LUX.markLoadTime() - _markLoadTime(); + if (!startMark) { + // For hard navigations set the synthetic load time when the beacon is being sent, unless + // one has already been set. + if (!endMark) { + _markLoadTime(); + } + } else { + // For soft navigations, only set the synthetic load time if SPA mode is not enabled, and... + if (!globalConfig.spaMode) { + // ...there is no existing end mark, or the end mark is from a previous SPA page. + if (!endMark || endMark.startTime < startMark.startTime) { + _markLoadTime(); + } + } } // Store any tracking parameters as custom data @@ -2008,6 +2005,7 @@ LUX = (function () { // See https://bugs.chromium.org/p/chromium/issues/detail?id=1133363 setTimeout(() => { if (gbLuxSent) { + logger.logEvent(LogEvent.BfCacheRestore); // If the beacon was already sent for this page, we start a new page view and mark the // load time as the time it took to restore the page. _init(getPageRestoreTime(), false); @@ -2040,36 +2038,91 @@ LUX = (function () { const globalLux = globalConfig as LuxGlobal; // Functions + globalLux.addData = _addData; + globalLux.cmd = _runCommand; + globalLux.getSessionId = _getUniqueId; globalLux.mark = _mark; - globalLux.measure = _measure; - globalLux.init = _init; globalLux.markLoadTime = _markLoadTime; + globalLux.measure = _measure; globalLux.on = Events.subscribe; - globalLux.send = () => { - logger.logEvent(LogEvent.SendCalled); + globalLux.snippetVersion = LUX.snippetVersion; + globalLux.version = VERSION; + + globalLux.init = (time?: number) => { + logger.logEvent(LogEvent.InitCalled); + + // Some customers (incorrectly) call LUX.init on the very first page load of a SPA. This would + // cause some first-page-only data (like paint metrics) to be lost. To prevent this, we silently + // bail from this function when we detect an unnecessary LUX.init call. + // + // Some notes about how this is compatible with SPA mode: + // - For "new" implementations where SPA mode has always been enabled, we expect + // LUX.startSoftNavigation() to be called instead of LUX.init(), so this code path should + // never be reached. + // + // - For "old" implementations, we expect LUX.send() is still being called. So we can rely on + // there being an end mark from the previous LUX.send() call. + // + const endMark = _getMark(END_MARK); + + if (!endMark) { + logger.logEvent(LogEvent.InitCallIgnored); + return; + } + + // In SPA mode, ensure the previous page's beacon has been sent + if (globalConfig.spaMode) { + beacon.send(); + _sendLux(); + } + + _init(time); + }; + + globalLux.startSoftNavigation = (time?: number): void => { + logger.logEvent(LogEvent.TriggerSoftNavigationCalled); beacon.send(); _sendLux(); + _init(time); }; - globalLux.addData = _addData; - globalLux.getSessionId = _getUniqueId; // so customers can do their own sampling + + globalLux.send = (force?: boolean) => { + if (globalConfig.spaMode && !force) { + // In SPA mode, sending the beacon manually is not necessary, and is ignored unless the `force` + // parameter has been specified. + logger.logEvent(LogEvent.SendCancelledSpaMode); + + // If markLoadTime() has not already been called, we assume this send() call corresponds to a + // "loaded" state and mark it as the load time. This mark is important as it is used to + // decide whether an init() call can be ignored or not. + const startMark = _getMark(START_MARK); + const endMark = _getMark(END_MARK); + + if (!endMark || (startMark && endMark.startTime < startMark.startTime)) { + _markLoadTime(); + } + } else { + logger.logEvent(LogEvent.SendCalled); + beacon.send(); + _sendLux(); + } + }; + globalLux.getDebug = () => { console.log( "SpeedCurve RUM debugging documentation: https://support.speedcurve.com/docs/rum-js-api#luxgetdebug", ); return logger.getEvents(); }; + globalLux.forceSample = () => { logger.logEvent(LogEvent.ForceSampleCalled); setUniqueId(createSyncId(true)); }; + globalLux.doUpdate = () => { // Deprecated, intentionally empty. }; - globalLux.cmd = _runCommand; - - // Public properties - globalLux.version = VERSION; - globalLux.snippetVersion = LUX.snippetVersion; /** * Run a command from the command queue diff --git a/src/snippet.ts b/src/snippet.ts index fed910c..646e910 100644 --- a/src/snippet.ts +++ b/src/snippet.ts @@ -16,12 +16,13 @@ LUX.ac = []; LUX.addData = (name, value) => LUX.cmd(["addData", name, value]); LUX.cmd = (cmd: Command) => LUX.ac!.push(cmd); LUX.getDebug = () => [[scriptStartTime, 0, []]]; -LUX.init = () => LUX.cmd(["init"]); +LUX.init = (time?: number) => LUX.cmd(["init", time || msSinceNavigationStart()]); LUX.mark = _mark; LUX.markLoadTime = () => LUX.cmd(["markLoadTime", msSinceNavigationStart()]); +LUX.startSoftNavigation = () => LUX.cmd(["startSoftNavigation", msSinceNavigationStart()]); LUX.measure = _measure; LUX.on = (event, callback) => LUX.cmd(["on", event, callback]); -LUX.send = () => LUX.cmd(["send"]); +LUX.send = (force?: boolean) => LUX.cmd(["send", force]); LUX.snippetVersion = __SNIPPET_VERSION; LUX.ns = scriptStartTime; @@ -78,6 +79,5 @@ function _measure(...args: Parameters): ReturnType; @@ -59,8 +60,10 @@ export function getNavTiming( return Object.fromEntries( matches.map((str) => { - const key = str.match(/[a-z]+/)![0]; - const name = navigationTimingKeys[key as keyof typeof navigationTimingKeys]; + const keyMatch = str.match(/[a-z]+/); + const name = keyMatch + ? navigationTimingKeys[keyMatch[0] as keyof typeof navigationTimingKeys] + : "navigationStart"; const val = parseFloat(str.match(/\d+/)![0]); return [name, val]; diff --git a/tests/helpers/shared-tests.ts b/tests/helpers/shared-tests.ts index e7c87ea..58d8f6b 100644 --- a/tests/helpers/shared-tests.ts +++ b/tests/helpers/shared-tests.ts @@ -1,4 +1,6 @@ import { Page, expect } from "@playwright/test"; +import { BeaconPayload } from "../../src/beacon"; +import { SNIPPET_VERSION, VERSION } from "../../src/version"; import { getNavTiming, getPageStat, getSearchParam } from "./lux"; type SharedTestArgs = { @@ -103,3 +105,19 @@ export function testNavigationTiming({ browserName, beacon }: SharedTestArgs) { expect(NT.largestContentfulPaint).toBeGreaterThan(0); } } + +export function testPostBeacon(beacon: BeaconPayload, hasSnippet = true) { + expect(beacon.customerId).toEqual("10001"); + expect(beacon.flags).toBeGreaterThan(0); + expect(beacon.pageId).toBeTruthy(); + expect(beacon.sessionId).toBeTruthy(); + expect(beacon.measureDuration).toBeGreaterThan(0); + expect(beacon.scriptVersion).toEqual(VERSION); + + if (hasSnippet) { + // The es2020 variant is set in tests/server.mjs + expect(beacon.snippetVersion).toEqual(`${SNIPPET_VERSION}-es2020`); + } else { + expect(beacon.snippetVersion).toBeUndefined(); + } +} diff --git a/tests/helpers/types.d.ts b/tests/helpers/types.d.ts new file mode 100644 index 0000000..8a71033 --- /dev/null +++ b/tests/helpers/types.d.ts @@ -0,0 +1 @@ +export type Writable = { -readonly [K in keyof T]: T[K] }; diff --git a/tests/integration/bf-cache.spec.ts b/tests/integration/bf-cache.spec.ts index d1171f7..82bb612 100644 --- a/tests/integration/bf-cache.spec.ts +++ b/tests/integration/bf-cache.spec.ts @@ -110,7 +110,7 @@ test.describe("BF cache integration", () => { const firstET = parseUserTiming(getSearchParam(firstBeacon, "ET")); const bfcET = parseUserTiming(getSearchParam(bfcBeacon, "ET")); - expect(firstET["eve-image"].startTime).toBeGreaterThanOrEqual(getNavTiming(firstBeacon, "le")!); + expect(firstET["eve-image"].startTime).toBeGreaterThanOrEqual(getNavTiming(firstBeacon, "ls")!); expect(firstET["eve-image-delayed"]).toBeUndefined(); expect(bfcET["eve-image"].startTime).toEqual(0); diff --git a/tests/integration/default-metrics.spec.ts b/tests/integration/default-metrics.spec.ts index 695f871..4d39a2b 100644 --- a/tests/integration/default-metrics.spec.ts +++ b/tests/integration/default-metrics.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { versionAsFloat } from "../../src/version"; +import { SNIPPET_VERSION, versionAsFloat } from "../../src/version"; import { getLuxJsStat, getSearchParam } from "../helpers/lux"; import * as Shared from "../helpers/shared-tests"; import RequestInterceptor from "../request-interceptor"; @@ -14,8 +14,10 @@ test.describe("Default metrics in auto mode", () => { // LUX beacon is automatically sent expect(luxRequests.count()).toEqual(1); - // LUX version is included in the beacon + // Script and snippet versions are included in the beacon expect(getSearchParam(beacon, "v")).toEqual(versionAsFloat().toString()); + // The es2020 variant is set in tests/server.mjs + expect(getSearchParam(beacon, "sv")).toEqual(`${SNIPPET_VERSION}-es2020`); // customer ID is detected correctly expect(getSearchParam(beacon, "id")).toEqual("10001"); diff --git a/tests/integration/events.spec.ts b/tests/integration/events.spec.ts index 0121c10..cd675f5 100644 --- a/tests/integration/events.spec.ts +++ b/tests/integration/events.spec.ts @@ -1,7 +1,16 @@ import { test, expect } from "@playwright/test"; +import { BeaconPayload } from "../../src/beacon"; import { getSearchParam } from "../helpers/lux"; import RequestInterceptor from "../request-interceptor"; +declare global { + interface Window { + page_id: string; + beacon_url: string; + payload: BeaconPayload; + } +} + test.describe("LUX events", () => { test("new_page_id", async ({ page }) => { const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -28,14 +37,24 @@ test.describe("LUX events", () => { }); test("beacon", async ({ page }) => { + const onBeacon = ` + (beacon) => { + if (typeof beacon === "string") { + window.beacon_url = beacon; + } else { + window.payload = beacon; + } + } + `; + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); - await page.goto( - "/default.html?injectScript=LUX.auto=false;LUX.on('beacon', (url) => window.beacon_url = url);", - { waitUntil: "networkidle" }, - ); + await page.goto(`/default.html?injectScript=LUX.auto=false;LUX.on('beacon', ${onBeacon});`, { + waitUntil: "networkidle", + }); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); const beacon = luxRequests.getUrl(0)!; + const payload = await page.evaluate(() => window.payload); let beaconUrl = await page.evaluate(() => window.beacon_url); // We don't encode the Delivery Type parameter before sending the beacon, but Chromium seems to @@ -44,5 +63,6 @@ test.describe("LUX events", () => { beaconUrl = beaconUrl.replace("dt(empty string)_", "dt(empty%20string)_"); expect(beaconUrl).toEqual(beacon.href); + expect(payload.customerId).toEqual("10001"); }); }); diff --git a/tests/integration/page-label.spec.ts b/tests/integration/page-label.spec.ts index 4831397..352f92e 100644 --- a/tests/integration/page-label.spec.ts +++ b/tests/integration/page-label.spec.ts @@ -2,6 +2,14 @@ import { test, expect } from "@playwright/test"; import Flags from "../../src/flags"; import { hasFlag } from "../helpers/lux"; import RequestInterceptor from "../request-interceptor"; +import type RequestMatcher from "../request-matcher"; + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: Record; + } +} test.describe("LUX page labels in auto mode", () => { test("no custom label set", async ({ page }) => { @@ -140,7 +148,7 @@ test.describe("LUX page labels in a SPA", () => { }); test.describe("LUX JS page label", () => { - let luxRequests; + let luxRequests: RequestMatcher; test.beforeEach(async ({ page }) => { luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -182,7 +190,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("Another JS Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("Another JS Label"); }); test("the variable can be changed on the fly", async ({ page }) => { @@ -194,7 +202,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("First JS Label"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("First JS Label"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => { @@ -205,7 +213,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("Different Variable Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("Different Variable Label"); // Restore jspagelabel to previous state await page.evaluate(() => (LUX.jspagelabel = "window.config.page[0].name")); @@ -220,7 +228,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("custom label"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("custom label"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => { @@ -231,7 +239,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("JS Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("JS Label"); }); test("LUX.pagegroups takes priority over JS page label", async ({ page }) => { @@ -243,7 +251,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("Pagegroup"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("Pagegroup"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => { @@ -254,7 +262,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(1).searchParams.get("l"))!.toEqual("JS Label"); + expect(luxRequests.getUrl(1)!.searchParams.get("l"))!.toEqual("JS Label"); }); test("falls back to JS variable when pagegroup doesn't match", async ({ page }) => { @@ -267,7 +275,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("JS Label"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("JS Label"); }); test("falls back to document title when JS variable doesn't eval", async ({ page }) => { @@ -279,7 +287,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("LUX default test page"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("LUX default test page"); }); test("falls back to document title when JS variable evaluates to a falsey value", async ({ @@ -293,7 +301,7 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("LUX default test page"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("LUX default test page"); }); test("internal LUX variables can't be accessed", async ({ page }) => { @@ -305,6 +313,6 @@ test.describe("LUX JS page label", () => { }), ); - expect(luxRequests.getUrl(0).searchParams.get("l"))!.toEqual("LUX default test page"); + expect(luxRequests.getUrl(0)!.searchParams.get("l"))!.toEqual("LUX default test page"); }); }); diff --git a/tests/integration/post-beacon/beacon-request.spec.ts b/tests/integration/post-beacon/beacon-request.spec.ts index bbfcca5..7cbf476 100644 --- a/tests/integration/post-beacon/beacon-request.spec.ts +++ b/tests/integration/post-beacon/beacon-request.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import { BeaconPayload } from "../../../src/beacon"; -import { VERSION } from "../../../src/version"; import { getElapsedMs } from "../../helpers/lux"; +import * as Shared from "../../helpers/shared-tests"; import RequestInterceptor from "../../request-interceptor"; /** @@ -25,13 +25,31 @@ test.describe("POST beacon request", () => { await luxRequests.waitForMatchingRequest(() => page.goto("/")); const b = luxRequests.get(0)!.postDataJSON() as BeaconPayload; - expect(b.customerId).toEqual("10001"); - expect(b.flags).toBeGreaterThan(0); - expect(b.pageId).toBeTruthy(); - expect(b.sessionId).toBeTruthy(); - expect(b.measureDuration).toBeGreaterThan(0); - expect(b.scriptVersion).toEqual(VERSION); expect(b.startTime).toEqual(0); + Shared.testPostBeacon(b); + }); + + test("beacon metadata works when the lux.js script is loaded before the snippet", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/"); + await page.goto("/images.html?noInlineSnippet", { waitUntil: "networkidle" }); + await page.addScriptTag({ url: "/js/snippet.js" }); + await luxRequests.waitForMatchingRequest(() => page.goto("/")); + + const b = luxRequests.get(0)!.postDataJSON() as BeaconPayload; + expect(b.startTime).toEqual(0); + Shared.testPostBeacon(b); + }); + + test("beacon metadata works when there is no snippet", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/"); + await page.goto("/images.html?noInlineSnippet", { waitUntil: "networkidle" }); + await luxRequests.waitForMatchingRequest(() => page.goto("/")); + + const b = luxRequests.get(0)!.postDataJSON() as BeaconPayload; + expect(b.startTime).toEqual(0); + Shared.testPostBeacon(b, false); }); test("beacon metadata is sent for SPAs", async ({ page }) => { @@ -49,21 +67,8 @@ test.describe("POST beacon request", () => { expect(luxRequests.count()).toEqual(2); const b = luxRequests.get(1)!.postDataJSON() as BeaconPayload; - expect(b.customerId).toEqual("10001"); - expect(b.flags).toBeGreaterThan(0); - expect(b.pageId).toBeTruthy(); - expect(b.sessionId).toBeTruthy(); - expect(b.measureDuration).toBeGreaterThan(0); - expect(b.scriptVersion).toEqual(VERSION); expect(b.startTime).toBeGreaterThanOrEqual(timeBeforeInit); - }); - - test("the beacon is sent when LUX.init() is called", async ({ page }) => { - const luxRequests = new RequestInterceptor(page).createRequestMatcher("/store/"); - await page.goto("/images.html", { waitUntil: "networkidle" }); - await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.init())); - - expect(luxRequests.count()).toEqual(1); + Shared.testPostBeacon(b); }); test("the beacon is not sent when LUX.auto is false", async ({ page }) => { diff --git a/tests/integration/snippet.spec.ts b/tests/integration/snippet.spec.ts index 77b6b9f..997ef25 100644 --- a/tests/integration/snippet.spec.ts +++ b/tests/integration/snippet.spec.ts @@ -9,6 +9,15 @@ import { } from "../helpers/lux"; import RequestInterceptor from "../request-interceptor"; +declare global { + interface Window { + beforeMeasureTime: number; + loadTime: number; + markTime: number; + startSoftNavTime: number; + } +} + test.describe("LUX inline snippet", () => { test("LUX.markLoadTime works before the script is loaded", async ({ page }) => { const beaconRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -20,13 +29,13 @@ test.describe("LUX inline snippet", () => { const beacon = beaconRequests.getUrl(0)!; const loadEventStart = getNavTiming(beacon, "le") || 0; - const loadTime = await page.evaluate(() => window.loadTime as number); + const loadTime = await page.evaluate(() => Math.floor(window.loadTime)); const loadTimeMark = await page.evaluate( (mark) => performance.getEntriesByName(mark)[0].startTime, END_MARK, ); - expect(loadTime).toBeLessThan(loadEventStart); + expect(loadTime).toBeLessThanOrEqual(loadEventStart); /** * Calling the snippet's version of markLoadTime() should cause the load time to be marked as @@ -37,8 +46,31 @@ test.describe("LUX inline snippet", () => { * so the values should be almost exactly the same. These assertions give a bit of leeway to * reduce flakiness on slower test machines. */ - expect(loadTimeMark).toBeGreaterThanOrEqual(Math.floor(loadTime)); - expect(loadTimeMark).toBeLessThan(loadTime + 5); + expect(loadTimeMark).toBeBetween(loadTime, loadTime + 5); + }); + + test("LUX.startSoftNavigation works before the script is loaded", async ({ page }) => { + const beaconRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + + await page.goto( + "/default.html?injectScript=window.startSoftNavTime=performance.now();LUX.startSoftNavigation();", + ); + + await beaconRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); + expect(beaconRequests.count()).toEqual(2); + + /** + * Similar to how we test markLoadTime() above, we keep track of the time right before + * calling startSoftNavigation() so we can compare it to the time when the soft navigation + * is recorded in the beacon. + */ + const hardNavBeacon = beaconRequests.getUrl(0)!; + const hardNavStart = getNavTiming(hardNavBeacon).navigationStart; + const softNavBeacon = beaconRequests.getUrl(1)!; + const softNavStart = getNavTiming(softNavBeacon).navigationStart; + const startSoftNavTime = await page.evaluate(() => Math.floor(window.startSoftNavTime)); + + expect(softNavStart - hardNavStart).toEqual(startSoftNavTime); }); test("LUX.mark works before the script is loaded", async ({ page }) => { @@ -55,7 +87,7 @@ test.describe("LUX inline snippet", () => { await beaconRequests.waitForMatchingRequest(); const beacon = beaconRequests.getUrl(0)!; - const markTime = (await page.evaluate(() => window.markTime)) as number; + const markTime = await page.evaluate(() => Math.floor(window.markTime)); const UT = parseUserTiming(getSearchParam(beacon, "UT")); expect(UT["mark-1"].startTime).toBeGreaterThanOrEqual(Math.floor(markTime)); @@ -83,7 +115,7 @@ test.describe("LUX inline snippet", () => { await beaconRequests.waitForMatchingRequest(); const beacon = beaconRequests.getUrl(0)!; - const beforeMeasureTime = await page.evaluate(() => window.beforeMeasureTime as number); + const beforeMeasureTime = await page.evaluate(() => Math.floor(window.beforeMeasureTime)); const connectEnd = await getNavigationTimingMs(page, "connectEnd"); const UT = parseUserTiming(getSearchParam(beacon, "UT")); diff --git a/tests/integration/spa-metrics.spec.ts b/tests/integration/spa-metrics.spec.ts index 13b07ea..a5f2616 100644 --- a/tests/integration/spa-metrics.spec.ts +++ b/tests/integration/spa-metrics.spec.ts @@ -109,7 +109,7 @@ test.describe("LUX SPA", () => { expect(luxLoadEventEnd).toEqual(pageLoadEventEnd); }); - test("load time value for subsequent pages is the time between LUX.init() and LUX.send()", async ({ + test("load time value for soft navs is the time between LUX.init() and LUX.send()", async ({ page, }) => { const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); @@ -130,8 +130,7 @@ test.describe("LUX SPA", () => { const loadEventEnd = getNavTiming(beacon, "le"); // We waited 50ms between LUX.init() and LUX.send(), so the load time should - // be at least 50ms. 60ms is an arbitrary upper limit to make sure we're not - // over-reporting load time. + // be at least 50ms, but less than the timestamp after LUX.send() was called expect(loadEventStart).toBeGreaterThanOrEqual(50); expect(loadEventStart).toBeLessThanOrEqual(timeAfterSend - timeBeforeInit); expect(loadEventStart).toEqual(loadEventEnd); @@ -142,21 +141,37 @@ test.describe("LUX SPA", () => { expect(hasFlag(beaconFlags, Flags.InitCalled)).toBe(true); }); - test("load time can be marked before the beacon is sent", async ({ page }) => { + test("LUX.markLoadTime() does not override loadEventEnd on a hard navigation", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.auto=false;"); + await page.evaluate(() => LUX.markLoadTime(12345)); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); + + const beacon = luxRequests.getUrl(0)!; + const pageLoadEventStart = await getNavigationTimingMs(page, "loadEventStart"); + const pageLoadEventEnd = await getNavigationTimingMs(page, "loadEventEnd"); + const loadEventStart = getNavTiming(beacon, "ls"); + const loadEventEnd = getNavTiming(beacon, "le"); + + expect(loadEventStart).toEqual(pageLoadEventStart); + expect(loadEventEnd).toEqual(pageLoadEventEnd); + }); + + test("LUX.markLoadTime() can set the load time on a soft navigation", async ({ page }) => { const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); await page.goto("/default.html?injectScript=LUX.auto=false;"); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); await page.evaluate(() => LUX.init()); - await page.waitForTimeout(10); - await page.evaluate(() => LUX.markLoadTime()); - await page.waitForTimeout(50); + await page.evaluate(() => LUX.markLoadTime(12345)); await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send())); const beacon = luxRequests.getUrl(1)!; - const loadEventStart = getNavTiming(beacon, "le"); + const loadEventStart = getNavTiming(beacon, "ls"); - expect(loadEventStart).toBeGreaterThanOrEqual(10); - expect(loadEventStart).toBeLessThan(50); + // In a soft nav the load time will be relative to the LUX.init call. + expect(loadEventStart).toBeBetween(12000, 12345); }); }); diff --git a/tests/integration/spa-mode.spec.ts b/tests/integration/spa-mode.spec.ts new file mode 100644 index 0000000..1082fe3 --- /dev/null +++ b/tests/integration/spa-mode.spec.ts @@ -0,0 +1,195 @@ +import { test, expect } from "@playwright/test"; +import { BeaconPayload } from "../../src/beacon"; +import Flags, { hasFlag } from "../../src/flags"; +import { entryTypeSupported } from "../helpers/browsers"; +import { + getElapsedMs, + getNavigationTimingMs, + getNavTiming, + getSearchParam, + parseUserTiming, +} from "../helpers/lux"; +import * as Shared from "../helpers/shared-tests"; +import RequestInterceptor from "../request-interceptor"; + +test.describe("LUX SPA Mode", () => { + test("beacons are only sent before page transitions and before pagehide", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;"); + await page.evaluate(() => LUX.send()); + + // Calling LUX.send() doesn't trigger a beacon + expect(luxRequests.count()).toEqual(0); + + // Calling LUX.init() to start a page transition should send the previous beacon + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.init())); + expect(luxRequests.count()).toEqual(1); + + // Calling LUX.startSoftNavigation() to start a page transition should send the previous beacon + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.startSoftNavigation())); + expect(luxRequests.count()).toEqual(2); + }); + + test("load time for soft navs is not recorded in SPA mode unless LUX.markLoadTime() is called", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;"); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + // Create a soft nav but don't call LUX.markLoadTime() + await page.evaluate(() => LUX.init()); + await page.waitForTimeout(50); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + let beacon = luxRequests.getUrl(1)!; + let beaconFlags = parseInt(getSearchParam(beacon, "fl")); + let loadEventStart = getNavTiming(beacon, "ls"); + let loadEventEnd = getNavTiming(beacon, "le"); + + expect(loadEventStart).toBeNull(); + expect(loadEventEnd).toBeNull(); + expect(hasFlag(beaconFlags, Flags.InitCalled)).toBe(true); + + // Create a soft nav and do call LUX.markLoadTime() + const timeBeforeInit = await page.evaluate(() => { + const beforeInit = Math.floor(performance.now()); + LUX.init(); + return beforeInit; + }); + await page.waitForTimeout(50); + await luxRequests.waitForMatchingRequest(() => + page.evaluate(() => { + LUX.markLoadTime(); + LUX.send(true); + }), + ); + const timeAfterSend = await getElapsedMs(page); + + beacon = luxRequests.getUrl(2)!; + beaconFlags = parseInt(getSearchParam(beacon, "fl")); + loadEventStart = getNavTiming(beacon, "ls"); + loadEventEnd = getNavTiming(beacon, "le"); + + // We called LUX.markLoadTime() after 50ms + expect(loadEventStart).toBeGreaterThanOrEqual(50); + expect(loadEventStart).toBeLessThanOrEqual(timeAfterSend - timeBeforeInit); + expect(loadEventStart).toEqual(loadEventEnd); + expect(hasFlag(beaconFlags, Flags.InitCalled)).toBe(true); + }); + + test("LUX.markLoadTime() can override loadEventEnd on a hard navigation", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;"); + await page.evaluate(() => LUX.markLoadTime(12345)); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + const beacon = luxRequests.getUrl(0)!; + const pageLoadEventStart = await getNavigationTimingMs(page, "loadEventStart"); + const loadEventStart = getNavTiming(beacon, "ls"); + const loadEventEnd = getNavTiming(beacon, "le"); + + expect(loadEventStart).toEqual(pageLoadEventStart); + expect(loadEventEnd).toEqual(12345); + }); + + test("LUX.init cannot be accidentally called on the initial navigation in SPA mode", async ({ + page, + }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;", { waitUntil: "networkidle" }); + await page.evaluate(() => LUX.init()); + await luxRequests.waitForMatchingRequest(() => page.evaluate(() => LUX.send(true))); + + const beacon = luxRequests.getUrl(0)!; + const NT = getNavTiming(beacon); + const lcpSupported = await entryTypeSupported(page, "largest-contentful-paint"); + + expect(NT.startRender).toBeGreaterThan(0); + expect(NT.firstContentfulPaint).toBeGreaterThan(0); + + if (lcpSupported) { + expect(NT.largestContentfulPaint).toBeGreaterThanOrEqual(0); + } else { + expect(NT.largestContentfulPaint).toBeUndefined(); + } + }); + + test("legacy implementations work as expected", async ({ page }) => { + const luxRequests = new RequestInterceptor(page).createRequestMatcher("/beacon/"); + + // Simulate loading lux.js with SPA mode injected by the server, and the implementor setting + // LUX.auto = false. + await page.goto("/default.html?injectScript=LUX.spaMode=true;LUX.auto=false;", { + waitUntil: "networkidle", + }); + + await page.evaluate(() => { + LUX.label = "page-01"; + performance.mark("load-marker", { startTime: 20 }); + + // Make an invalid LUX.init call during the hard navigation to test that this is still + // handled correctly. + LUX.init(); + + // This will be used as the load time but the beacon will not be sent here + LUX.send(); + + LUX.init(); + LUX.label = "page-02"; + + setTimeout(() => { + performance.mark("load-marker"); + LUX.send(); + LUX.init(); + LUX.label = "page-03"; + + setTimeout(() => { + performance.mark("load-marker"); + }, 20); + }, 20); + }); + + // Wait for the timeouts + await page.waitForTimeout(80); + + // Abandon the page before LUX.send is called for the third page load. + await page.goto("/"); + await luxRequests.waitForMatchingRequest(); + + expect(luxRequests.count()).toEqual(3); + + const beacon1 = luxRequests.getUrl(0)!; + const beacon2 = luxRequests.getUrl(1)!; + const beacon3 = luxRequests.getUrl(2)!; + const pageLoadEventEnd = await getNavigationTimingMs(page, "loadEventEnd"); + + expect(getSearchParam(beacon1, "l")).toEqual("page-01"); + expect(getSearchParam(beacon2, "l")).toEqual("page-02"); + expect(getSearchParam(beacon3, "l")).toEqual("page-03"); + + const UT1 = parseUserTiming(getSearchParam(beacon1, "UT")); + const UT2 = parseUserTiming(getSearchParam(beacon2, "UT")); + const UT3 = parseUserTiming(getSearchParam(beacon3, "UT")); + + expect(UT1["load-marker"].startTime).toEqual(20); + expect(UT2["load-marker"].startTime).toBeBetween(20, 30); + expect(UT3["load-marker"].startTime).toBeBetween(20, 30); + + expect(getNavTiming(beacon1, "le")).toBeGreaterThanOrEqual(pageLoadEventEnd); + }); + + test("MPAs still work as expected", async ({ page, browserName }) => { + const requestInterceptor = new RequestInterceptor(page); + const getBeacons = requestInterceptor.createRequestMatcher("/beacon/"); + const postBeacons = requestInterceptor.createRequestMatcher("/store/"); + await page.goto("/default.html?injectScript=LUX.spaMode=true;", { waitUntil: "networkidle" }); + await page.goto("/"); + const getBeacon = getBeacons.getUrl(0)!; + const postBeacon = postBeacons.get(0)!.postDataJSON() as BeaconPayload; + + Shared.testPageStats({ page, browserName, beacon: getBeacon }); + Shared.testNavigationTiming({ page, browserName, beacon: getBeacon }); + Shared.testPostBeacon(postBeacon); + }); +}); diff --git a/tests/playwright.d.ts b/tests/playwright.d.ts index 3c248e0..cf14e2c 100644 --- a/tests/playwright.d.ts +++ b/tests/playwright.d.ts @@ -1,6 +1,10 @@ +import type { LuxGlobal } from "../src/global"; + export {}; declare global { + declare const LUX: LuxGlobal; + namespace PlaywrightTest { interface Matchers { toBeBetween(a: number, b: number): R; diff --git a/tests/request-matcher.ts b/tests/request-matcher.ts index 85bc850..9a81532 100644 --- a/tests/request-matcher.ts +++ b/tests/request-matcher.ts @@ -35,7 +35,9 @@ export default class RequestMatcher { requestCount?: number, ): Promise; async waitForMatchingRequest(requestCount?: number): Promise; - async waitForMatchingRequest(...args): Promise { + async waitForMatchingRequest( + ...args: [(() => Promise) | number | undefined, number?] + ): Promise { let afterCb: (() => Promise) | undefined; let requestCount: number | undefined; diff --git a/tests/server.mjs b/tests/server.mjs index 287195e..42759bc 100644 --- a/tests/server.mjs +++ b/tests/server.mjs @@ -2,10 +2,10 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import path from "node:path"; -import url from "node:url"; +import { fileURLToPath } from "node:url"; import BeaconStore from "./helpers/beacon-store.js"; -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const testPagesDir = path.join(__dirname, "test-pages"); const distDir = path.join(__dirname, "..", "dist"); @@ -15,27 +15,27 @@ BeaconStore.open().then(async (store) => { const server = createServer(async (req, res) => { const reqTime = new Date(); - const inlineSnippet = await readFile(path.join(distDir, "lux-snippet.js")); - const parsedUrl = url.parse(req.url, true); - const pathname = parsedUrl.pathname; + const inlineSnippet = await readFile(path.join(distDir, "lux-snippet.es2020.js")); + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; const headers = (contentType) => { const h = { - "cache-control": `public, max-age=${parsedUrl.query.maxAge || 0}`, + "cache-control": `public, max-age=${url.searchParams.get("maxAge") || 0}`, "content-type": contentType, - "server-timing": parsedUrl.query.serverTiming || "", + "server-timing": url.searchParams.get("serverTiming") || "", "timing-allow-origin": "*", }; - if (!parsedUrl.query.keepAlive) { + if (!url.searchParams.get("keepAlive")) { h.connection = "close"; } - if (parsedUrl.query.csp) { - const cspHeader = parsedUrl.query.cspReportOnly + if (url.searchParams.has("csp")) { + const cspHeader = url.searchParams.get("cspReportOnly") ? "content-security-policy-report-only" : "content-security-policy"; - h[cspHeader] = parsedUrl.query.csp; + h[cspHeader] = url.searchParams.get("csp"); } return h; @@ -43,9 +43,7 @@ BeaconStore.open().then(async (store) => { const sendResponse = async (status, headers, body) => { console.log( - [reqTime.toISOString(), status, req.method, `${pathname}${parsedUrl.search || ""}`].join( - " ", - ), + [reqTime.toISOString(), status, req.method, `${pathname}${url.search || ""}`].join(" "), ); res.writeHead(status, headers); @@ -65,16 +63,24 @@ BeaconStore.open().then(async (store) => { break; } - if (parsedUrl.query.redirectTo) { + if (url.searchParams.has("redirectTo")) { // Send the redirect after a short delay so that the redirectStart time is measurable - setTimeout(() => { - sendResponse(302, { location: decodeURIComponent(parsedUrl.query.redirectTo) }, ""); - }, parsedUrl.query.redirectDelay || 0); + const redirectLocation = decodeURIComponent(url.searchParams.get("redirectTo")); + + setTimeout( + () => { + sendResponse(302, { location: redirectLocation }, ""); + }, + url.searchParams.get("redirectDelay") || 0, + ); } else if (pathname === "/") { sendResponse(200, headers("text/plain"), "OK"); } else if (pathname === "/js/lux.min.js.map") { const contents = await readFile(path.join(distDir, "lux.min.js.map")); sendResponse(200, headers("application/json"), contents); + } else if (pathname === "/js/snippet.js") { + const contents = await readFile(path.join(distDir, "lux-snippet.es2020.js")); + sendResponse(200, headers("application/json"), contents); } else if (pathname === "/js/lux.js") { const contents = await readFile(path.join(distDir, "lux.min.js")); let preamble = [ @@ -89,16 +95,16 @@ BeaconStore.open().then(async (store) => { sendResponse(200, headers(contentType), `${preamble};${contents}`); } else if (pathname === "/beacon/" || pathname === "/error/") { if (req.headers.referer) { - const referrerUrl = url.parse(req.headers.referer, true); + const referrerUrl = new URL(req.headers.referer); - if ("useBeaconStore" in referrerUrl.query) { - store.id = referrerUrl.query.useBeaconStore; + if (referrerUrl.searchParams.has("useBeaconStore")) { + store.id = referrerUrl.searchParams.get("useBeaconStore"); store.put( reqTime.getTime(), req.headers["user-agent"], new URL(req.url, `http://${req.headers.host}`).href, - parsedUrl.query.l, - decodeURIComponent(parsedUrl.query.PN), + url.searchParams.get("l"), + decodeURIComponent(url.searchParams.get("PN")), ); } } @@ -121,25 +127,25 @@ BeaconStore.open().then(async (store) => { }; `; - if (parsedUrl.query.injectBeforeSnippet) { - injectScript += parsedUrl.query.injectBeforeSnippet; + if (url.searchParams.has("injectBeforeSnippet")) { + injectScript += url.searchParams.get("injectBeforeSnippet"); } - if (!parsedUrl.query.noInlineSnippet) { + if (!url.searchParams.has("noInlineSnippet")) { injectScript += inlineSnippet; } - if (parsedUrl.query.injectScript) { - injectScript += parsedUrl.query.injectScript; + if (url.searchParams.has("injectScript")) { + injectScript += url.searchParams.get("injectScript"); } contents = contents.toString().replace("/*INJECT_SCRIPT*/", injectScript); } - if (parsedUrl.query.delay) { + if (url.searchParams.has("delay")) { setTimeout( () => sendResponse(200, headers(contentType), contents), - parseInt(parsedUrl.query.delay), + parseInt(url.searchParams.get("delay")), ); } else { sendResponse(200, headers(contentType), contents); diff --git a/tests/test-pages/preact.js b/tests/test-pages/preact.js new file mode 100644 index 0000000..af3a959 --- /dev/null +++ b/tests/test-pages/preact.js @@ -0,0 +1,3 @@ +/* esm.sh - htm@3.1.1/preact/standalone */ +var N,f,en,D,tn,B,_n,F={},rn=[],yn=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function C(e,n){for(var t in n)e[t]=n[t];return e}function on(e){var n=e.parentNode;n&&n.removeChild(e)}function ln(e,n,t){var _,l,r,u={};for(r in n)r=="key"?_=n[r]:r=="ref"?l=n[r]:u[r]=n[r];if(arguments.length>2&&(u.children=arguments.length>3?N.call(arguments,2):t),typeof e=="function"&&e.defaultProps!=null)for(r in e.defaultProps)u[r]===void 0&&(u[r]=e.defaultProps[r]);return U(e,u,_,l,null)}function U(e,n,t,_,l){var r={type:e,props:n,key:t,ref:_,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:l??++en};return f.vnode!=null&&f.vnode(r),r}function W(e){return e.children}function A(e,n){this.props=e,this.context=n}function E(e,n){if(n==null)return e.__?E(e.__,e.__.__k.indexOf(e)+1):null;for(var t;n0?U(i.type,i.props,i.key,null,i.__v):i)!=null){if(i.__=t,i.__b=t.__b+1,(c=y[o])===null||c&&i.key==c.key&&i.type===c.type)y[o]=void 0;else for(m=0;m=t.__.length&&t.__.push({}),t.__[e]}function bn(e){return S=1,Cn(vn,e)}function Cn(e,n,t){var _=P(x++,2);return _.t=e,_.__c||(_.__=[t?t(n):vn(void 0,n),function(l){var r=_.t(_.__[0],l);_.__[0]!==r&&(_.__=[r,_.__[1]],_.__c.setState({}))}],_.__c=g),_.__}function Pn(e,n){var t=P(x++,3);!f.__s&&q(t.__H,n)&&(t.__=e,t.__H=n,g.__H.__h.push(t))}function xn(e,n){var t=P(x++,4);!f.__s&&q(t.__H,n)&&(t.__=e,t.__H=n,g.__h.push(t))}function Dn(e){return S=5,dn(function(){return{current:e}},[])}function wn(e,n,t){S=6,xn(function(){typeof e=="function"?e(n()):e&&(e.current=n())},t==null?t:t.concat(e))}function dn(e,n){var t=P(x++,7);return q(t.__H,n)&&(t.__=e(),t.__H=n,t.__h=e),t.__}function Tn(e,n){return S=8,dn(function(){return e},n)}function Un(e){var n=g.context[e.__c],t=P(x++,9);return t.c=e,n?(t.__==null&&(t.__=!0,n.sub(g)),n.props.value):e.__}function An(e,n){f.useDebugValue&&f.useDebugValue(n?n(e):e)}function Mn(e){var n=P(x++,10),t=bn();return n.__=e,g.componentDidCatch||(g.componentDidCatch=function(_){n.__&&n.__(_),t[1](_)}),[t[0],function(){t[1](void 0)}]}function Hn(){I.forEach(function(e){if(e.__P)try{e.__H.__h.forEach(M),e.__H.__h.forEach(O),e.__H.__h=[]}catch(n){e.__H.__h=[],f.__e(n,e.__v)}}),I=[]}f.__b=function(e){g=null,J&&J(e)},f.__r=function(e){K&&K(e),x=0;var n=(g=e.__c).__H;n&&(n.__h.forEach(M),n.__h.forEach(O),n.__h=[])},f.diffed=function(e){Q&&Q(e);var n=e.__c;n&&n.__H&&n.__H.__h.length&&(I.push(n)!==1&&z===f.requestAnimationFrame||((z=f.requestAnimationFrame)||function(t){var _,l=function(){clearTimeout(r),Z&&cancelAnimationFrame(_),setTimeout(t)},r=setTimeout(l,100);Z&&(_=requestAnimationFrame(l))})(Hn)),g=void 0},f.__c=function(e,n){n.some(function(t){try{t.__h.forEach(M),t.__h=t.__h.filter(function(_){return!_.__||O(_)})}catch(_){n.some(function(l){l.__h&&(l.__h=[])}),n=[],f.__e(_,t.__v)}}),X&&X(e,n)},f.unmount=function(e){Y&&Y(e);var n=e.__c;if(n&&n.__H)try{n.__H.__.forEach(M)}catch(t){f.__e(t,n.__v)}};var Z=typeof requestAnimationFrame=="function";function M(e){var n=g;typeof e.__c=="function"&&e.__c(),g=n}function O(e){var n=g;e.__c=e.__(),g=n}function q(e,n){return!e||e.length!==n.length||n.some(function(t,_){return t!==e[_]})}function vn(e,n){return typeof n=="function"?n(e):n}var mn=function(e,n,t,_){var l;n[0]=0;for(var r=1;r=5&&((u||!c&&r===5)&&(s.push(r,0,u,l),r=6),c&&(s.push(r,c,0,l),r=6)),u=""},o=0;o"?(r=1,u=""):u=_+u[0]:a?_===a?a="":u+=_:_==='"'||_==="'"?a=_:_===">"?(h(),r=1):r&&(_==="="?(r=5,l=u,u=""):_==="/"&&(r<5||t[o][m+1]===">")?(h(),r===3&&(s=s[0]),r=s,(s=s[0]).push(2,0,r),r=0):_===" "||_===" "||_===` +`||_==="\r"?(h(),r=2):u+=_),r===3&&u==="!--"&&(r=4,s=s[0])}return h(),s}(e)),n),arguments,[])).length>1?n:n[0]}.bind(ln);export{A as Component,Sn as createContext,ln as h,Fn as html,En as render,Tn as useCallback,Un as useContext,An as useDebugValue,Pn as useEffect,Mn as useErrorBoundary,wn as useImperativeHandle,xn as useLayoutEffect,dn as useMemo,Cn as useReducer,Dn as useRef,bn as useState}; diff --git a/tests/test-pages/spa.html b/tests/test-pages/spa.html new file mode 100644 index 0000000..9b4427a --- /dev/null +++ b/tests/test-pages/spa.html @@ -0,0 +1,167 @@ + + + + + LUX SPA test page + + + + + +

LUX SPA test page

+ +
+ + + + + diff --git a/tests/unit/CLS.test.ts b/tests/unit/CLS.test.ts index ea1f403..43ffe4d 100644 --- a/tests/unit/CLS.test.ts +++ b/tests/unit/CLS.test.ts @@ -2,6 +2,12 @@ import { describe, expect, test } from "@jest/globals"; import * as Config from "../../src/config"; import * as CLS from "../../src/metric/CLS"; +declare global { + interface Window { + LayoutShift: () => void; + } +} + const config = Config.fromObject({}); // Mock LayoutShift support so the CLS.getData() returns a value. diff --git a/tests/unit/INP.test.ts b/tests/unit/INP.test.ts index e302a0d..637a5a2 100644 --- a/tests/unit/INP.test.ts +++ b/tests/unit/INP.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from "@jest/globals"; import * as Config from "../../src/config"; import * as INP from "../../src/metric/INP"; import "../../src/window.d.ts"; +import { Writable } from "../helpers/types"; const config = Config.fromObject({}); @@ -137,7 +138,7 @@ describe("INP", () => { }); }); -function makeEntry(props: Partial): PerformanceEventTiming { +function makeEntry(props: Partial): Writable { return { interactionId: 0, duration: 0, diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 6df629e..e75ad38 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -8,15 +8,19 @@ describe("Config.fromObject()", () => { expect(config.auto).toEqual(true); expect(config.beaconUrl).toEqual("https://lux.speedcurve.com/lux/"); expect(config.customerid).toBeUndefined(); + expect(config.errorBeaconUrl).toEqual("https://lux.speedcurve.com/error/"); expect(config.jspagelabel).toBeUndefined(); expect(config.label).toBeUndefined(); expect(config.maxErrors).toEqual(5); expect(config.maxMeasureTime).toEqual(60_000); expect(config.measureUntil).toEqual("onload"); expect(config.minMeasureTime).toEqual(0); + expect(config.newBeaconOnPageShow).toEqual(false); expect(config.samplerate).toEqual(100); expect(config.sendBeaconOnPageHidden).toEqual(true); + expect(config.spaMode).toEqual(false); expect(config.trackErrors).toEqual(true); + expect(config.trackHiddenPages).toEqual(false); }); test("it uses values from the config object when they are provided", () => { @@ -53,4 +57,26 @@ describe("Config.fromObject()", () => { expect(config.sendBeaconOnPageHidden).toEqual(true); }); + + test("it sets sensible defaults in SPA mode", () => { + const config = Config.fromObject({ + spaMode: true, + }); + + expect(config.auto).toEqual(false); + expect(config.measureUntil).toEqual("pagehidden"); + expect(config.sendBeaconOnPageHidden).toEqual(true); + expect(config.spaMode).toEqual(true); + }); + + test("SPA mode defaults can be overridden, except for auto", () => { + const config = Config.fromObject({ + auto: true, + spaMode: true, + sendBeaconOnPageHidden: false, + }); + + expect(config.auto).toEqual(false); + expect(config.sendBeaconOnPageHidden).toEqual(false); + }); }); From 9e46a47920c700ab3fccafd10f5ccbf705b5a70c Mon Sep 17 00:00:00 2001 From: Joseph Wynn Date: Mon, 8 Sep 2025 16:11:52 +1200 Subject: [PATCH 6/6] Debug parser improvements --- docs/debug-parser.html | 8 +++-- docs/debug-parser.js | 2 +- docs/debug-parser/events.ts | 68 ++++++++++++++++++++++++++----------- docs/debug-parser/index.ts | 6 ++-- src/logger.ts | 7 ++-- src/lux.ts | 2 +- src/snippet.ts | 3 +- 7 files changed, 64 insertions(+), 32 deletions(-) diff --git a/docs/debug-parser.html b/docs/debug-parser.html index c5fdf9a..ce79720 100644 --- a/docs/debug-parser.html +++ b/docs/debug-parser.html @@ -102,9 +102,11 @@

SpeedCurve RUM Debug Parser

Parsed debug output

- - - + + + + +
    diff --git a/docs/debug-parser.js b/docs/debug-parser.js index 8dc9e1d..e81b772 100644 --- a/docs/debug-parser.js +++ b/docs/debug-parser.js @@ -1 +1 @@ -!function(){"use strict";"function"==typeof SuppressedError&&SuppressedError;var e={as:"activationStart",rs:"redirectStart",re:"redirectEnd",fs:"fetchStart",ds:"domainLookupStart",de:"domainLookupEnd",cs:"connectStart",sc:"secureConnectionStart",ce:"connectEnd",qs:"requestStart",bs:"responseStart",be:"responseEnd",oi:"domInteractive",os:"domContentLoadedEventStart",oe:"domContentLoadedEventEnd",oc:"domComplete",ls:"loadEventStart",le:"loadEventEnd",sr:"startRender",fc:"firstContentfulPaint",lc:"largestContentfulPaint"};function t(t,r){if(r)return function(e,t){var n=e.match(new RegExp("".concat(t,"(\\d+)")));return n?parseFloat(n[1]):null}(n(t,"NT"),r);var a=n(t,"NT").match(/[a-z]+[0-9]+/g);return a?Object.fromEntries(a.map((function(t){var n=t.match(/[a-z]+/)[0];return[e[n],parseFloat(t.match(/\d+/)[0])]}))):{}}function n(e,t){return e.searchParams.get(t)||""}function r(e){return e.map((function(e){return JSON.stringify(e)})).join(", ")}var a=document.querySelector("#input"),c=document.querySelector("#event-counter"),o=document.querySelector("#output"),s=document.querySelector("#parse"),i=document.querySelectorAll(".event-filter");if(!a||!o||!s)throw new Error("Cannot start debug parser.");function u(e){e.innerHTML="";var n=[];try{n=JSON.parse(a.value)}catch(t){e.appendChild(d("Could not parse input: ".concat(t),"red"))}c.innerText="(".concat(n.length," events)");for(var o=Number(new Date(n[0][0])),s=0,u=n;s0&&(a+=" Minimum measure time was ".concat(n[1])),a;case 21:return"Sample rate is ".concat(n[0],"%. This session is being sampled.");case 22:return"Sample rate is ".concat(n[0],"%. This session is not being sampled.");case 23:return a="Main beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 24:return a="Supplementary user timing beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 25:return a="Supplementary user interaction beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 26:return a="Supplementary custom data beacon sent",t.includes("beaconUrl")&&(a+=": ".concat(n[0])),a;case 80:return"POST beacon initialised.";case 81:return"POST beacon send() called.";case 82:return"POST beacon maximum measure timeout reached.";case 83:return t.includes("beaconUrl")?"POST beacon sent: ".concat(n[0]):"POST beacon sent.";case 89:return t.includes("beaconUrl")?"POST beacon send failed: ".concat(n[0]):"POST beacon send failed.";case 84:return"POST beacon cancelled (already sent).";case 85:return"POST beacon cancelled.";case 86:return"POST beacon is no longer recording metrics. Metrics received after this point may be ignored.";case 87:return"POST beacon metric rejected: ".concat(n[0]);case 90:return"POST beacon cancelled due to CSP violation.";case 91:return"POST beacon metric collector: ".concat(n[0]," (has data: ").concat(n[1],")");case 41:return"";case 42:return"layout-shift"===n[0].entryType?"Received layout shift at ".concat(n[0].startTime.toFixed()," ms with value of ").concat(n[0].value.toFixed(3)):"longtask"===n[0].entryType?"Received long task with duration of ".concat(n[0].duration," ms"):"event"===n[0].entryType?0===n[0].interactionId?"Ignored INP entry with no interaction ID":"Received INP entry with duration of ".concat(n[0].duration," ms (ID: ").concat(n[0].interactionId,")"):"first-input"===n[0].entryType?"Received FID entry with duration of ".concat(n[0].duration," ms"):"largest-contentful-paint"===n[0].entryType?"Received LCP entry at ".concat(n[0].startTime.toFixed()," ms"):"element"===n[0].entryType?"Received element timing entry for ".concat(n[0].identifier," at ").concat(n[0].startTime.toFixed()," ms"):(a="Received ".concat(n[0].entryType," entry"),n[0].startTime&&(a+=" at ".concat(n[0].startTime.toFixed()," ms")),a);case 43:return"largest-contentful-paint"===n[0].entryType?"Picked LCP from entry at ".concat(n[0].startTime.toFixed()," ms"):"";case 51:return"Error while initialising PerformanceObserver: ".concat(n[0]);case 52:return"Error reading input event. Cannot calculate FID for this page.";case 53:return"Cannot read the innerHTML property of an element. Cannot calculate inline style or script sizes for this page.";case 54:return"Error reading input event. Cannot calculate user interaction times for this page.";case 55:return"Error reading session cookie. This page will not be linked to a user session.";case 56:return"Error setting session cookie. This page will not be linked to a user session.";case 57:return"Error while evaluating '".concat(n[0],"' for the page label: ").concat(n[1]);case 71:return"The Navigation Timing API is not supported. Performance metrics for this page will be limited.";case 72:return"Start render time could not be determined."}return a}(a,f),u=a[2];if(i){if(function(e){return[23,26,25,24].includes(e)}(a[1])){(b=d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i))).classList.add("tooltip-container");var l=new URL(u[0]),g=t(l);(h=document.createElement("div")).className="tooltip",h.innerHTML='\n
    \n Page label: '.concat(l.searchParams.get("l"),"
    \n Hostname: ").concat(l.searchParams.get("HN"),"
    \n Path: ").concat(l.searchParams.get("PN"),"
    \n lux.js version: ").concat(l.searchParams.get("v"),"
    \n
    \n LCP: ").concat(g.largestContentfulPaint,"
    \n CLS: ").concat(l.searchParams.get("DCLS"),"
    \n INP: ").concat(l.searchParams.get("INP"),"
    \n FID: ").concat(l.searchParams.get("FID"),"
    \n
    \n "),b.appendChild(h),e.appendChild(b)}else if(1===a[1]){(b=d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i," Hover to view configuration."))).classList.add("tooltip-container");var v=u[1];try{v=JSON.parse(v)}catch(e){}(h=document.createElement("div")).className="tooltip",h.innerHTML='\n
    \n
    '.concat(JSON.stringify(v,null,4),"
    \n
    \n "),b.appendChild(h),e.appendChild(b)}else if(83===a[1]){var b;(b=d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i," Hover to view data."))).classList.add("tooltip-container");var h,S=u[1];try{S=JSON.parse(S)}catch(e){}(h=document.createElement("div")).className="tooltip",h.innerHTML='\n
    \n
    '.concat(JSON.stringify(S,null,4),"
    \n
    \n "),b.appendChild(h),e.appendChild(b)}else e.appendChild(d("".concat((new Intl.NumberFormat).format(s)," ms: ").concat(i)));if(9===a[1]&&(p=!0),3===a[1]&&(m=a[0],p=!1),7===a[1])a[0]-m<1e3&&e.appendChild(d("".concat((new Intl.NumberFormat).format(s)," ms: âš ī¸ Data was gathered for less than 1 second. Consider increasing the value of LUX.minMeasureTime.")));if(p&&42===a[1])42!==n[c+1][1]&&e.appendChild(d("".concat((new Intl.NumberFormat).format(s)," ms: âš ī¸ Performance entries were received after the beacon was sent.")))}}));var g=new Date(o);e.prepend(d("0 ms: Navigation started at ".concat(g.toLocaleDateString()," ").concat(g.toLocaleTimeString())))}function d(e,t){var n=document.createElement("li");return n.textContent=e,n.className=t||"",n}s.addEventListener("click",(function(){return u(o)})),i.forEach((function(e){e.addEventListener("change",(function(){return u(o)}))})),a.value&&u(o)}(); +!function(){"use strict";"function"==typeof SuppressedError&&SuppressedError;var e={_:"navigationStart",as:"activationStart",rs:"redirectStart",re:"redirectEnd",fs:"fetchStart",ds:"domainLookupStart",de:"domainLookupEnd",cs:"connectStart",sc:"secureConnectionStart",ce:"connectEnd",qs:"requestStart",bs:"responseStart",be:"responseEnd",oi:"domInteractive",os:"domContentLoadedEventStart",oe:"domContentLoadedEventEnd",oc:"domComplete",ls:"loadEventStart",le:"loadEventEnd",sr:"startRender",fc:"firstContentfulPaint",lc:"largestContentfulPaint"};function t(e){return e.map(function(e){return JSON.stringify(e)}).join(", ")}var n=document.querySelector("#input"),r=document.querySelector("#event-counter"),a=document.querySelector("#output"),c=document.querySelector("#parse"),o=document.querySelectorAll(".event-filter");if(!n||!a||!c)throw new Error("Cannot start debug parser.");function s(a){a.innerHTML="";var c=[];try{c=JSON.parse(n.value)}catch(e){a.appendChild(i("Could not parse input: ".concat(e),"red"))}var s=c.filter(function(e){return 23===e[1]});r.innerText="(".concat(c.length," events; ").concat(s.length," page views)");for(var u=Number(new Date(c[0][0])),d=0,l=c;d0&&(a+=" Minimum measure time was ".concat(r[1])),a;case 21:return"Sample rate is ".concat(r[0],"%. This session is being sampled.");case 22:return"Sample rate is ".concat(r[0],"%. This session is not being sampled.");case 23:return a="đŸ“Ģ Main beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 24:return a="📧 Supplementary user timing beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 25:return a="📧 Supplementary user interaction beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 26:return a="📧 Supplementary custom data beacon sent",n.includes("beaconUrl")&&(a+=": ".concat(r[0])),a;case 80:return"POST beacon initialised.";case 81:return"POST beacon send() called.";case 82:return"POST beacon maximum measure timeout reached.";case 83:return n.includes("beaconUrl")?"đŸ“Ģ POST beacon sent: ".concat(r[0]):"đŸ“Ģ POST beacon sent.";case 89:return n.includes("beaconUrl")?"âš ī¸ POST beacon send failed: ".concat(r[0]):"âš ī¸ POST beacon send failed.";case 85:return"POST beacon cancelled.";case 86:return"POST beacon is no longer recording metrics. Metrics received after this point may be ignored.";case 87:return"POST beacon metric rejected: ".concat(r[0]);case 90:return"POST beacon cancelled due to CSP violation.";case 91:return n.includes("metrics")?"POST beacon metric collector: ".concat(r[0]," (has data: ").concat(r[1],")"):"";case 41:return"";case 42:return n.includes("metrics")?"layout-shift"===r[0].entryType?"Received layout shift at ".concat(r[0].startTime.toFixed()," ms with value of ").concat(r[0].value.toFixed(3)):"longtask"===r[0].entryType?"Received long task with duration of ".concat(r[0].duration," ms"):"event"===r[0].entryType?0===r[0].interactionId?"Ignored INP entry with no interaction ID":"Received INP entry with duration of ".concat(r[0].duration," ms (ID: ").concat(r[0].interactionId,")"):"first-input"===r[0].entryType?"Received FID entry with duration of ".concat(r[0].duration," ms"):"largest-contentful-paint"===r[0].entryType?"Received LCP entry at ".concat(r[0].startTime.toFixed()," ms"):"element"===r[0].entryType?"Received element timing entry for ".concat(r[0].identifier," at ").concat(r[0].startTime.toFixed()," ms"):(a="Received ".concat(r[0].entryType," entry"),r[0].startTime&&(a+=" at ".concat(r[0].startTime.toFixed()," ms")),a):"";case 43:return n.includes("metrics")&&"largest-contentful-paint"===r[0].entryType?"Picked LCP from entry at ".concat(r[0].startTime.toFixed()," ms"):"";case 51:return"Error while initialising PerformanceObserver: ".concat(r[0]);case 52:return"Error reading input event. Cannot calculate FID for this page.";case 53:return"Cannot read the innerHTML property of an element. Cannot calculate inline style or script sizes for this page.";case 54:return"Error reading input event. Cannot calculate user interaction times for this page.";case 55:return"Error reading session cookie. This page will not be linked to a user session.";case 56:return"Error setting session cookie. This page will not be linked to a user session.";case 57:return"Error while evaluating '".concat(r[0],"' for the page label: ").concat(r[1]);case 71:return"The Navigation Timing API is not supported. Performance metrics for this page will be limited.";case 72:return"Start render time could not be determined."}return a}(n,g),v=n[2];if(m){if(function(e){return[23,26,25,24].includes(e)}(n[1])){(P=i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m))).classList.add("tooltip-container");var b=new URL(v[0]),h=(d=(o=b,s="NT",o.searchParams.get(s)||"").match(/([a-z]+)?[0-9]+/g))?Object.fromEntries(d.map(function(t){var n=t.match(/[a-z]+/);return[n?e[n[0]]:"navigationStart",parseFloat(t.match(/\d+/)[0])]})):{};(T=document.createElement("div")).className="tooltip",T.innerHTML='\n
    \n Page label: '.concat(b.searchParams.get("l"),"
    \n Hostname: ").concat(b.searchParams.get("HN"),"
    \n Path: ").concat(b.searchParams.get("PN"),"
    \n lux.js version: ").concat(b.searchParams.get("v"),"
    \n
    \n LCP: ").concat(h.largestContentfulPaint,"
    \n CLS: ").concat(b.searchParams.get("DCLS"),"
    \n INP: ").concat(b.searchParams.get("INP"),"
    \n FID: ").concat(b.searchParams.get("FID"),"
    \n
    \n "),P.appendChild(T),a.appendChild(P)}else if(1===n[1]){(P=i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m," Hover to view configuration."))).classList.add("tooltip-container");var S=v[1];try{S=JSON.parse(S)}catch(e){}(T=document.createElement("div")).className="tooltip",T.innerHTML='\n
    \n
    '.concat(JSON.stringify(S,null,4),"
    \n
    \n "),P.appendChild(T),a.appendChild(P)}else if(83===n[1]){var P;(P=i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m," Hover to view data."))).classList.add("tooltip-container");var T,C=v[1];try{C=JSON.parse(C)}catch(e){}(T=document.createElement("div")).className="tooltip",T.innerHTML='\n
    \n
    '.concat(JSON.stringify(C,null,4),"
    \n
    \n "),P.appendChild(T),a.appendChild(P)}else a.appendChild(i("".concat((new Intl.NumberFormat).format(l)," ms: ").concat(m)));if(9===n[1]&&(f=!0),3===n[1]&&(p=n[0],f=!1),7===n[1])n[0]-p<1e3&&a.appendChild(i("".concat((new Intl.NumberFormat).format(l)," ms: âš ī¸ Data was gathered for less than 1 second. Consider increasing the value of LUX.minMeasureTime.")));if(f&&42===n[1])42!==c[r+1][1]&&a.appendChild(i("".concat((new Intl.NumberFormat).format(l)," ms: âš ī¸ Performance entries were received after the beacon was sent.")))}});var v=new Date(u);a.prepend(i("0 ms: đŸŸĸ Navigation started at ".concat(v.toLocaleDateString()," ").concat(v.toLocaleTimeString())))}function i(e,t){var n=document.createElement("li");return n.textContent=e,n.className=t||"",n}c.addEventListener("click",function(){return s(a)}),o.forEach(function(e){e.addEventListener("change",function(){return s(a)})}),n.value&&s(a)}(); diff --git a/docs/debug-parser/events.ts b/docs/debug-parser/events.ts index c58a82c..d3b811e 100644 --- a/docs/debug-parser/events.ts +++ b/docs/debug-parser/events.ts @@ -14,7 +14,23 @@ export function isBeaconEvent(event: LogEvent) { ].includes(event); } +function isVerboseEvent(event: LogEvent) { + return [ + LogEvent.DataCollectionStart, + LogEvent.PostBeaconCancelled, + LogEvent.PostBeaconCSPViolation, + LogEvent.PostBeaconInitialised, + LogEvent.PostBeaconSendCalled, + LogEvent.PostBeaconStopRecording, + LogEvent.PostBeaconTimeoutReached, + ].includes(event); +} + export function getMessageForEvent(event: LogEventRecord, filters: string[]): string { + if (isVerboseEvent(event[1]) && !filters.includes("verbose")) { + return ""; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const args = event[2] as any[]; @@ -26,20 +42,23 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st } switch (event[1]) { - case 0: + case 0 as LogEvent: return "The lux.js script was not loaded on this page."; case LogEvent.EvaluationStart: - return `lux.js v${args[0]} is initialising.`; + return `lux.js v${args[0]} init begin.`; case LogEvent.EvaluationEnd: - return "lux.js has finished initialising."; + return "lux.js init complete."; case LogEvent.InitCalled: - return "LUX.init()"; + return "đŸŸĸ LUX.init() - new page view started"; + + case LogEvent.StartSoftNavigationCalled: + return "đŸŸĸ LUX.startSoftNavigation() - new page view started"; case LogEvent.MarkLoadTimeCalled: - return `LUX.markLoadTime(${argsAsString(args)})`; + return `đŸŽ¯ LUX.markLoadTime(${argsAsString(args)})`; case LogEvent.MarkCalled: if (filters.includes("userTiming")) { @@ -71,6 +90,9 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st case LogEvent.SendCancelledPageHidden: return "This beacon was not sent because the page visibility was hidden."; + case LogEvent.SendCancelledSpaMode: + return "â„šī¸ LUX.send() was ignored because SPA Mode is enabled."; + case LogEvent.ForceSampleCalled: return "LUX.forceSample()"; @@ -78,7 +100,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return "Preparing to send main beacon. Metrics received after this point may be ignored."; case LogEvent.UnloadHandlerTriggered: - return "Unload handler was triggered."; + return "â¤´ī¸ Unload handler was triggered."; case LogEvent.OnloadHandlerTriggered: message = `Onload handler was triggered after ${args[0]} ms.`; @@ -96,7 +118,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return `Sample rate is ${args[0]}%. This session is not being sampled.`; case LogEvent.MainBeaconSent: - message = "Main beacon sent"; + message = "đŸ“Ģ Main beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -105,7 +127,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.UserTimingBeaconSent: - message = "Supplementary user timing beacon sent"; + message = "📧 Supplementary user timing beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -114,7 +136,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.InteractionBeaconSent: - message = "Supplementary user interaction beacon sent"; + message = "📧 Supplementary user interaction beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -123,7 +145,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.CustomDataBeaconSent: - message = "Supplementary custom data beacon sent"; + message = "📧 Supplementary custom data beacon sent"; if (filters.includes("beaconUrl")) { message += `: ${args[0]}`; @@ -142,17 +164,17 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st case LogEvent.PostBeaconSent: if (filters.includes("beaconUrl")) { - return `POST beacon sent: ${args[0]}`; + return `đŸ“Ģ POST beacon sent: ${args[0]}`; } - return "POST beacon sent."; + return "đŸ“Ģ POST beacon sent."; case LogEvent.PostBeaconSendFailed: if (filters.includes("beaconUrl")) { - return `POST beacon send failed: ${args[0]}`; + return `âš ī¸ POST beacon send failed: ${args[0]}`; } - return "POST beacon send failed."; + return "âš ī¸ POST beacon send failed."; case LogEvent.PostBeaconCancelled: return "POST beacon cancelled."; @@ -167,12 +189,20 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return "POST beacon cancelled due to CSP violation."; case LogEvent.PostBeaconCollector: - return `POST beacon metric collector: ${args[0]} (has data: ${args[1]})`; + if (filters.includes("metrics")) { + return `POST beacon metric collector: ${args[0]} (has data: ${args[1]})`; + } + + return ""; case LogEvent.NavigationStart: return ""; case LogEvent.PerformanceEntryReceived: + if (!filters.includes("metrics")) { + return ""; + } + if (args[0].entryType === "layout-shift") { return `Received layout shift at ${args[0].startTime.toFixed()} ms with value of ${args[0].value.toFixed( 3, @@ -204,7 +234,7 @@ export function getMessageForEvent(event: LogEventRecord, filters: string[]): st return message; case LogEvent.PerformanceEntryProcessed: - if (args[0].entryType === "largest-contentful-paint") { + if (filters.includes("metrics") && args[0].entryType === "largest-contentful-paint") { return `Picked LCP from entry at ${args[0].startTime.toFixed()} ms`; } @@ -269,10 +299,8 @@ function getEventName(event: LogEvent) { return "MarkLoadTimeCalled"; case LogEvent.SendCancelledPageHidden: return "SendCancelledPageHidden"; - case LogEvent.TriggerSoftNavigationCalled: - return "TriggerSoftNavigationCalled"; - case LogEvent.SendTriggeredBySoftNavigation: - return "SendTriggeredBySoftNavigation"; + case LogEvent.StartSoftNavigationCalled: + return "StartSoftNavigationCalled"; case LogEvent.SendCancelledSpaMode: return "SendCancelledSpaMode"; case LogEvent.BfCacheRestore: diff --git a/docs/debug-parser/index.ts b/docs/debug-parser/index.ts index d26bb15..eef5f40 100644 --- a/docs/debug-parser/index.ts +++ b/docs/debug-parser/index.ts @@ -34,7 +34,9 @@ function renderOutput(output: Element) { output.appendChild(li(`Could not parse input: ${err}`, "red")); } - eventCounter.innerText = `(${inputEvents.length} events)`; + const sentBeacons = inputEvents.filter((event) => event[1] === LogEvent.MainBeaconSent); + + eventCounter.innerText = `(${inputEvents.length} events; ${sentBeacons.length} page views)`; let navigationStart = Number(new Date(inputEvents[0][0])); @@ -183,7 +185,7 @@ function renderOutput(output: Element) { output.prepend( li( - `0 ms: Navigation started at ${startTime.toLocaleDateString()} ${startTime.toLocaleTimeString()}`, + `0 ms: đŸŸĸ Navigation started at ${startTime.toLocaleDateString()} ${startTime.toLocaleTimeString()}`, ), ); } diff --git a/src/logger.ts b/src/logger.ts index 9e52ce6..22bd8d1 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -15,8 +15,7 @@ export const enum LogEvent { OnloadHandlerTriggered = 11, MarkLoadTimeCalled = 12, SendCancelledPageHidden = 13, - TriggerSoftNavigationCalled = 14, - SendTriggeredBySoftNavigation = 15, + StartSoftNavigationCalled = 14, SendCancelledSpaMode = 16, BfCacheRestore = 17, InitCallIgnored = 18, @@ -62,12 +61,12 @@ export const enum LogEvent { PostBeaconCollector = 91, } -export type LogEventRecord = [number, number, ...unknown[]]; +export type LogEventRecord = [number, LogEvent, ...unknown[]]; export default class Logger { events: LogEventRecord[] = []; - logEvent(event: number, args: unknown[] = []) { + logEvent(event: LogEvent, args: unknown[] = []) { this.events.push([now(), event, args]); } diff --git a/src/lux.ts b/src/lux.ts index e8ff2f7..9e18321 100644 --- a/src/lux.ts +++ b/src/lux.ts @@ -2080,7 +2080,7 @@ LUX = (function () { }; globalLux.startSoftNavigation = (time?: number): void => { - logger.logEvent(LogEvent.TriggerSoftNavigationCalled); + logger.logEvent(LogEvent.StartSoftNavigationCalled); beacon.send(); _sendLux(); _init(time); diff --git a/src/snippet.ts b/src/snippet.ts index 646e910..d7b21fd 100644 --- a/src/snippet.ts +++ b/src/snippet.ts @@ -1,4 +1,5 @@ import type { Command, LuxGlobal } from "./global"; +import { LogEvent } from "./logger"; import { performance } from "./performance"; import scriptStartTime from "./start-marker"; import { msSinceNavigationStart } from "./timing"; @@ -15,7 +16,7 @@ LUX = window.LUX || ({} as LuxGlobal); LUX.ac = []; LUX.addData = (name, value) => LUX.cmd(["addData", name, value]); LUX.cmd = (cmd: Command) => LUX.ac!.push(cmd); -LUX.getDebug = () => [[scriptStartTime, 0, []]]; +LUX.getDebug = () => [[scriptStartTime, 0 as LogEvent, []]]; LUX.init = (time?: number) => LUX.cmd(["init", time || msSinceNavigationStart()]); LUX.mark = _mark; LUX.markLoadTime = () => LUX.cmd(["markLoadTime", msSinceNavigationStart()]);