From cf49e1b79eacde4dd80be3d26273c8e726c40966 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 05:22:27 -0700 Subject: [PATCH 01/32] feat(rum-legacy): add ES5 build target and compatibility gate Introduce packages/rum-legacy, a CDN-only bundle for browsers without ES2015 support. This commit sets up the toolchain only; collection and transport follow. The package does not extend tsconfig.base.json on purpose. Restricting "lib" to ES5 + DOM turns a missing runtime API into a compile error rather than a crash on the target browsers, and an empty "paths" map keeps @flashcatcloud/* imports unresolvable, since those packages are authored against ES2018. check-es5-compatibility.js parses the emitted bundle with acorn at ecmaVersion 5. It also asserts that the modern bundles are rejected: if a misconfiguration made the parser accept everything, the positive assertion alone would still pass and the gate would silently stop protecting anything. Console access is looked up lazily instead of captured at module evaluation, because in IE9 window.console does not exist until the developer tools are opened, and its methods are host objects without bind(). --- eslint-local-rules/disallowSideEffects.js | 1 + package.json | 2 + packages/rum-legacy/package.json | 24 ++ packages/rum-legacy/src/boot/global.spec.ts | 62 ++++ packages/rum-legacy/src/boot/global.ts | 38 ++ packages/rum-legacy/src/entries/main.ts | 23 ++ packages/rum-legacy/src/tools/display.ts | 32 ++ packages/rum-legacy/tsconfig.json | 30 ++ packages/rum-legacy/webpack.config.js | 64 ++++ scripts/check-es5-compatibility.js | 75 ++++ scripts/deploy/lib/deploymentUtils.js | 1 + yarn.lock | 390 ++------------------ 12 files changed, 373 insertions(+), 369 deletions(-) create mode 100644 packages/rum-legacy/package.json create mode 100644 packages/rum-legacy/src/boot/global.spec.ts create mode 100644 packages/rum-legacy/src/boot/global.ts create mode 100644 packages/rum-legacy/src/entries/main.ts create mode 100644 packages/rum-legacy/src/tools/display.ts create mode 100644 packages/rum-legacy/tsconfig.json create mode 100644 packages/rum-legacy/webpack.config.js create mode 100644 scripts/check-es5-compatibility.js diff --git a/eslint-local-rules/disallowSideEffects.js b/eslint-local-rules/disallowSideEffects.js index d17dba4ce8..dfe434fda8 100644 --- a/eslint-local-rules/disallowSideEffects.js +++ b/eslint-local-rules/disallowSideEffects.js @@ -30,6 +30,7 @@ const pathsWithSideEffect = new Set([ `${packagesRoot}/flagging/src/entries/main.ts`, `${packagesRoot}/rum/src/entries/main.ts`, `${packagesRoot}/rum-slim/src/entries/main.ts`, + `${packagesRoot}/rum-legacy/src/entries/main.ts`, ]) // Those packages are known to have no side effects when evaluated diff --git a/package.json b/package.json index c58783c316..5fa6f235fb 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:e2e:ci": "yarn test:e2e:init && yarn test:e2e", "test:e2e:ci:bs": "yarn build && yarn build:apps && yarn test:e2e:bs", "test:compat:tsc": "node scripts/check-typescript-compatibility.js", + "test:compat:es5": "node scripts/check-es5-compatibility.js", "test:compat:ssr": "scripts/cli check_server_side_rendering_compatibility", "rum-events-format:sync": "scripts/cli update_submodule && scripts/cli build_json2type && node scripts/generate-schema-types.js", "size": "node scripts/show-bundle-size.js", @@ -49,6 +50,7 @@ "@types/express": "5.0.2", "@types/jasmine": "3.10.18", "@types/node": "22.15.19", + "acorn": "8.14.1", "ajv": "8.17.1", "ali-oss": "6.22.0", "browserstack-local": "1.5.6", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json new file mode 100644 index 0000000000..69f3a7c891 --- /dev/null +++ b/packages/rum-legacy/package.json @@ -0,0 +1,24 @@ +{ + "name": "@flashcatcloud/browser-rum-legacy", + "version": "0.0.2", + "license": "Apache-2.0", + "private": true, + "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", + "scripts": { + "build": "yarn build:bundle", + "build:bundle": "rm -rf bundle && SDK_SETUP=cdn webpack --mode=production", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "terser-webpack-plugin": "5.3.14", + "webpack": "5.99.8" + }, + "repository": { + "type": "git", + "url": "https://github.com/flashcatcloud/browser-sdk", + "directory": "packages/rum-legacy" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/rum-legacy/src/boot/global.spec.ts b/packages/rum-legacy/src/boot/global.spec.ts new file mode 100644 index 0000000000..78b3e3c922 --- /dev/null +++ b/packages/rum-legacy/src/boot/global.spec.ts @@ -0,0 +1,62 @@ +import type { QueuedGlobal } from './global' +import { defineGlobal } from './global' + +describe('defineGlobal', () => { + let host: { FC_RUM?: QueuedGlobal } + const api = { version: 'test' } + + beforeEach(() => { + host = {} + }) + + it('exposes the api on the host object', () => { + defineGlobal(host, 'FC_RUM', api) + + expect(host.FC_RUM).toBe(api) + }) + + it('runs callbacks queued by the loader snippet before the bundle arrived', () => { + const calls: string[] = [] + host.FC_RUM = { q: [() => calls.push('first'), () => calls.push('second')] } + + defineGlobal(host, 'FC_RUM', api) + + expect(calls).toEqual(['first', 'second']) + }) + + it('runs queued callbacks against the real api, not the placeholder', () => { + let seen: unknown + host.FC_RUM = { q: [() => (seen = host.FC_RUM)] } + + defineGlobal(host, 'FC_RUM', api) + + expect(seen).toBe(api) + }) + + it('keeps running the remaining callbacks when one of them throws', () => { + const calls: string[] = [] + host.FC_RUM = { + q: [ + () => { + throw new Error('customer callback is broken') + }, + () => calls.push('second'), + ], + } + + expect(() => defineGlobal(host, 'FC_RUM', api)).not.toThrow() + expect(calls).toEqual(['second']) + }) + + it('does not fail when no placeholder was set up', () => { + expect(() => defineGlobal(host, 'FC_RUM', api)).not.toThrow() + expect(host.FC_RUM).toBe(api) + }) + + it('does not fail when the placeholder has no queue', () => { + host.FC_RUM = { version: 'already-loaded' } + + expect(() => defineGlobal(host, 'FC_RUM', api)).not.toThrow() + expect(host.FC_RUM).toBe(api) + }) +}) diff --git a/packages/rum-legacy/src/boot/global.ts b/packages/rum-legacy/src/boot/global.ts new file mode 100644 index 0000000000..c4bc92985c --- /dev/null +++ b/packages/rum-legacy/src/boot/global.ts @@ -0,0 +1,38 @@ +import { displayError, displayWarn } from '../tools/display' + +/* + * Shape of the placeholder the loader snippet puts on `window` before either bundle has arrived: + * + * window.FC_RUM = window.FC_RUM || { q: [], onReady: function (c) { this.q.push(c) } } + * + * Note that `q` holds callbacks, matching what the modern bundle already drains. Queueing + * `['init', options]` tuples instead would leave the modern bundle with a queue nobody consumes, + * silently breaking initialisation on modern browsers. + */ +export interface QueuedGlobal { + q?: Array<() => void> + version?: string +} + +export function defineGlobal(host: Host, name: Name, api: Host[Name]): void { + const placeholder = host[name] as QueuedGlobal | undefined + + if (placeholder && !placeholder.q && placeholder.version) { + displayWarn('SDK is loaded more than once. This is unsupported and might have unexpected behavior.') + } + + host[name] = api + + if (placeholder && placeholder.q) { + const queue = placeholder.q + for (let i = 0; i < queue.length; i++) { + // A throwing customer callback must not prevent the remaining ones from running, and must + // never propagate out of the SDK into the host page. + try { + queue[i]() + } catch (error) { + displayError('onReady callback threw an error:', error) + } + } + } +} diff --git a/packages/rum-legacy/src/entries/main.ts b/packages/rum-legacy/src/entries/main.ts new file mode 100644 index 0000000000..80e44facd0 --- /dev/null +++ b/packages/rum-legacy/src/entries/main.ts @@ -0,0 +1,23 @@ +import { defineGlobal } from '../boot/global' + +// replaced at build time +declare const __BUILD_ENV__SDK_VERSION__: string + +interface BrowserWindow extends Window { + FC_RUM?: unknown +} + +export const flashcatRumLegacy = { + version: __BUILD_ENV__SDK_VERSION__, + + /** + * Kept for parity with the modern bundle: once this script has run the SDK is loaded, so the + * callback can be invoked straight away. The loader snippet's placeholder queues callbacks + * registered before that point, and `defineGlobal` drains them below. + */ + onReady(callback: () => void): void { + callback() + }, +} + +defineGlobal(window as BrowserWindow, 'FC_RUM', flashcatRumLegacy) diff --git a/packages/rum-legacy/src/tools/display.ts b/packages/rum-legacy/src/tools/display.ts new file mode 100644 index 0000000000..79c3b5ae8a --- /dev/null +++ b/packages/rum-legacy/src/tools/display.ts @@ -0,0 +1,32 @@ +/* + * Console access has to be defensive here, and it deliberately differs from the modern bundle's + * display module, which captures `console` and binds its methods once at module evaluation. + * + * - In IE9, `window.console` does not exist at all until the developer tools are opened. Capturing + * it at load time would permanently capture `undefined`, and a bare `console.log` throws and + * takes the host page down with it. Hence the lazy lookup on every call. + * - In IE9, the console methods are host objects rather than real functions: `typeof console.log` + * evaluates to 'object' and `console.log.bind` is undefined. Guarding with + * `typeof fn === 'function'` would silence logging on the exact browsers this build targets, and + * binding them throws. Hence the truthiness test and the direct call. + */ + +const PREFIX = '[FC_RUM]' + +function getConsole(): Console | undefined { + return typeof console !== 'undefined' && console ? console : undefined +} + +export function displayWarn(message: string): void { + const consoleRef = getConsole() + if (consoleRef && consoleRef.warn) { + consoleRef.warn(`${PREFIX} ${message}`) + } +} + +export function displayError(message: string, error?: unknown): void { + const consoleRef = getConsole() + if (consoleRef && consoleRef.error) { + consoleRef.error(`${PREFIX} ${message}`, error) + } +} diff --git a/packages/rum-legacy/tsconfig.json b/packages/rum-legacy/tsconfig.json new file mode 100644 index 0000000000..d464d91535 --- /dev/null +++ b/packages/rum-legacy/tsconfig.json @@ -0,0 +1,30 @@ +// This package deliberately does NOT extend tsconfig.base.json. +// +// Two settings below are load-bearing and would be silently lost by inheriting the base config: +// +// - "lib": ["ES5", "DOM"] turns "this API does not exist in the target browsers" from a runtime +// crash into a compile error. Promise, Map, Set, Object.assign, Array.from and friends simply +// do not resolve. Syntax is not the concern here (TypeScript downlevels it), missing runtime +// APIs are, and no bundler setting catches those. +// +// - "paths": {} keeps this package free of @flashcatcloud/* imports. The core and rum-core +// packages are authored against ES2018 and pull in Promise/Map/Set, so importing any of them +// defeats the purpose of this build. Without path mappings those imports fail to resolve. +{ + "compilerOptions": { + "baseUrl": ".", + "esModuleInterop": true, + "importHelpers": false, + "module": "ES2020", + "moduleResolution": "node", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES5", + "lib": ["ES5", "DOM"], + "types": [], + "paths": {} + }, + "include": ["src"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/rum-legacy/webpack.config.js b/packages/rum-legacy/webpack.config.js new file mode 100644 index 0000000000..db8feffd24 --- /dev/null +++ b/packages/rum-legacy/webpack.config.js @@ -0,0 +1,64 @@ +const path = require('path') +const webpack = require('webpack') +const TerserPlugin = require('terser-webpack-plugin') +const { getBuildEnvValue } = require('../../scripts/lib/buildEnv') + +// This config does not reuse webpack.base.js: that one is pinned to ES2018 in three places +// (webpack target, ts-loader config file and Terser `ecma`), which is exactly what this build has +// to move away from. +module.exports = (_env, argv) => ({ + entry: path.resolve(__dirname, 'src/entries/main.ts'), + mode: argv.mode, + output: { + filename: 'fc-rum-legacy.js', + path: path.resolve(__dirname, 'bundle'), + }, + target: ['web', 'es5'], + devtool: false, + module: { + rules: [ + { + test: /\.ts$/, + loader: 'ts-loader', + exclude: /node_modules/, + options: { + configFile: path.resolve(__dirname, 'tsconfig.json'), + onlyCompileBundledFiles: true, + }, + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, + optimization: { + minimizer: [ + new TerserPlugin({ + extractComments: false, + terserOptions: { + // Without this, Terser happily "optimizes" the ES5 input back into arrow functions and + // shorthand syntax, undoing the whole point of the build. + ecma: 5, + module: false, + compress: { + passes: 3, + }, + format: { + ecma: 5, + }, + }, + }), + ], + }, + plugins: [ + new webpack.SourceMapDevToolPlugin({ + filename: '[file].map', + append: false, + }), + new webpack.DefinePlugin({ + __BUILD_ENV__SDK_VERSION__: webpack.DefinePlugin.runtimeValue(() => + JSON.stringify(getBuildEnvValue('SDK_VERSION')) + ), + }), + ], +}) diff --git a/scripts/check-es5-compatibility.js b/scripts/check-es5-compatibility.js new file mode 100644 index 0000000000..437fcb09a6 --- /dev/null +++ b/scripts/check-es5-compatibility.js @@ -0,0 +1,75 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const acorn = require('acorn') +const { printLog, printError, runMain } = require('./lib/executionUtils') + +const ROOT_DIR = path.join(__dirname, '..') + +/** + * The legacy bundle targets browsers without ES2015 support, so it must parse as ES5. Nothing but a + * parser can tell us that: a single arrow function or `const` left anywhere in the output makes the + * whole script fail to load, before any feature detection inside the SDK gets a chance to run. + * + * The modern bundles are checked the other way around. If a configuration mistake made the parser + * accept everything, the ES5 assertion below would still pass and the gate would silently stop + * protecting anything. Asserting that the modern bundles are rejected keeps the gate honest. + */ +const EXPECTED_ES5 = ['packages/rum-legacy/bundle/fc-rum-legacy.js'] + +const EXPECTED_NOT_ES5 = ['packages/rum/bundle/flashcat-rum.js', 'packages/rum-slim/bundle/flashcat-rum-slim.js'] + +runMain(() => { + const failures = [] + + for (const relativePath of EXPECTED_ES5) { + const result = parseAsEs5(relativePath) + if (result.missing) { + failures.push(`${relativePath}: not found, build it before running this check`) + } else if (result.error) { + failures.push(`${relativePath}: expected to parse as ES5, but failed at ${formatError(result.error)}`) + } else { + printLog(`✅ ${relativePath} parses as ES5`) + } + } + + for (const relativePath of EXPECTED_NOT_ES5) { + const result = parseAsEs5(relativePath) + if (result.missing) { + printLog(`⏭️ ${relativePath} not built, skipping self-check`) + } else if (result.error) { + printLog(`✅ ${relativePath} is rejected as ES5, the check is able to detect newer syntax`) + } else { + failures.push( + `${relativePath}: parsed as ES5, which is impossible for an ES2018 bundle. The ES5 check is not working.` + ) + } + } + + if (failures.length > 0) { + printError('ES5 compatibility check failed:') + for (const failure of failures) { + printError(` - ${failure}`) + } + process.exit(1) + } +}) + +function parseAsEs5(relativePath) { + const absolutePath = path.join(ROOT_DIR, relativePath) + if (!fs.existsSync(absolutePath)) { + return { missing: true } + } + + try { + acorn.parse(fs.readFileSync(absolutePath, 'utf-8'), { ecmaVersion: 5 }) + return {} + } catch (error) { + return { error } + } +} + +function formatError(error) { + return typeof error.loc?.line === 'number' ? `line ${error.loc.line}: ${error.message}` : error.message +} diff --git a/scripts/deploy/lib/deploymentUtils.js b/scripts/deploy/lib/deploymentUtils.js index 992aee6d46..1d73395397 100644 --- a/scripts/deploy/lib/deploymentUtils.js +++ b/scripts/deploy/lib/deploymentUtils.js @@ -2,6 +2,7 @@ const packages = [ { packageName: 'logs', service: 'browser-logs-sdk' }, { packageName: 'rum', service: 'browser-rum-sdk' }, { packageName: 'rum-slim', service: 'browser-rum-sdk' }, + { packageName: 'rum-legacy', service: 'browser-rum-sdk' }, ] // ex: datadog-rum-v4.js, chunks/recorder-8d8a8dfab6958424038f-datadog-rum.js diff --git a/yarn.lock b/yarn.lock index 807d1134be..4cf8f4b170 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,212 +15,6 @@ __metadata: languageName: node linkType: hard -"@alicloud/credentials@npm:^2, @alicloud/credentials@npm:^2.4.2, @alicloud/credentials@npm:latest": - version: 2.4.3 - resolution: "@alicloud/credentials@npm:2.4.3" - dependencies: - "@alicloud/tea-typescript": "npm:^1.8.0" - httpx: "npm:^2.3.3" - ini: "npm:^1.3.5" - kitx: "npm:^2.0.0" - checksum: 10c0/a1f49a7b70f87325561bf4632f60bad21174fc38da74a0076b1bc8d8e7a20fcf960d86d3960f9494050bb1ccead98cb12ca01f3bc21857f10556915692645e59 - languageName: node - linkType: hard - -"@alicloud/darabonba-array@npm:^0.1.0": - version: 0.1.1 - resolution: "@alicloud/darabonba-array@npm:0.1.1" - dependencies: - "@alicloud/tea-typescript": "npm:^1.7.1" - checksum: 10c0/fe02153505398e3c0b31c73b5e4e15e23b988746b1f699421b69e8298cc573f436e7d2ee92ba9bd9f3e0910c84603302545f29ace77f43935bc8430d8161e002 - languageName: node - linkType: hard - -"@alicloud/darabonba-encode-util@npm:^0.0.1": - version: 0.0.1 - resolution: "@alicloud/darabonba-encode-util@npm:0.0.1" - dependencies: - "@alicloud/tea-typescript": "npm:^1.7.1" - moment: "npm:^2.29.1" - checksum: 10c0/81f1cca815e6d6a9e75fb52f719cc9247a3bedf2da546ddad5b2ed07e5bf9be0041229a474449b2b93da9044c8a2fdb1256c0e3f9b4fceb4b5c4a6ecfba105e2 - languageName: node - linkType: hard - -"@alicloud/darabonba-encode-util@npm:^0.0.2": - version: 0.0.2 - resolution: "@alicloud/darabonba-encode-util@npm:0.0.2" - dependencies: - moment: "npm:^2.29.1" - checksum: 10c0/fb0fefcdc72b033bd4acb986dbc8f0dd893c0cf3952db5be8b0ba88721a4bb1702cfeb57cb04dc19a31bf6d95f155a71c282db8702cbfb7cc0f109202efb6669 - languageName: node - linkType: hard - -"@alicloud/darabonba-map@npm:^0.0.1": - version: 0.0.1 - resolution: "@alicloud/darabonba-map@npm:0.0.1" - dependencies: - "@alicloud/tea-typescript": "npm:^1.7.1" - checksum: 10c0/98796892ac3222bb4d152e786ac33ed09b5ade7930b6d1c9cd101b176cf7d1880a70b085eb7efaf8bc691316595f1b4b0989162c56e979d445e9e40421ee501f - languageName: node - linkType: hard - -"@alicloud/darabonba-signature-util@npm:^0.0.4": - version: 0.0.4 - resolution: "@alicloud/darabonba-signature-util@npm:0.0.4" - dependencies: - "@alicloud/darabonba-encode-util": "npm:^0.0.1" - checksum: 10c0/b26b2a5b5fefa823c415259623cdfef2d50b0fc7aeec919f53e52f72e0db3f0b7fe51699a76c8a48556b3ad3f0e0d7c5584f61f12c827ef03b2bb3d94874526c - languageName: node - linkType: hard - -"@alicloud/darabonba-string@npm:^1.0.2": - version: 1.0.3 - resolution: "@alicloud/darabonba-string@npm:1.0.3" - dependencies: - "@alicloud/tea-typescript": "npm:^1.5.1" - checksum: 10c0/ba1ce6617ad0cbf28418db74fa62cf9f5c313a54b8caeccc88a66dc7954e41a0076e3e3107300f59baa04eea14e944bb3c202e77445e33909fced91f35779889 - languageName: node - linkType: hard - -"@alicloud/endpoint-util@npm:^0.0.1": - version: 0.0.1 - resolution: "@alicloud/endpoint-util@npm:0.0.1" - dependencies: - "@alicloud/tea-typescript": "npm:^1.5.1" - kitx: "npm:^2.0.0" - checksum: 10c0/3f6476efd8699103c2906366749747330a605bad3c7a5bf544a311204a6b6749a536e8ebfe961337f79798f577a116e1a94593d10cf3778fe21785003f673675 - languageName: node - linkType: hard - -"@alicloud/gateway-pop@npm:0.0.6": - version: 0.0.6 - resolution: "@alicloud/gateway-pop@npm:0.0.6" - dependencies: - "@alicloud/credentials": "npm:^2" - "@alicloud/darabonba-array": "npm:^0.1.0" - "@alicloud/darabonba-encode-util": "npm:^0.0.2" - "@alicloud/darabonba-map": "npm:^0.0.1" - "@alicloud/darabonba-signature-util": "npm:^0.0.4" - "@alicloud/darabonba-string": "npm:^1.0.2" - "@alicloud/endpoint-util": "npm:^0.0.1" - "@alicloud/gateway-spi": "npm:^0.0.8" - "@alicloud/openapi-util": "npm:^0.3.2" - "@alicloud/tea-typescript": "npm:^1.7.1" - "@alicloud/tea-util": "npm:^1.4.8" - checksum: 10c0/59cbea2d906adc613ab4421ee85efa48191e74f138a7e1afbc514bcf48b39f24eff6f072bb8b2c2b642ac1f12ad3ecce7e96cbc1e4ab3baa618d1210f96fd04d - languageName: node - linkType: hard - -"@alicloud/gateway-spi@npm:^0.0.8": - version: 0.0.8 - resolution: "@alicloud/gateway-spi@npm:0.0.8" - dependencies: - "@alicloud/credentials": "npm:^2" - "@alicloud/tea-typescript": "npm:^1.7.1" - checksum: 10c0/6d585aced75b874d1407b168f5230ff0511a30ac1620b759da9f58a3d1220d8b159941c5121ccfad7d2e64636d095f4569b1557af90b644c5f5aefe3c0bf76cc - languageName: node - linkType: hard - -"@alicloud/openapi-client@npm:0.4.13": - version: 0.4.13 - resolution: "@alicloud/openapi-client@npm:0.4.13" - dependencies: - "@alicloud/credentials": "npm:^2.4.2" - "@alicloud/gateway-spi": "npm:^0.0.8" - "@alicloud/openapi-util": "npm:^0.3.2" - "@alicloud/tea-typescript": "npm:^1.7.1" - "@alicloud/tea-util": "npm:1.4.9" - "@alicloud/tea-xml": "npm:0.0.3" - checksum: 10c0/db7f2d3786476a92fcb3b479d4b35d08ca2816aec1296541ca64ba5a5945d14bd38e26503badbece8b0f3134e837de35aaf56ce17fa2a086068f6437e42ee756 - languageName: node - linkType: hard - -"@alicloud/openapi-core@npm:^1.0.0": - version: 1.0.4 - resolution: "@alicloud/openapi-core@npm:1.0.4" - dependencies: - "@alicloud/credentials": "npm:latest" - "@alicloud/gateway-pop": "npm:0.0.6" - "@alicloud/gateway-spi": "npm:^0.0.8" - "@darabonba/typescript": "npm:^1.0.2" - checksum: 10c0/83beebe993bc4307ef1fed8f608fef63ef2bd569c9157555a1235d535212e6816bd751f6ad29ac4363e2c872517d0da2649885668fc8b8e5b6e75b0bdaf030e4 - languageName: node - linkType: hard - -"@alicloud/openapi-util@npm:^0.3.2": - version: 0.3.2 - resolution: "@alicloud/openapi-util@npm:0.3.2" - dependencies: - "@alicloud/tea-typescript": "npm:^1.7.1" - "@alicloud/tea-util": "npm:^1.3.0" - kitx: "npm:^2.1.0" - sm3: "npm:^1.0.3" - checksum: 10c0/1cdb89d59512fa2f75bc802ba7e662643aba9bc6d8ae7b9ffe7bbca7f5a7265745be19410c7f9059be0d419dbc2e4989ca374b3f4fba6c74d7734f19f51f064a - languageName: node - linkType: hard - -"@alicloud/tea-typescript@npm:^1, @alicloud/tea-typescript@npm:^1.5.1, @alicloud/tea-typescript@npm:^1.7.1, @alicloud/tea-typescript@npm:^1.8.0": - version: 1.8.0 - resolution: "@alicloud/tea-typescript@npm:1.8.0" - dependencies: - "@types/node": "npm:^12.0.2" - httpx: "npm:^2.2.6" - checksum: 10c0/72d894747e1bb176d5159f00c0a79f3bb0704ab6e0fe31ed9d3c899659d8365feea5c2bfa42914e2e3a0ebdcb7584a1f90f011a260cba66eca3d457f03cd96ba - languageName: node - linkType: hard - -"@alicloud/tea-util@npm:1.4.9": - version: 1.4.9 - resolution: "@alicloud/tea-util@npm:1.4.9" - dependencies: - "@alicloud/tea-typescript": "npm:^1.5.1" - kitx: "npm:^2.0.0" - checksum: 10c0/1976e34d5bef689eb6de81b2b4075b9d36faab18cae2087ade6d28d28b0b395302d28863d22437cf760d22cc509c4c3891ba01997d0e7c07e6cd31454b1e5375 - languageName: node - linkType: hard - -"@alicloud/tea-util@npm:^1.3.0, @alicloud/tea-util@npm:^1.4.8": - version: 1.4.10 - resolution: "@alicloud/tea-util@npm:1.4.10" - dependencies: - "@alicloud/tea-typescript": "npm:^1.5.1" - "@darabonba/typescript": "npm:^1.0.0" - kitx: "npm:^2.0.0" - checksum: 10c0/e0fb40494044a7ad7f2a9f0f61d8d00bfa7bd02c321071aab0fa2ab353b7ee959547bb35b630b65d3e9229660b1b61e516dda7e29800d999b34e32fb6d8cc214 - languageName: node - linkType: hard - -"@alicloud/tea-xml@npm:0.0.3": - version: 0.0.3 - resolution: "@alicloud/tea-xml@npm:0.0.3" - dependencies: - "@alicloud/tea-typescript": "npm:^1" - "@types/xml2js": "npm:^0.4.5" - xml2js: "npm:^0.6.0" - checksum: 10c0/9f0ad91ba9221a867a60d120a83bbd4d67e6c6908aa73bb20fe0b80885b5707256b6bb4953204e2e1f0a329f09ecb2dc235899e809177273bc9562d6353d83d0 - languageName: node - linkType: hard - -"@ampproject/remapping@npm:^2.2.0": - version: 2.3.0 - resolution: "@ampproject/remapping@npm:2.3.0" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed - languageName: node - linkType: hard - -"@alicloud/cdn20180510@npm:5.0.0": - version: 5.0.0 - resolution: "@alicloud/cdn20180510@npm:5.0.0" - dependencies: - "@alicloud/openapi-core": "npm:^1.0.0" - "@darabonba/typescript": "npm:^1.0.0" - checksum: 10c0/41ef701053fcffb1507be13b12161e2a8a1a6495bb70779b100ce540ad7c03771105bdf97020e9914324e7164ae2a296e369434cb143a114118f23882325df77 - languageName: node - linkType: hard - "@alicloud/credentials@npm:^2, @alicloud/credentials@npm:^2.4.2": version: 2.4.2 resolution: "@alicloud/credentials@npm:2.4.2" @@ -406,6 +200,16 @@ __metadata: languageName: node linkType: hard +"@ampproject/remapping@npm:^2.2.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + "@apidevtools/json-schema-ref-parser@https://github.com/bcherny/json-schema-ref-parser.git#984282d34a2993e5243aa35100fe32a63699164d": version: 0.0.0-dev resolution: "@apidevtools/json-schema-ref-parser@https://github.com/bcherny/json-schema-ref-parser.git#commit=984282d34a2993e5243aa35100fe32a63699164d" @@ -819,6 +623,15 @@ __metadata: languageName: unknown linkType: soft +"@flashcatcloud/browser-rum-legacy@workspace:packages/rum-legacy": + version: 0.0.0-use.local + resolution: "@flashcatcloud/browser-rum-legacy@workspace:packages/rum-legacy" + dependencies: + terser-webpack-plugin: "npm:5.3.14" + webpack: "npm:5.99.8" + languageName: unknown + linkType: soft + "@flashcatcloud/browser-rum-react@workspace:packages/rum-react": version: 0.0.0-use.local resolution: "@flashcatcloud/browser-rum-react@workspace:packages/rum-react" @@ -868,7 +681,6 @@ __metadata: "@flashcatcloud/browser-rum-core": "workspace:*" "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" - webpack: "npm:5.99.8" peerDependencies: "@flashcatcloud/browser-logs": 0.0.2 peerDependenciesMeta: @@ -2443,15 +2255,6 @@ __metadata: languageName: node linkType: hard -"@types/xml2js@npm:^0.4.5": - version: 0.4.14 - resolution: "@types/xml2js@npm:0.4.14" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/06776e7f7aec55a698795e60425417caa7d7db3ff680a7b4ccaae1567c5fec28ff49b9975e9a0d74ff4acb8f4a43730501bbe64f9f761d784c6476ba4db12e13 - languageName: node - linkType: hard - "@types/yauzl@npm:^2.9.1": version: 2.10.3 resolution: "@types/yauzl@npm:2.10.3" @@ -2877,7 +2680,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.4.1": +"acorn@npm:8.14.1, acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.4.1": version: 8.14.1 resolution: "acorn@npm:8.14.1" bin: @@ -3544,6 +3347,7 @@ __metadata: "@types/express": "npm:5.0.2" "@types/jasmine": "npm:3.10.18" "@types/node": "npm:22.15.19" + acorn: "npm:8.14.1" ajv: "npm:8.17.1" ali-oss: "npm:6.22.0" browserstack-local: "npm:1.5.6" @@ -3664,13 +3468,6 @@ __metadata: languageName: node linkType: hard -"builtin-status-codes@npm:^3.0.0": - version: 3.0.0 - resolution: "builtin-status-codes@npm:3.0.0" - checksum: 10c0/c37bbba11a34c4431e56bd681b175512e99147defbe2358318d8152b3a01df7bf25e0305873947e5b350073d5ef41a364a22b37e48f1fb6d2fe6d5286a0f348c - languageName: node - linkType: hard - "busboy@npm:^1.0.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -3766,16 +3563,6 @@ __metadata: languageName: node linkType: hard -"call-bound@npm:^1.0.2": - version: 1.0.4 - resolution: "call-bound@npm:1.0.4" - dependencies: - call-bind-apply-helpers: "npm:^1.0.2" - get-intrinsic: "npm:^1.3.0" - checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 - languageName: node - linkType: hard - "call-me-maybe@npm:^1.0.1": version: 1.0.2 resolution: "call-me-maybe@npm:1.0.2" @@ -4375,13 +4162,6 @@ __metadata: languageName: node linkType: hard -"copy-to@npm:^2.0.1": - version: 2.0.1 - resolution: "copy-to@npm:2.0.1" - checksum: 10c0/ee10fa7ab257ccc1fada75d8571312f7a7eb2fa6a3129d89c6e3afc9884e0eb0cbb79140a92671fd3e35fa285b1e7f27f5422f885494ff14cf4c8c56e62d9daf - languageName: node - linkType: hard - "copy-webpack-plugin@npm:13.0.0": version: 13.0.0 resolution: "copy-webpack-plugin@npm:13.0.0" @@ -4609,13 +4389,6 @@ __metadata: languageName: node linkType: hard -"dateformat@npm:^2.0.0": - version: 2.2.0 - resolution: "dateformat@npm:2.2.0" - checksum: 10c0/cb41b1439162cd5852cf52717c5e4dea9f9a3207cca18e0be91c5bbd0cf95624f019a79b728fffb2c6e7ebb3499bc188631ac7d1e4bf984505174a33a524a264 - languageName: node - linkType: hard - "dateformat@npm:^3.0.3": version: 3.0.3 resolution: "dateformat@npm:3.0.3" @@ -4710,15 +4483,6 @@ __metadata: languageName: node linkType: hard -"default-user-agent@npm:^1.0.0": - version: 1.0.0 - resolution: "default-user-agent@npm:1.0.0" - dependencies: - os-name: "npm:~1.0.3" - checksum: 10c0/c7389e78cef67e7bd7706e71bbf3e3012815e4f9ecc814202353072877573529c5caefd54fa0cb7c53918471443794e6f5347428692048923ab931ff43bea5db - languageName: node - linkType: hard - "defaults@npm:^1.0.3": version: 1.0.4 resolution: "defaults@npm:1.0.4" @@ -4975,17 +4739,6 @@ __metadata: languageName: node linkType: hard -"dunder-proto@npm:^1.0.1": - version: 1.0.1 - resolution: "dunder-proto@npm:1.0.1" - dependencies: - call-bind-apply-helpers: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - gopd: "npm:^1.2.0" - checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 - languageName: node - linkType: hard - "duplexer@npm:^0.1.1, duplexer@npm:~0.1.1": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -5808,15 +5561,6 @@ __metadata: languageName: node linkType: hard -"extend-shallow@npm:^2.0.1": - version: 2.0.1 - resolution: "extend-shallow@npm:2.0.1" - dependencies: - is-extendable: "npm:^0.1.0" - checksum: 10c0/ee1cb0a18c9faddb42d791b2d64867bd6cfd0f3affb711782eb6e894dd193e2934a7f529426aac7c8ddb31ac5d38000a00aa2caf08aa3dfc3e1c8ff6ba340bd9 - languageName: node - linkType: hard - "extend@npm:^3.0.0": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -6117,18 +5861,6 @@ __metadata: languageName: node linkType: hard -"formstream@npm:^1.1.0": - version: 1.5.1 - resolution: "formstream@npm:1.5.1" - dependencies: - destroy: "npm:^1.0.4" - mime: "npm:^2.5.2" - node-hex: "npm:^1.0.1" - pause-stream: "npm:~0.0.11" - checksum: 10c0/f1a33a31fd9e6b9ae02238013c6112a92b77d443bb75c0b34f7d72287c7dc2413dba68cfda2345a652ac280ee05413716ef0ccb52eee7d611960d991f3b7b1cb - languageName: node - linkType: hard - "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" @@ -6311,24 +6043,6 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": - version: 1.3.0 - resolution: "get-intrinsic@npm:1.3.0" - dependencies: - call-bind-apply-helpers: "npm:^1.0.2" - es-define-property: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.1.1" - function-bind: "npm:^1.1.2" - get-proto: "npm:^1.0.1" - gopd: "npm:^1.2.0" - has-symbols: "npm:^1.1.0" - hasown: "npm:^2.0.2" - math-intrinsics: "npm:^1.1.0" - checksum: 10c0/52c81808af9a8130f581e6a6a83e1ba4a9f703359e7a438d1369a5267a25412322f03dcbd7c549edaef0b6214a0630a28511d7df0130c93cfd380f4fa0b5b66a - languageName: node - linkType: hard - "get-nonce@npm:^1.0.0": version: 1.0.1 resolution: "get-nonce@npm:1.0.1" @@ -6642,13 +6356,6 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.2.0": - version: 1.2.0 - resolution: "gopd@npm:1.2.0" - checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead - languageName: node - linkType: hard - "graceful-fs@npm:4.2.11, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -6972,15 +6679,6 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.3": - version: 0.6.3 - resolution: "iconv-lite@npm:0.6.3" - dependencies: - safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 - languageName: node - linkType: hard - "icss-utils@npm:^5.0.0, icss-utils@npm:^5.1.0": version: 5.1.0 resolution: "icss-utils@npm:5.1.0" @@ -9188,13 +8886,6 @@ __metadata: languageName: node linkType: hard -"node-hex@npm:^1.0.1": - version: 1.0.1 - resolution: "node-hex@npm:1.0.1" - checksum: 10c0/de7ba2d1531306bcd9ab73973048c9220f10cbb2c2e69682635f1051fb999674674104105ca2bb2313dc6a01a4ea664df44afc8157c726aebe51b78279ae7a92 - languageName: node - linkType: hard - "node-machine-id@npm:1.1.12": version: 1.1.12 resolution: "node-machine-id@npm:1.1.12" @@ -9496,13 +9187,6 @@ __metadata: languageName: node linkType: hard -"object-inspect@npm:^1.13.3": - version: 1.13.4 - resolution: "object-inspect@npm:1.13.4" - checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692 - languageName: node - linkType: hard - "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -11522,13 +11206,6 @@ __metadata: languageName: node linkType: hard -"sm3@npm:^1.0.3": - version: 1.0.3 - resolution: "sm3@npm:1.0.3" - checksum: 10c0/e33d5f4c6911b1c8cfa8232981c986eb3cb18ef08b87d0f78fc0da5f50f11b9234fa6c37e9ee218221ecc8e7b430095b3b66820da0cc8497b236237f5cdb696e - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -12784,31 +12461,6 @@ __metadata: languageName: node linkType: hard -"urllib@npm:^2.44.0": - version: 2.44.0 - resolution: "urllib@npm:2.44.0" - dependencies: - any-promise: "npm:^1.3.0" - content-type: "npm:^1.0.2" - default-user-agent: "npm:^1.0.0" - digest-header: "npm:^1.0.0" - ee-first: "npm:~1.1.1" - formstream: "npm:^1.1.0" - humanize-ms: "npm:^1.2.0" - iconv-lite: "npm:^0.6.3" - pump: "npm:^3.0.0" - qs: "npm:^6.4.0" - statuses: "npm:^1.3.1" - utility: "npm:^1.16.1" - peerDependencies: - proxy-agent: ^5.0.0 - peerDependenciesMeta: - proxy-agent: - optional: true - checksum: 10c0/69641aa5549ee657039979b659ef2a2054b41be1361309bed0ed4b23291e2c8db1864b156092b81a0036a5d9f55dbcfe6afa78fa3c57aad7e0c6a6ceee2fb5b5 - languageName: node - linkType: hard - "use-callback-ref@npm:^1.3.3": version: 1.3.3 resolution: "use-callback-ref@npm:1.3.3" From 4d3a115ab645076226098c1579cab5989f14e9cf Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 05:36:19 -0700 Subject: [PATCH 02/32] feat(rum-legacy): send batched events over XMLHttpRequest Transport for browsers that have neither fetch nor sendBeacon. The intake url is built to match the modern bundle byte for byte, so a single reverse proxy rule on the customer domain serves both builds and the intake needs no compatibility branch. Two details carry that property and are easy to get wrong: the real intake path travels inside the ddforward query parameter rather than being appended to the proxy path, and a relative proxy value is resolved to an absolute url first. The specs build a reference url with the modern implementation and compare against it, so a change on either side fails loudly instead of drifting. Completion is detected through onreadystatechange. onload arrived in IE10, so a transport built on it would look correct in a modern test browser and never complete on the browsers this package exists for. The spec's fake XMLHttpRequest fires only onreadystatechange to keep that honest. The exit path sends synchronously because there is no sendBeacon to hand the payload to. Batch limits match the modern bundle. Payload size is measured with a UTF-8 byte count rather than string length, which would undercount non-latin content threefold and let batches grow past the intake limit. Session identity reuses the modern cookie name, serialisation and expiration rules. The modern parser rejects uppercase characters, so the generated uuid has to stay lowercase or every page load would silently start a new session. Timers and listener registration go through the unpatched originals when Zone.js is present, whose patched versions have been observed to cause memory leaks and high CPU usage in host pages. Time is read via a local dateNow() rather than Date.now(), which some sites wrongly polyfill to return a Date instance. Pages still running these browsers are the most likely to carry such a dependency. --- .github/workflows/deploy-auto.yml | 3 + .github/workflows/deploy-manual.yml | 5 +- .github/workflows/deploy-staging.yml | 3 + packages/rum-legacy/package.json | 3 +- .../src/domain/sessionStore.spec.ts | 105 ++++++++++ .../rum-legacy/src/domain/sessionStore.ts | 119 +++++++++++ packages/rum-legacy/src/tools/timeUtils.ts | 6 + packages/rum-legacy/src/tools/zoneJs.ts | 33 ++++ .../rum-legacy/src/transport/batch.spec.ts | 184 ++++++++++++++++++ packages/rum-legacy/src/transport/batch.ts | 159 +++++++++++++++ .../src/transport/httpRequest.spec.ts | 173 ++++++++++++++++ .../rum-legacy/src/transport/httpRequest.ts | 54 +++++ .../src/transport/intakeUrl.spec.ts | 125 ++++++++++++ .../rum-legacy/src/transport/intakeUrl.ts | 103 ++++++++++ 14 files changed, 1073 insertions(+), 2 deletions(-) create mode 100644 packages/rum-legacy/src/domain/sessionStore.spec.ts create mode 100644 packages/rum-legacy/src/domain/sessionStore.ts create mode 100644 packages/rum-legacy/src/tools/timeUtils.ts create mode 100644 packages/rum-legacy/src/tools/zoneJs.ts create mode 100644 packages/rum-legacy/src/transport/batch.spec.ts create mode 100644 packages/rum-legacy/src/transport/batch.ts create mode 100644 packages/rum-legacy/src/transport/httpRequest.spec.ts create mode 100644 packages/rum-legacy/src/transport/httpRequest.ts create mode 100644 packages/rum-legacy/src/transport/intakeUrl.spec.ts create mode 100644 packages/rum-legacy/src/transport/intakeUrl.ts diff --git a/.github/workflows/deploy-auto.yml b/.github/workflows/deploy-auto.yml index 0818ea7a40..a87b315372 100644 --- a/.github/workflows/deploy-auto.yml +++ b/.github/workflows/deploy-auto.yml @@ -35,6 +35,9 @@ jobs: - name: Build bundle run: yarn build:bundle + - name: Verify ES5 compatibility of the legacy bundle + run: yarn test:compat:es5 + - name: Deploy to prod run: node ./scripts/deploy/deploy-oss.js prod v${VERSION} env: diff --git a/.github/workflows/deploy-manual.yml b/.github/workflows/deploy-manual.yml index c5015d06d3..e677b77295 100644 --- a/.github/workflows/deploy-manual.yml +++ b/.github/workflows/deploy-manual.yml @@ -38,6 +38,9 @@ jobs: - name: Build bundle run: yarn build:bundle + - name: Verify ES5 compatibility of the legacy bundle + run: yarn test:compat:es5 + - name: Deploy to prod run: node ./scripts/deploy/deploy-oss.js prod v${VERSION} env: @@ -67,7 +70,7 @@ jobs: run: node ./scripts/deploy/publish-npm.js env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - + notify-success: needs: publish-npm runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index a1309936c7..f74647f903 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -38,6 +38,9 @@ jobs: - name: Build bundle run: yarn build:bundle + - name: Verify ES5 compatibility of the legacy bundle + run: yarn test:compat:es5 + - name: Deploy to staging run: node ./scripts/deploy/deploy-oss.js staging v${VERSION} env: diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 69f3a7c891..47cdca34ad 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -6,7 +6,8 @@ "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", "scripts": { "build": "yarn build:bundle", - "build:bundle": "rm -rf bundle && SDK_SETUP=cdn webpack --mode=production", + "build:bundle": "rm -rf bundle && yarn typecheck && SDK_SETUP=cdn webpack --mode=production && yarn check:es5", + "check:es5": "node ../../scripts/check-es5-compatibility.js", "typecheck": "tsc --noEmit -p tsconfig.json" }, "devDependencies": { diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts new file mode 100644 index 0000000000..d2f84c72ac --- /dev/null +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -0,0 +1,105 @@ +import { isValidSessionString } from '../../../core/src/domain/session/sessionStateValidation' +import { toSessionState } from '../../../core/src/domain/session/sessionState' +import { SESSION_COOKIE_NAME, createSessionStore, deleteSessionCookie } from './sessionStore' + +/** + * The cookie written here is the same one the modern bundle reads, so its format is not ours to + * choose. These specs validate what we write with the modern parser rather than with a + * hand-written expectation. + */ +describe('session store', () => { + const ONE_MINUTE = 60 * 1000 + + function readRawCookie(): string | undefined { + const match = new RegExp(`(?:^|;)\\s*${SESSION_COOKIE_NAME}\\s*=\\s*([^;]+)`).exec(document.cookie) + return match ? decodeURIComponent(match[1]) : undefined + } + + afterEach(() => { + deleteSessionCookie() + }) + + it('creates a session with a lowercase uuid', () => { + const session = createSessionStore().getOrCreateSession() + + expect(session.id).toMatch(/^[0-9a-f-]{36}$/) + }) + + it('writes a cookie the modern bundle considers valid', () => { + createSessionStore().getOrCreateSession() + + expect(isValidSessionString(readRawCookie())).toBe(true) + }) + + it('writes the fields the modern bundle expects to find', () => { + const session = createSessionStore().getOrCreateSession() + + const state = toSessionState(readRawCookie()) + expect(state.id).toBe(session.id) + // '2' means tracked without session replay, which is all this build can offer. + expect(state.rum).toBe('2') + expect(Number(state.created)).toBeGreaterThan(0) + expect(Number(state.expire)).toBeGreaterThan(Date.now()) + }) + + it('reuses the session across calls', () => { + const store = createSessionStore() + + expect(store.getOrCreateSession().id).toBe(store.getOrCreateSession().id) + }) + + it('reuses a session written by a previous page load', () => { + const first = createSessionStore().getOrCreateSession() + + expect(createSessionStore().getOrCreateSession().id).toBe(first.id) + }) + + it('pushes the expiration forward on activity', () => { + const store = createSessionStore() + store.getOrCreateSession() + const firstExpire = Number(toSessionState(readRawCookie()).expire) + + jasmine.clock().install() + jasmine.clock().mockDate(new Date(Date.now() + ONE_MINUTE)) + store.getOrCreateSession() + const secondExpire = Number(toSessionState(readRawCookie()).expire) + jasmine.clock().uninstall() + + expect(secondExpire).toBeGreaterThan(firstExpire) + }) + + it('starts a new session once the inactivity window has passed', () => { + const first = createSessionStore().getOrCreateSession() + + jasmine.clock().install() + jasmine.clock().mockDate(new Date(Date.now() + 16 * ONE_MINUTE)) + const second = createSessionStore().getOrCreateSession() + jasmine.clock().uninstall() + + expect(second.id).not.toBe(first.id) + }) + + it('starts a new session once the maximum duration has passed', () => { + const first = createSessionStore().getOrCreateSession() + + jasmine.clock().install() + // Still inside the inactivity window, but past the 4 hour cap. + jasmine.clock().mockDate(new Date(Date.now() + 4 * 60 * ONE_MINUTE + ONE_MINUTE)) + const store = createSessionStore() + const second = store.getOrCreateSession() + jasmine.clock().uninstall() + + expect(second.id).not.toBe(first.id) + }) + + it('keeps working when the cookie cannot be persisted', () => { + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')! + Object.defineProperty(document, 'cookie', { get: () => '', set: () => undefined, configurable: true }) + + const session = createSessionStore().getOrCreateSession() + + Object.defineProperty(document, 'cookie', descriptor) + + expect(session.id).toMatch(/^[0-9a-f-]{36}$/) + }) +}) diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts new file mode 100644 index 0000000000..a93c07da60 --- /dev/null +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -0,0 +1,119 @@ +import { dateNow } from '../tools/timeUtils' +import { generateUUID } from '../transport/intakeUrl' + +/** + * Session identity is shared with the modern bundle: same cookie name, same serialisation, same + * expiration rules. A page that loads the legacy build on one visit and the modern build on the + * next keeps the same session, and the intake sees one consistent format. + */ +export const SESSION_COOKIE_NAME = '_dd_s' + +const ONE_MINUTE = 60 * 1000 +const ONE_HOUR = 60 * ONE_MINUTE + +/** Time without activity after which the session ends. */ +const SESSION_EXPIRATION_DELAY = 15 * ONE_MINUTE +/** Hard cap on a session's lifetime, however active it is. */ +const SESSION_TIME_OUT_DELAY = 4 * ONE_HOUR + +/** '2' is "tracked, without session replay", the only state this build can be in. */ +const TRACKED_WITHOUT_SESSION_REPLAY = '2' + +export interface LegacySession { + id: string +} + +interface SessionState { + id?: string + created?: string + expire?: string + rum?: string +} + +export function createSessionStore() { + // Kept in memory as well as in the cookie so that a page which cannot persist cookies still + // reports a stable session for the lifetime of the document. + let inMemoryState: SessionState | undefined + + return { + getOrCreateSession(): LegacySession { + const now = dateNow() + let state = readSessionCookie() || inMemoryState + + if (!state || !state.id || isExpired(state, now)) { + state = { + id: generateUUID(), + created: String(now), + rum: TRACKED_WITHOUT_SESSION_REPLAY, + } + } + + state.expire = String(now + SESSION_EXPIRATION_DELAY) + inMemoryState = state + writeSessionCookie(state) + + return { id: state.id! } + }, + } +} + +function isExpired(state: SessionState, now: number): boolean { + const createdAt = Number(state.created) + const expiresAt = Number(state.expire) + return (createdAt && now - createdAt >= SESSION_TIME_OUT_DELAY) || (expiresAt && now >= expiresAt) ? true : false +} + +/** + * Serialisation has to match the modern bundle's parser, whose entry pattern is + * /^([a-zA-Z]+)=([a-z0-9-]+)$/. Uppercase characters or padding would make it discard the whole + * cookie, silently restarting the session on every page load. + */ +function serialize(state: SessionState): string { + const entries: string[] = [] + if (state.id) { + entries.push(`id=${state.id}`) + } + if (state.created) { + entries.push(`created=${state.created}`) + } + if (state.expire) { + entries.push(`expire=${state.expire}`) + } + if (state.rum) { + entries.push(`rum=${state.rum}`) + } + return entries.join('&') +} + +function deserialize(value: string): SessionState | undefined { + const state: SessionState = {} + const entries = value.split('&') + for (let i = 0; i < entries.length; i++) { + const match = /^([a-zA-Z]+)=([a-z0-9-]+)$/.exec(entries[i]) + if (match) { + state[match[1] as keyof SessionState] = match[2] + } + } + return state.id ? state : undefined +} + +function readSessionCookie(): SessionState | undefined { + const match = new RegExp(`(?:^|;)\\s*${SESSION_COOKIE_NAME}\\s*=\\s*([^;]+)`).exec(document.cookie) + if (!match) { + return undefined + } + try { + return deserialize(decodeURIComponent(match[1])) + } catch { + return undefined + } +} + +function writeSessionCookie(state: SessionState): void { + const expires = new Date(dateNow() + SESSION_EXPIRATION_DELAY).toUTCString() + document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent(serialize(state))};expires=${expires};path=/;samesite=strict` +} + +export function deleteSessionCookie(): void { + document.cookie = `${SESSION_COOKIE_NAME}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/` +} diff --git a/packages/rum-legacy/src/tools/timeUtils.ts b/packages/rum-legacy/src/tools/timeUtils.ts new file mode 100644 index 0000000000..b7485b3ba6 --- /dev/null +++ b/packages/rum-legacy/src/tools/timeUtils.ts @@ -0,0 +1,6 @@ +export function dateNow(): number { + // Not `Date.now()`: some sites wrongly "polyfill" it. A very old datejs release patched it to + // return a Date instance rather than a timestamp. That kind of dependency is exactly what a page + // still targeting these browsers is likely to be carrying, so read the time the safe way. + return new Date().getTime() +} diff --git a/packages/rum-legacy/src/tools/zoneJs.ts b/packages/rum-legacy/src/tools/zoneJs.ts new file mode 100644 index 0000000000..c16c152554 --- /dev/null +++ b/packages/rum-legacy/src/tools/zoneJs.ts @@ -0,0 +1,33 @@ +interface WindowWithZoneJs extends Window { + Zone?: { + // Every Zone.js version exposes __symbol__, but some pages define an unrelated global named + // 'Zone', so treat it as optional. + __symbol__?: (name: string) => string + } +} + +/** + * Returns the unpatched value of a DOM API that Zone.js may have replaced. + * + * Zone.js patches timers and event registration, keeping the originals on hidden + * `__zone_symbol__`-prefixed properties. Its patched versions have been observed to cause memory + * leaks and high CPU usage in host pages. Since the first requirement of this build is that the + * page keeps working normally, the timer and listener calls go through here. + */ +export function getZoneJsOriginalValue( + target: Target, + name: Name +): Target[Name] { + const browserWindow = window as WindowWithZoneJs + let original: Target[Name] | undefined + + if (browserWindow.Zone && typeof browserWindow.Zone.__symbol__ === 'function') { + original = (target as any)[browserWindow.Zone.__symbol__(name)] + } + + if (!original) { + original = target[name] + } + + return original +} diff --git a/packages/rum-legacy/src/transport/batch.spec.ts b/packages/rum-legacy/src/transport/batch.spec.ts new file mode 100644 index 0000000000..2cde6273c3 --- /dev/null +++ b/packages/rum-legacy/src/transport/batch.spec.ts @@ -0,0 +1,184 @@ +import type { HttpRequest } from './httpRequest' +import { BATCH_BYTES_LIMIT, BATCH_MESSAGES_LIMIT, FLUSH_TIMEOUT, MESSAGE_BYTES_LIMIT, startBatch } from './batch' + +describe('batch', () => { + let request: HttpRequest & { sentPayloads: string[]; exitPayloads: string[] } + + function createRequestSpy() { + const sentPayloads: string[] = [] + const exitPayloads: string[] = [] + return { + sentPayloads, + exitPayloads, + send: (data: string) => sentPayloads.push(data), + sendOnExit: (data: string) => exitPayloads.push(data), + } + } + + beforeEach(() => { + request = createRequestSpy() + jasmine.clock().install() + }) + + afterEach(() => { + jasmine.clock().uninstall() + }) + + it('buffers events instead of sending one request per event', () => { + const batch = startBatch(request) + + batch.add({ type: 'view' }) + batch.add({ type: 'error' }) + + expect(request.sentPayloads).toEqual([]) + batch.stop() + }) + + it('sends one json document per line', () => { + const batch = startBatch(request) + + batch.add({ type: 'view' }) + batch.add({ type: 'error' }) + batch.flush() + + expect(request.sentPayloads.length).toBe(1) + expect(request.sentPayloads[0].split('\n')).toEqual(['{"type":"view"}', '{"type":"error"}']) + batch.stop() + }) + + it('sends nothing when the buffer is empty', () => { + const batch = startBatch(request) + + batch.flush() + + expect(request.sentPayloads).toEqual([]) + batch.stop() + }) + + it('flushes once the message count limit is reached', () => { + const batch = startBatch(request) + + for (let i = 0; i < BATCH_MESSAGES_LIMIT; i++) { + batch.add({ i }) + } + + expect(request.sentPayloads.length).toBe(1) + expect(request.sentPayloads[0].split('\n').length).toBe(BATCH_MESSAGES_LIMIT) + batch.stop() + }) + + it('flushes once the byte limit is reached', () => { + const batch = startBatch(request) + const padding = new Array(1024).join('a') + + let added = 0 + while (request.sentPayloads.length === 0) { + batch.add({ padding }) + added++ + if (added > 1000) { + break + } + } + + expect(request.sentPayloads.length).toBe(1) + expect(request.sentPayloads[0].length).toBeLessThan(BATCH_BYTES_LIMIT * 2) + batch.stop() + }) + + it('flushes on a timer so a quiet page still reports', () => { + const batch = startBatch(request) + + batch.add({ type: 'view' }) + expect(request.sentPayloads).toEqual([]) + + jasmine.clock().tick(FLUSH_TIMEOUT) + + expect(request.sentPayloads.length).toBe(1) + batch.stop() + }) + + it('drops a single event too large to ever be accepted, keeping the rest of the batch', () => { + const batch = startBatch(request) + + batch.add({ padding: new Array(MESSAGE_BYTES_LIMIT + 10).join('a') }) + batch.add({ type: 'view' }) + batch.flush() + + expect(request.sentPayloads).toEqual(['{"type":"view"}']) + batch.stop() + }) + + it('drops an event that cannot be serialised rather than losing the batch', () => { + const batch = startBatch(request) + const circular: any = {} + circular.self = circular + + expect(() => batch.add(circular)).not.toThrow() + batch.add({ type: 'view' }) + batch.flush() + + expect(request.sentPayloads).toEqual(['{"type":"view"}']) + batch.stop() + }) + + /** + * The page exit events are captured rather than dispatched. The test runner installs its own + * beforeunload/unload handlers to detect navigation, so firing real ones on window makes it + * believe the page reloaded and abandons the run. + */ + function captureExitHandlers() { + const handlers: { [eventName: string]: Array<() => void> } = {} + spyOn(window, 'addEventListener').and.callFake((eventName: string, handler: any) => { + handlers[eventName] = handlers[eventName] || [] + handlers[eventName].push(handler) + }) + return handlers + } + + it('uses the exit transport when the page is unloading', () => { + const handlers = captureExitHandlers() + const batch = startBatch(request) + + batch.add({ type: 'view' }) + handlers.beforeunload[0]() + + expect(request.exitPayloads).toEqual(['{"type":"view"}']) + expect(request.sentPayloads).toEqual([]) + batch.stop() + }) + + it('listens to both page exit events available before IE10', () => { + const handlers = captureExitHandlers() + + const batch = startBatch(request) + + expect(handlers.beforeunload).toBeDefined() + expect(handlers.unload).toBeDefined() + batch.stop() + }) + + it('does not send the same events twice when both exit events fire', () => { + const handlers = captureExitHandlers() + const batch = startBatch(request) + + batch.add({ type: 'view' }) + handlers.beforeunload[0]() + handlers.unload[0]() + + expect(request.exitPayloads).toEqual(['{"type":"view"}']) + batch.stop() + }) + + it('stops listening and stops flushing once stopped', () => { + const handlers = captureExitHandlers() + const batch = startBatch(request) + batch.stop() + + batch.add({ type: 'view' }) + jasmine.clock().tick(FLUSH_TIMEOUT * 2) + handlers.beforeunload[0]() + + expect(request.sentPayloads).toEqual([]) + expect(request.exitPayloads).toEqual([]) + }) +}) diff --git a/packages/rum-legacy/src/transport/batch.ts b/packages/rum-legacy/src/transport/batch.ts new file mode 100644 index 0000000000..f8dbfe6bc1 --- /dev/null +++ b/packages/rum-legacy/src/transport/batch.ts @@ -0,0 +1,159 @@ +import { getZoneJsOriginalValue } from '../tools/zoneJs' +import type { HttpRequest } from './httpRequest' + +const ONE_KIBI_BYTE = 1024 + +// Same limits as the modern bundle, so the intake sees batches of the shape it already handles. +export const BATCH_BYTES_LIMIT = 16 * ONE_KIBI_BYTE +export const BATCH_MESSAGES_LIMIT = 50 +export const MESSAGE_BYTES_LIMIT = 256 * ONE_KIBI_BYTE +export const FLUSH_TIMEOUT = 30 * 1000 + +export interface Batch { + add: (event: object) => void + flush: () => void + stop: () => void +} + +export function startBatch(request: HttpRequest): Batch { + let messages: string[] = [] + let bytesCount = 0 + let stopped = false + let flushTimeoutId: number | undefined + + function flush(useExitTransport?: boolean): void { + cancelScheduledFlush() + + if (messages.length === 0) { + return + } + + const payload = messages.join('\n') + messages = [] + bytesCount = 0 + + if (useExitTransport) { + request.sendOnExit(payload) + } else { + request.send(payload) + } + } + + function cancelScheduledFlush(): void { + if (flushTimeoutId !== undefined) { + getZoneJsOriginalValue(window, 'clearTimeout')(flushTimeoutId) + flushTimeoutId = undefined + } + } + + function scheduleFlush(): void { + if (flushTimeoutId === undefined) { + flushTimeoutId = getZoneJsOriginalValue(window, 'setTimeout')(() => { + flushTimeoutId = undefined + flush() + }, FLUSH_TIMEOUT) as unknown as number + } + } + + function onPageExit(): void { + if (!stopped) { + flush(true) + } + } + + // No visibilitychange here: it does not exist before IE10, and the prefixed IE10 variant would + // only cover part of the range this build targets. beforeunload plus unload is what is available + // everywhere. Flushing empties the buffer, so the second event is a no-op rather than a resend. + const addEventListener = getZoneJsOriginalValue(window, 'addEventListener') + addEventListener.call(window, 'beforeunload', onPageExit) + addEventListener.call(window, 'unload', onPageExit) + + return { + add(event: object) { + if (stopped) { + return + } + + const message = serialize(event) + if (message === undefined) { + return + } + + const messageBytesCount = computeBytesCount(message) + if (messageBytesCount > MESSAGE_BYTES_LIMIT) { + // The intake would reject it anyway, and keeping it would block every following event. + return + } + + if (messages.length > 0 && bytesCount + messageBytesCount >= BATCH_BYTES_LIMIT) { + flush() + } + + messages.push(message) + bytesCount += messageBytesCount + + if (messages.length >= BATCH_MESSAGES_LIMIT) { + flush() + } else { + scheduleFlush() + } + }, + + flush() { + if (!stopped) { + flush() + } + }, + + stop() { + stopped = true + cancelScheduledFlush() + const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener') + removeEventListener.call(window, 'beforeunload', onPageExit) + removeEventListener.call(window, 'unload', onPageExit) + }, + } +} + +function serialize(event: object): string | undefined { + // A circular or otherwise unserialisable event must cost only itself, not the whole batch. + try { + return JSON.stringify(event) + } catch { + return undefined + } +} + +/** + * Counts the bytes the payload will actually occupy once encoded. + * + * TextEncoder does not exist in the browsers this build targets, and using `string.length` instead + * would undercount any non-latin content by a factor of three, letting batches grow well past the + * intake limit on pages that are not written in English. + */ +export function computeBytesCount(candidate: string): number { + let count = 0 + + for (let i = 0; i < candidate.length; i++) { + const code = candidate.charCodeAt(i) + + if (code < 0x80) { + count += 1 + } else if (code < 0x800) { + count += 2 + } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < candidate.length) { + const nextCode = candidate.charCodeAt(i + 1) + if (nextCode >= 0xdc00 && nextCode <= 0xdfff) { + // A surrogate pair encodes a single 4 byte code point. + count += 4 + i++ + } else { + count += 3 + } + } else { + count += 3 + } + } + + return count +} diff --git a/packages/rum-legacy/src/transport/httpRequest.spec.ts b/packages/rum-legacy/src/transport/httpRequest.spec.ts new file mode 100644 index 0000000000..060da230ce --- /dev/null +++ b/packages/rum-legacy/src/transport/httpRequest.spec.ts @@ -0,0 +1,173 @@ +import { createHttpRequest } from './httpRequest' + +/** + * IE9 only added onload/onerror/onprogress to XMLHttpRequest in IE10, so the fake below behaves + * like IE9 does: assigning onload is possible but nothing ever calls it. A transport that relies on + * onload therefore looks fine in these specs' modern host browser and silently never completes on + * the browsers this build exists for. + */ +interface FakeXhr { + method?: string + url?: string + async?: boolean + body?: unknown + headers: Array<[string, string]> + assignedHandlers: string[] + readyState: number + status: number + open: (method: string, url: string, async?: boolean) => void + send: (body?: unknown) => void + setRequestHeader: (name: string, value: string) => void + onreadystatechange?: () => void + onload?: () => void + complete: (status: number) => void +} + +describe('http request', () => { + let sent: FakeXhr[] + let originalXhr: typeof XMLHttpRequest + let sendShouldThrow: boolean + + function createFakeXhr(): FakeXhr { + const xhr: FakeXhr = { + headers: [], + assignedHandlers: [], + readyState: 0, + status: 0, + open(method, url, async) { + xhr.method = method + xhr.url = url + xhr.async = async + }, + send(body) { + xhr.body = body + if (sendShouldThrow) { + throw new Error('network is down') + } + }, + setRequestHeader(name, value) { + xhr.headers.push([name, value]) + }, + complete(status) { + xhr.readyState = 4 + xhr.status = status + // Deliberately only the IE9 handler. + if (xhr.onreadystatechange) { + xhr.onreadystatechange() + } + }, + } + + // Record which handlers the implementation assigns, so a spec can assert it does not depend on + // one that IE9 never fires. + for (const handler of ['onreadystatechange', 'onload', 'onerror'] as const) { + let value: (() => void) | undefined + Object.defineProperty(xhr, handler, { + get: () => value, + set: (newValue) => { + value = newValue + xhr.assignedHandlers.push(handler) + }, + }) + } + + return xhr + } + + beforeEach(() => { + sent = [] + sendShouldThrow = false + originalXhr = window.XMLHttpRequest + ;(window as any).XMLHttpRequest = function () { + const xhr = createFakeXhr() + sent.push(xhr) + return xhr + } + }) + + afterEach(() => { + window.XMLHttpRequest = originalXhr + }) + + const buildUrl = () => 'https://example.com/rum-intake/?ddforward=x' + + it('posts the payload to the built url', () => { + createHttpRequest(buildUrl).send('{"a":1}') + + expect(sent.length).toBe(1) + expect(sent[0].method).toBe('POST') + expect(sent[0].url).toBe('https://example.com/rum-intake/?ddforward=x') + expect(sent[0].body).toBe('{"a":1}') + }) + + it('sends asynchronously', () => { + createHttpRequest(buildUrl).send('{}') + + expect(sent[0].async).toBe(true) + }) + + it('does not set a content type, so the request stays a simple request', () => { + createHttpRequest(buildUrl).send('{}') + + expect(sent[0].headers).toEqual([]) + }) + + it('completes through onreadystatechange, which is the only handler IE9 fires', () => { + const onResponse = jasmine.createSpy('onResponse') + createHttpRequest(buildUrl, onResponse).send('{}') + + expect(sent[0].assignedHandlers).toContain('onreadystatechange') + sent[0].complete(202) + + expect(onResponse).toHaveBeenCalledWith(202) + }) + + it('does not report a response before the request finished', () => { + const onResponse = jasmine.createSpy('onResponse') + createHttpRequest(buildUrl, onResponse).send('{}') + + sent[0].readyState = 2 + sent[0].onreadystatechange!() + + expect(onResponse).not.toHaveBeenCalled() + }) + + it('builds a fresh url for every request', () => { + let count = 0 + const request = createHttpRequest(() => `https://example.com/?n=${count++}`) + + request.send('{}') + request.send('{}') + + expect(sent[0].url).not.toBe(sent[1].url) + }) + + it('sends synchronously on exit, because there is no sendBeacon to fall back on', () => { + createHttpRequest(buildUrl).sendOnExit('{}') + + expect(sent[0].async).toBe(false) + }) + + it('never lets a transport failure reach the host page', () => { + sendShouldThrow = true + + expect(() => createHttpRequest(buildUrl).send('{}')).not.toThrow() + expect(() => createHttpRequest(buildUrl).sendOnExit('{}')).not.toThrow() + }) + + it('never lets a failing response handler reach the host page', () => { + createHttpRequest(buildUrl, () => { + throw new Error('handler is broken') + }).send('{}') + + expect(() => sent[0].complete(500)).not.toThrow() + }) + + it('survives a browser that cannot create an XMLHttpRequest at all', () => { + ;(window as any).XMLHttpRequest = function () { + throw new Error('blocked') + } + + expect(() => createHttpRequest(buildUrl).send('{}')).not.toThrow() + }) +}) diff --git a/packages/rum-legacy/src/transport/httpRequest.ts b/packages/rum-legacy/src/transport/httpRequest.ts new file mode 100644 index 0000000000..595ed4b52d --- /dev/null +++ b/packages/rum-legacy/src/transport/httpRequest.ts @@ -0,0 +1,54 @@ +export interface HttpRequest { + send: (data: string) => void + sendOnExit: (data: string) => void +} + +/* + * Transport for browsers with neither fetch nor sendBeacon. + * + * Two constraints shape this: + * - completion is detected through onreadystatechange. IE9 gained onload only in IE10, so a + * transport built on onload would never report a response there. + * - the exit path is a synchronous request. Without sendBeacon there is no way to hand a payload + * to the browser and let the document go, so the last batch is sent inline while the page is + * unloading. + * + * No request header is set, keeping the request a "simple request" and matching what the modern + * bundle sends: the intake reads newline separated json without relying on a content type. + */ +export function createHttpRequest(buildUrl: () => string, onResponse?: (status: number) => void): HttpRequest { + function request(data: string, isAsync: boolean): void { + // The host page must keep working even if the SDK cannot report anything at all, so every + // failure mode here is swallowed: the constructor throwing, a blocked cross-origin send, a + // security error on an unloading document. + try { + const xhr = new XMLHttpRequest() + xhr.open('POST', buildUrl(), isAsync) + + if (onResponse) { + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + try { + onResponse(xhr.status) + } catch { + // A broken response handler is still our problem, not the page's. + } + } + } + } + + xhr.send(data) + } catch { + // Intentionally silent: reporting a monitoring failure must never become a page failure. + } + } + + return { + send(data: string) { + request(data, true) + }, + sendOnExit(data: string) { + request(data, false) + }, + } +} diff --git a/packages/rum-legacy/src/transport/intakeUrl.spec.ts b/packages/rum-legacy/src/transport/intakeUrl.spec.ts new file mode 100644 index 0000000000..27e58a84bf --- /dev/null +++ b/packages/rum-legacy/src/transport/intakeUrl.spec.ts @@ -0,0 +1,125 @@ +import { createEndpointBuilder } from '../../../core/src/domain/configuration' +import type { BuildEnvWindow } from '../../../core/test' +import { createIntakeUrlBuilder } from './intakeUrl' + +/** + * The whole "no backend change" property of this build rests on one thing: the URL this package + * produces has to be shaped exactly like the modern bundle's, so that a single reverse proxy rule + * serves both. Rather than hard-coding what we believe that shape to be, these specs build the + * reference URL with the modern implementation and compare against it. If the modern builder ever + * changes, this fails instead of silently drifting. + */ +describe('intake url', () => { + const CLIENT_TOKEN = 'some_client_token' + const PROXY = '/rum-intake/' + + beforeEach(() => { + ;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test-version' + }) + + function buildModernUrl(initConfiguration: { clientToken: string; proxy: string }, tags: string[] = []) { + return createEndpointBuilder(initConfiguration, 'rum', tags).build('fetch', { + data: '', + bytesCount: 0, + }) + } + + function parse(url: string) { + const [base, query] = url.split('?ddforward=') + const forwarded = decodeURIComponent(query) + const [path, parameters] = forwarded.split('?') + const entries = parameters.split('&').map((entry) => { + const separatorIndex = entry.indexOf('=') + return [entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)] as const + }) + return { + base, + path, + keys: entries.map(([key]) => key), + values: new Map(entries), + } + } + + it('resolves the proxy path to an absolute url, like the modern bundle does', () => { + const legacy = parse(createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy: PROXY })()) + const modern = parse(buildModernUrl({ clientToken: CLIENT_TOKEN, proxy: PROXY })) + + expect(legacy.base).toBe(modern.base) + expect(legacy.base).toBe(`${location.origin}${PROXY}`) + }) + + it('forwards the same intake path', () => { + const legacy = parse(createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy: PROXY })()) + const modern = parse(buildModernUrl({ clientToken: CLIENT_TOKEN, proxy: PROXY })) + + expect(legacy.path).toBe(modern.path) + expect(legacy.path).toBe('/api/v2/rum') + }) + + it('emits the same query parameters in the same order', () => { + const legacy = parse(createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy: PROXY })()) + const modern = parse(buildModernUrl({ clientToken: CLIENT_TOKEN, proxy: PROXY })) + + expect(legacy.keys).toEqual(modern.keys) + }) + + it('emits the same values for every parameter that is not per-request', () => { + const legacy = parse(createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy: PROXY })()) + const modern = parse(buildModernUrl({ clientToken: CLIENT_TOKEN, proxy: PROXY })) + + for (const key of ['ddsource', 'dd-api-key', 'dd-evp-origin', 'dd-evp-origin-version']) { + expect(legacy.values.get(key)).toBe(modern.values.get(key), `parameter ${key} differs`) + } + }) + + it('reports the transport actually used in the api tag', () => { + const legacy = parse(createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy: PROXY })()) + + const tags = decodeURIComponent(legacy.values.get('ddtags')!).split(',') + expect(tags).toContain('api:xhr') + expect(tags).toContain('sdk_version:test-version') + }) + + it('builds the configuration tags like the modern bundle', () => { + const configuration = { + clientToken: CLIENT_TOKEN, + proxy: PROXY, + env: 'staging', + service: 'checkout', + version: '1.2.3', + } + const legacy = parse(createIntakeUrlBuilder(configuration)()) + const modern = parse(buildModernUrl(configuration, ['env:staging', 'service:checkout', 'version:1.2.3'])) + + const withoutApi = (tags: string) => + decodeURIComponent(tags) + .split(',') + .filter((tag) => tag.indexOf('api:') !== 0) + + expect(withoutApi(legacy.values.get('ddtags')!)).toEqual(withoutApi(modern.values.get('ddtags')!)) + }) + + it('replaces commas in tag values so a value cannot forge extra tags', () => { + const legacy = parse(createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy: PROXY, service: 'a,b' })()) + + expect(decodeURIComponent(legacy.values.get('ddtags')!).split(',')).toContain('service:a_b') + }) + + it('sends a fresh request id and batch time on every build', () => { + const build = createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy: PROXY }) + const first = parse(build()) + const second = parse(build()) + + expect(first.values.get('dd-request-id')).toMatch(/^[0-9a-f-]{36}$/) + expect(first.values.get('dd-request-id')).not.toBe(second.values.get('dd-request-id')) + expect(Number(first.values.get('batch_time'))).toBeGreaterThan(0) + }) + + it('supports an absolute proxy url', () => { + const proxy = 'https://collector.example.com/rum-intake/' + const legacy = parse(createIntakeUrlBuilder({ clientToken: CLIENT_TOKEN, proxy })()) + const modern = parse(buildModernUrl({ clientToken: CLIENT_TOKEN, proxy })) + + expect(legacy.base).toBe(modern.base) + }) +}) diff --git a/packages/rum-legacy/src/transport/intakeUrl.ts b/packages/rum-legacy/src/transport/intakeUrl.ts new file mode 100644 index 0000000000..c8b4d28bd1 --- /dev/null +++ b/packages/rum-legacy/src/transport/intakeUrl.ts @@ -0,0 +1,103 @@ +import { dateNow } from '../tools/timeUtils' + +// replaced at build time +declare const __BUILD_ENV__SDK_VERSION__: string + +const INTAKE_PATH = '/api/v2/rum' + +export interface IntakeConfiguration { + clientToken: string + proxy: string + env?: string + service?: string + version?: string + datacenter?: string +} + +/* + * Produces the same url the modern bundle sends to, so that one reverse proxy rule on the customer + * domain serves both builds and the intake needs no compatibility branch. + * + * Two details are easy to get wrong and both would break that: + * - the real intake path travels inside the `ddforward` query parameter, it is not appended to + * the proxy path + * - the proxy value is resolved to an absolute url first, so a relative `/rum-intake/` reaches + * the intake as `https:///rum-intake/` + */ +export function createIntakeUrlBuilder(configuration: IntakeConfiguration): () => string { + const baseUrl = normalizeUrl(configuration.proxy) + const configurationTags = buildTags(configuration) + + return function build() { + const parameters = buildParameters(configuration, configurationTags) + return `${baseUrl}?ddforward=${encodeURIComponent(`${INTAKE_PATH}?${parameters}`)}` + } +} + +function buildParameters(configuration: IntakeConfiguration, configurationTags: string[]): string { + const tags = [`sdk_version:${__BUILD_ENV__SDK_VERSION__}`, 'api:xhr'].concat(configurationTags) + + return [ + 'ddsource=browser', + `ddtags=${encodeURIComponent(tags.join(','))}`, + `dd-api-key=${configuration.clientToken}`, + `dd-evp-origin-version=${encodeURIComponent(__BUILD_ENV__SDK_VERSION__)}`, + 'dd-evp-origin=browser', + `dd-request-id=${generateUUID()}`, + // This build only ever sends to the rum track, which always carries a batch time. + `batch_time=${dateNow()}`, + ].join('&') +} + +function buildTags(configuration: IntakeConfiguration): string[] { + const tags: string[] = [] + + // Same keys and same order as the modern bundle. The tag character validation it performs is + // skipped: it relies on unicode property escapes, which the browsers this build targets do not + // support, and it only ever produces a console warning. + if (configuration.env) { + tags.push(buildTag('env', configuration.env)) + } + if (configuration.service) { + tags.push(buildTag('service', configuration.service)) + } + if (configuration.version) { + tags.push(buildTag('version', configuration.version)) + } + if (configuration.datacenter) { + tags.push(buildTag('datacenter', configuration.datacenter)) + } + + return tags +} + +function buildTag(key: string, rawValue: string): string { + // Commas separate tags, so a value containing one could forge additional tags. + return `${key}:${rawValue.replace(/,/g, '_')}` +} + +/** + * Resolves a possibly relative url against the current document. + * + * The modern bundle uses the URL constructor when available. Here the anchor element trick is the + * only option: IE9 has no URL constructor, and merely referencing the `URL` global to feature-detect + * it throws a ReferenceError there. + */ +export function normalizeUrl(url: string): string { + const anchor = document.createElement('a') + anchor.href = url + return anchor.href +} + +/** + * RFC4122 version 4 uuid, lowercase. Sourced from Math.random rather than crypto: IE9 has no + * crypto.getRandomValues. The session cookie parser rejects uppercase characters, so the lowercase + * output of toString(16) matters. + */ +export function generateUUID(): string { + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (character) => { + const digit = Number(character) + // eslint-disable-next-line no-bitwise + return (digit ^ ((Math.random() * 16) >> (digit / 4))).toString(16) + }) +} From e5f37ee76f13888a355021fc9be5a76bc8a62f77 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 05:45:43 -0700 Subject: [PATCH 03/32] feat(rum-legacy): collect errors, page load timings and views Adds the event assembly and the collection this build can actually support: uncaught errors, page load timings, view lifecycle and manual actions. Event shape is validated in the specs against the shared rum-events-format schemas rather than against hand-written expectations, since the intake owns that format. Durations are nanoseconds, so page load timings derived from performance.timing are converted rather than passed through as milliseconds. The zero-valued resource and long task counts are emitted rather than omitted. Those signals cannot be observed on these browsers, and leaving the fields out would read downstream as missing data instead of a real zero. Timings the browser has not reached are the opposite case: performance.timing reports them as 0, which would be a false measurement, so they are left out. window.onerror preserves and still calls whatever handler the page had installed, and passes its return value back so the page can keep suppressing the browser's default logging. Replacing it outright would silently disable the customer's own error reporting. Without an error object there is no stack, so the script url and line are folded into a single synthetic frame, which is what makes the error locatable at all. Route changes are tracked through hashchange only, as there is no History API to hook into here. --- packages/rum-legacy/package.json | 1 + .../src/domain/errorCollection.spec.ts | 134 +++++++++++++ .../rum-legacy/src/domain/errorCollection.ts | 113 +++++++++++ .../src/domain/eventAssembly.spec.ts | 152 ++++++++++++++ .../rum-legacy/src/domain/eventAssembly.ts | 109 ++++++++++ .../rum-legacy/src/domain/viewManager.spec.ts | 188 ++++++++++++++++++ packages/rum-legacy/src/domain/viewManager.ts | 178 +++++++++++++++++ yarn.lock | 1 + 8 files changed, 876 insertions(+) create mode 100644 packages/rum-legacy/src/domain/errorCollection.spec.ts create mode 100644 packages/rum-legacy/src/domain/errorCollection.ts create mode 100644 packages/rum-legacy/src/domain/eventAssembly.spec.ts create mode 100644 packages/rum-legacy/src/domain/eventAssembly.ts create mode 100644 packages/rum-legacy/src/domain/viewManager.spec.ts create mode 100644 packages/rum-legacy/src/domain/viewManager.ts diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 47cdca34ad..99a9eab3e3 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "devDependencies": { + "ajv": "8.17.1", "terser-webpack-plugin": "5.3.14", "webpack": "5.99.8" }, diff --git a/packages/rum-legacy/src/domain/errorCollection.spec.ts b/packages/rum-legacy/src/domain/errorCollection.spec.ts new file mode 100644 index 0000000000..ad999b3faf --- /dev/null +++ b/packages/rum-legacy/src/domain/errorCollection.spec.ts @@ -0,0 +1,134 @@ +import { startErrorCollection } from './errorCollection' + +describe('error collection', () => { + let collected: any[] + let stop: (() => void) | undefined + let originalOnError: OnErrorEventHandler + + beforeEach(() => { + collected = [] + originalOnError = window.onerror + window.onerror = null + }) + + afterEach(() => { + stop?.() + stop = undefined + window.onerror = originalOnError + }) + + function start() { + const collection = startErrorCollection((error) => collected.push(error)) + stop = () => collection.stop() + return collection + } + + /** Invokes whatever handler is currently installed, the way the browser would. */ + function triggerUncaughtError(message: string, url?: string, line?: number, column?: number, error?: Error): unknown { + const handler = window.onerror as (...args: unknown[]) => unknown + return handler(message, url, line, column, error) + } + + it('reports an uncaught error', () => { + start() + + triggerUncaughtError('Uncaught Error: boom', 'https://example.com/app.js', 12) + + expect(collected.length).toBe(1) + expect(collected[0].message).toBe('Uncaught Error: boom') + expect(collected[0].source).toBe('source') + expect(collected[0].handling).toBe('unhandled') + expect(collected[0].source_type).toBe('browser') + expect(collected[0].id).toMatch(/^[0-9a-f-]{36}$/) + }) + + it('keeps calling the handler the page had installed', () => { + const pageHandler = jasmine.createSpy('pageHandler') + window.onerror = pageHandler + start() + + triggerUncaughtError('boom', 'https://example.com/app.js', 12, 34) + + expect(pageHandler).toHaveBeenCalledWith('boom', 'https://example.com/app.js', 12, 34, undefined) + }) + + it('passes through what the page handler returned, so it can still suppress the default logging', () => { + window.onerror = () => true + start() + + expect(triggerUncaughtError('boom', 'https://example.com/app.js', 12)).toBe(true) + }) + + it('does not suppress the default logging when there was no page handler', () => { + start() + + expect(triggerUncaughtError('boom', 'https://example.com/app.js', 12)).toBe(false) + }) + + it('still reports the error when the page handler throws', () => { + window.onerror = () => { + throw new Error('page handler is broken') + } + start() + + expect(() => triggerUncaughtError('boom', 'https://example.com/app.js', 12)).not.toThrow() + expect(collected.length).toBe(1) + }) + + it('restores the page handler when stopped', () => { + const pageHandler = jasmine.createSpy('pageHandler') + window.onerror = pageHandler + const collection = start() + + collection.stop() + + expect(window.onerror).toBe(pageHandler) + }) + + it('records where the error happened when no error object is available', () => { + // This is the IE9 case: onerror receives only a message, a url and a line. + start() + + triggerUncaughtError('boom', 'https://example.com/app.js', 12) + + expect(collected[0].stack).toContain('https://example.com/app.js:12') + }) + + it('uses the real stack when the browser provides an error object', () => { + start() + const error = new TypeError('bad access') + + triggerUncaughtError('boom', 'https://example.com/app.js', 12, 34, error) + + expect(collected[0].type).toBe('TypeError') + expect(collected[0].stack).toBe(error.stack) + expect(collected[0].message).toBe('bad access') + }) + + it('reports a manually added error as handled', () => { + const collection = start() + + collection.addError(new Error('manual')) + + expect(collected[0].message).toBe('manual') + expect(collected[0].handling).toBe('handled') + expect(collected[0].source).toBe('custom') + }) + + it('accepts a non-error value passed to addError', () => { + const collection = start() + + collection.addError('just a string') + + expect(collected[0].message).toBe('just a string') + expect('stack' in collected[0]).toBe(false) + }) + + it('does not install itself twice over its own handler', () => { + start() + const installed = window.onerror + + expect(installed).not.toBe(null) + expect(typeof installed).toBe('function') + }) +}) diff --git a/packages/rum-legacy/src/domain/errorCollection.ts b/packages/rum-legacy/src/domain/errorCollection.ts new file mode 100644 index 0000000000..34f55c7680 --- /dev/null +++ b/packages/rum-legacy/src/domain/errorCollection.ts @@ -0,0 +1,113 @@ +import { generateUUID } from '../transport/intakeUrl' + +export interface CollectedError { + id: string + message: string + source: string + handling: string + source_type: string + type?: string + stack?: string +} + +/* + * Error collection for browsers without a usable stack. + * + * window.onerror is the only source available: there is no unhandledrejection event, and IE9 passes + * neither a column number nor an error object, so the message plus the script url and line is all + * there is. That location is folded into a single synthetic stack frame, which is what makes the + * error locatable in the UI at all. + * + * The handler the page had installed is preserved and still called. Replacing it outright would + * silently disable the customer's own error reporting, which is exactly the kind of interference + * this build must not cause. + */ +export function startErrorCollection(onError: (error: CollectedError) => void) { + const previousOnError = window.onerror + + function handleError(message: Event | string, url?: string, line?: number, column?: number, error?: Error): boolean { + try { + onError(computeError(message, url, line, error)) + } catch { + // Never let a reporting failure become a page failure. + } + + if (previousOnError) { + try { + // Returning the page handler's own result keeps its ability to suppress the browser's + // default error logging. + return previousOnError.call(window, message, url, line, column, error) as boolean + } catch { + // A broken page handler is not ours to propagate. + } + } + + return false + } + + window.onerror = handleError + + return { + addError(value: unknown): void { + onError(computeManualError(value)) + }, + + stop(): void { + if (window.onerror === handleError) { + window.onerror = previousOnError + } + }, + } +} + +function computeError(message: Event | string, url?: string, line?: number, error?: Error): CollectedError { + const collected: CollectedError = { + id: generateUUID(), + message: typeof message === 'string' ? message : 'Unknown error', + source: 'source', + handling: 'unhandled', + source_type: 'browser', + } + + if (error) { + collected.message = error.message || collected.message + if (error.name) { + collected.type = error.name + } + if (error.stack) { + collected.stack = error.stack + } + return collected + } + + if (url) { + // The single frame these browsers can offer. Without it the error has no location at all. + collected.stack = `at @ ${url}:${line === undefined ? '?' : line}` + } + + return collected +} + +function computeManualError(value: unknown): CollectedError { + const collected: CollectedError = { + id: generateUUID(), + message: '', + source: 'custom', + handling: 'handled', + source_type: 'browser', + } + + if (value instanceof Error) { + collected.message = value.message + if (value.name) { + collected.type = value.name + } + if (value.stack) { + collected.stack = value.stack + } + } else { + collected.message = String(value) + } + + return collected +} diff --git a/packages/rum-legacy/src/domain/eventAssembly.spec.ts b/packages/rum-legacy/src/domain/eventAssembly.spec.ts new file mode 100644 index 0000000000..1e4f8d816f --- /dev/null +++ b/packages/rum-legacy/src/domain/eventAssembly.spec.ts @@ -0,0 +1,152 @@ +import ajv from 'ajv' +// Test-only import of the schema bundle the modern packages validate against. The event shape is +// defined by the intake, not by this package, so it is validated against the real thing rather +// than against a hand-written expectation. Reaching past the test index is deliberate: this bundle +// is generated with require.context and is not re-exported there. +// eslint-disable-next-line local-rules/disallow-protected-directory-import +import { allJsonSchemas } from '../../../rum-core/test/allJsonSchemas' +import { assembleEvent } from './eventAssembly' + +function expectValidRumEvent(event: object) { + const instance = new ajv({ allErrors: true }) + instance.addSchema(allJsonSchemas as any) + void instance.validate('rum-events-schema.json', event) + + if (instance.errors) { + const errors = instance.errors.map((error) => ` event${error.instancePath || ''} ${error.message}`).join('\n') + fail(`Invalid RUM event format:\n${errors}`) + } +} + +describe('event assembly', () => { + const CONFIGURATION = { + applicationId: '00000000-aaaa-0000-aaaa-000000000000', + sessionSampleRate: 100, + } + const SESSION_ID = '11111111-aaaa-0000-aaaa-000000000000' + const VIEW = { + id: '22222222-aaaa-0000-aaaa-000000000000', + url: 'https://example.com/checkout', + referrer: 'https://example.com/', + } + + function assemble(type: string, properties: object, context?: object) { + return assembleEvent({ + type, + configuration: CONFIGURATION, + sessionId: SESSION_ID, + view: VIEW, + properties, + context, + }) + } + + it('produces an error event the intake schema accepts', () => { + expectValidRumEvent( + assemble('error', { + error: { + id: '33333333-aaaa-0000-aaaa-000000000000', + message: 'boom', + source: 'source', + handling: 'unhandled', + source_type: 'browser', + }, + }) + ) + }) + + it('produces a view event the intake schema accepts', () => { + expectValidRumEvent( + assemble('view', { + view: { + loading_type: 'initial_load', + time_spent: 1_000_000, + is_active: true, + action: { count: 0 }, + error: { count: 0 }, + resource: { count: 0 }, + long_task: { count: 0 }, + frustration: { count: 0 }, + }, + _dd: { document_version: 1 }, + }) + ) + }) + + it('produces an action event the intake schema accepts', () => { + expectValidRumEvent( + assemble('action', { + action: { + id: '44444444-aaaa-0000-aaaa-000000000000', + type: 'custom', + target: { name: 'checkout' }, + }, + }) + ) + }) + + it('carries the identity fields every event needs', () => { + const event = assemble('error', { error: { message: 'boom', source: 'source' } }) as any + + expect(event.type).toBe('error') + expect(event.source).toBe('browser') + expect(event.application.id).toBe(CONFIGURATION.applicationId) + expect(event.session).toEqual({ id: SESSION_ID, type: 'user' }) + expect(event.view).toEqual(VIEW) + expect(event.date).toBeGreaterThan(0) + expect(event._dd.format_version).toBe(2) + }) + + it('reports the sample rates it was configured with', () => { + const event = assemble('error', { error: { message: 'boom', source: 'source' } }) as any + + expect(event._dd.configuration.session_sample_rate).toBe(100) + // Session replay cannot run on these browsers, so the rate is reported as zero rather than + // left out, which would read as "unknown" downstream. + expect(event._dd.configuration.session_replay_sample_rate).toBe(0) + }) + + it('leaves out service and version when they are not configured', () => { + const event = assemble('error', { error: { message: 'boom', source: 'source' } }) as any + + expect('service' in event).toBe(false) + expect('version' in event).toBe(false) + }) + + it('includes service and version when they are configured', () => { + const event = assembleEvent({ + type: 'error', + configuration: { ...CONFIGURATION, service: 'checkout', version: '1.2.3' }, + sessionId: SESSION_ID, + view: VIEW, + properties: { error: { message: 'boom', source: 'source' } }, + }) as any + + expect(event.service).toBe('checkout') + expect(event.version).toBe('1.2.3') + }) + + it('attaches user context but never lets it overwrite identity fields', () => { + const event = assemble('error', { error: { message: 'boom', source: 'source' } }, { orderId: 42, type: 'spoofed' }) + + expect((event as any).context).toEqual({ orderId: 42, type: 'spoofed' }) + expect((event as any).type).toBe('error') + }) + + it('leaves out an empty context', () => { + const event = assemble('error', { error: { message: 'boom', source: 'source' } }, {}) + + expect('context' in event).toBe(false) + }) + + it('merges the event specific properties into the envelope', () => { + const event = assemble('view', { + view: { time_spent: 5, action: { count: 1 }, error: { count: 0 }, resource: { count: 0 } }, + }) as any + + // The view sub-object has to keep the identity fields as well as the event specific ones. + expect(event.view.id).toBe(VIEW.id) + expect(event.view.url).toBe(VIEW.url) + expect(event.view.time_spent).toBe(5) + }) +}) diff --git a/packages/rum-legacy/src/domain/eventAssembly.ts b/packages/rum-legacy/src/domain/eventAssembly.ts new file mode 100644 index 0000000000..fac55861f7 --- /dev/null +++ b/packages/rum-legacy/src/domain/eventAssembly.ts @@ -0,0 +1,109 @@ +import { dateNow } from '../tools/timeUtils' + +export interface AssemblyConfiguration { + applicationId: string + sessionSampleRate: number + service?: string + version?: string +} + +export interface ViewContext { + id: string + url: string + referrer: string +} + +export interface AssembleOptions { + type: string + configuration: AssemblyConfiguration + sessionId: string + view: ViewContext + properties: { [key: string]: any } + context?: { [key: string]: any } +} + +/* + * Builds the envelope every event shares. The shape is defined by the intake, not by this package, + * so it mirrors what the modern bundle assembles: same field names, same nesting, same units. + * + * The event specific properties are merged last but cannot displace the identity fields, since the + * `view` sub-object is merged rather than replaced. + */ +export function assembleEvent(options: AssembleOptions): object { + const { type, configuration, sessionId, view, properties, context } = options + + const event: { [key: string]: any } = { + type, + date: dateNow(), + source: 'browser', + application: { + id: configuration.applicationId, + }, + session: { + id: sessionId, + type: 'user', + }, + view: { + id: view.id, + url: view.url, + referrer: view.referrer, + }, + _dd: { + format_version: 2, + drift: 0, + configuration: { + session_sample_rate: configuration.sessionSampleRate, + // Session replay cannot run here. Reporting 0 rather than omitting it keeps the field + // meaningful downstream instead of reading as "unknown". + session_replay_sample_rate: 0, + }, + }, + } + + if (configuration.service) { + event.service = configuration.service + } + if (configuration.version) { + event.version = configuration.version + } + if (context && !isEmptyObject(context)) { + event.context = context + } + + for (const key in properties) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + const value = properties[key] + event[key] = isPlainObject(event[key]) && isPlainObject(value) ? shallowMerge(event[key], value) : value + } + } + + return event +} + +function shallowMerge(base: { [key: string]: any }, extra: { [key: string]: any }): { [key: string]: any } { + const result: { [key: string]: any } = {} + for (const key in base) { + if (Object.prototype.hasOwnProperty.call(base, key)) { + result[key] = base[key] + } + } + for (const key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { + result[key] = extra[key] + } + } + return result +} + +function isPlainObject(value: unknown): value is { [key: string]: any } { + return typeof value === 'object' && value !== null && !(value instanceof Array) +} + +function isEmptyObject(value: { [key: string]: any }): boolean { + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + return false + } + } + return true +} diff --git a/packages/rum-legacy/src/domain/viewManager.spec.ts b/packages/rum-legacy/src/domain/viewManager.spec.ts new file mode 100644 index 0000000000..5e4a057303 --- /dev/null +++ b/packages/rum-legacy/src/domain/viewManager.spec.ts @@ -0,0 +1,188 @@ +import { startViewManager } from './viewManager' + +describe('view manager', () => { + let updates: any[] + let stopManager: (() => void) | undefined + + function start(options?: { readyState?: DocumentReadyState }) { + updates = [] + const manager = startViewManager((properties) => updates.push(properties), { + isDocumentLoaded: () => (options?.readyState ?? 'complete') === 'complete', + }) + stopManager = () => manager.stop() + return manager + } + + afterEach(() => { + stopManager?.() + stopManager = undefined + if (location.hash) { + location.hash = '' + } + }) + + it('starts a view identified by the current location', () => { + const manager = start() + + const view = manager.getCurrentView() + expect(view.id).toMatch(/^[0-9a-f-]{36}$/) + expect(view.url).toBe(location.href) + expect(view.referrer).toBe(document.referrer) + }) + + it('reports the first view as an initial load', () => { + start() + + expect(updates[0].view.loading_type).toBe('initial_load') + }) + + it('reports every event count the schema requires, even the ones always zero here', () => { + start() + + const view = updates[0].view + expect(view.error).toEqual({ count: 0 }) + expect(view.action).toEqual({ count: 0 }) + // Resources and long tasks cannot be observed on these browsers, but the counts are part of the + // event format and omitting them would read as missing data rather than as zero. + expect(view.resource).toEqual({ count: 0 }) + expect(view.long_task).toEqual({ count: 0 }) + expect(view.frustration).toEqual({ count: 0 }) + }) + + it('counts errors and actions into the view', () => { + const manager = start() + + manager.addErrorCount() + manager.addErrorCount() + manager.addActionCount() + manager.flush() + + const last = updates[updates.length - 1].view + expect(last.error.count).toBe(2) + expect(last.action.count).toBe(1) + }) + + it('increments the document version on every update so the intake can order them', () => { + const manager = start() + + manager.flush() + manager.flush() + + const versions = updates.map((update) => update._dd.document_version as number) + expect(versions).toEqual([1, 2, 3]) + }) + + it('reports the view as active until it ends', () => { + const manager = start() + + expect(updates[0].view.is_active).toBe(true) + + manager.stop() + expect(updates[updates.length - 1].view.is_active).toBe(false) + }) + + it('measures time spent in nanoseconds', () => { + jasmine.clock().install() + const manager = start() + jasmine.clock().mockDate(new Date(Date.now() + 2000)) + manager.flush() + jasmine.clock().uninstall() + + // The event format uses nanoseconds, so two seconds is 2e9 and not 2000. + expect(updates[updates.length - 1].view.time_spent).toBe(2_000_000_000) + }) + + it('starts a new view on a hash change and closes the previous one', () => { + const manager = start() + const firstViewId = manager.getCurrentView().id + + location.hash = '#/orders' + window.dispatchEvent(new Event('hashchange')) + + expect(manager.getCurrentView().id).not.toBe(firstViewId) + const closing = updates.filter((update) => update.view.is_active === false) + expect(closing.length).toBe(1) + }) + + it('reports a view started by navigation as a route change, not an initial load', () => { + const manager = start() + + manager.startView() + + expect(updates[updates.length - 1].view.loading_type).toBe('route_change') + }) + + it('restarts the document version for each new view', () => { + const manager = start() + + manager.startView() + const firstUpdateOfNewView = updates[updates.length - 1] + + expect(firstUpdateOfNewView._dd.document_version).toBe(1) + }) + + it('accepts a name for a manually started view', () => { + const manager = start() + + manager.startView('checkout') + + expect(updates[updates.length - 1].view.name).toBe('checkout') + }) + + describe('navigation timings', () => { + it('derives page load timings from performance.timing, in nanoseconds', () => { + const navigationStart = 1_000_000 + spyOnProperty(performance, 'timing', 'get').and.returnValue({ + navigationStart, + responseStart: navigationStart + 100, + domInteractive: navigationStart + 200, + domContentLoadedEventEnd: navigationStart + 300, + domComplete: navigationStart + 400, + loadEventEnd: navigationStart + 500, + } as any) + + start() + + const view = updates[0].view + expect(view.first_byte).toBe(100_000_000) + expect(view.dom_interactive).toBe(200_000_000) + expect(view.dom_content_loaded).toBe(300_000_000) + expect(view.dom_complete).toBe(400_000_000) + expect(view.load_event).toBe(500_000_000) + }) + + it('leaves out timings the browser has not reached yet', () => { + const navigationStart = 1_000_000 + spyOnProperty(performance, 'timing', 'get').and.returnValue({ + navigationStart, + responseStart: navigationStart + 100, + domInteractive: 0, + domContentLoadedEventEnd: 0, + domComplete: 0, + loadEventEnd: 0, + } as any) + + start() + + const view = updates[0].view + expect(view.first_byte).toBe(100_000_000) + expect('dom_interactive' in view).toBe(false) + expect('load_event' in view).toBe(false) + }) + + it('reports no timings rather than failing when performance.timing is missing', () => { + spyOnProperty(performance, 'timing', 'get').and.returnValue(undefined as any) + + expect(() => start()).not.toThrow() + expect('first_byte' in updates[0].view).toBe(false) + }) + + it('does not attach page load timings to a route change', () => { + const manager = start() + + manager.startView() + + expect('first_byte' in updates[updates.length - 1].view).toBe(false) + }) + }) +}) diff --git a/packages/rum-legacy/src/domain/viewManager.ts b/packages/rum-legacy/src/domain/viewManager.ts new file mode 100644 index 0000000000..5682a38f30 --- /dev/null +++ b/packages/rum-legacy/src/domain/viewManager.ts @@ -0,0 +1,178 @@ +import { dateNow } from '../tools/timeUtils' +import { getZoneJsOriginalValue } from '../tools/zoneJs' +import { generateUUID } from '../transport/intakeUrl' +import type { ViewContext } from './eventAssembly' + +const INITIAL_LOAD = 'initial_load' +const ROUTE_CHANGE = 'route_change' + +export interface ViewManagerOptions { + isDocumentLoaded: () => boolean +} + +interface CurrentView extends ViewContext { + name?: string + loadingType: string + startTime: number + errorCount: number + actionCount: number + documentVersion: number +} + +export function startViewManager( + onViewUpdate: (properties: { [key: string]: any }) => void, + options?: Partial +) { + const isDocumentLoaded = options?.isDocumentLoaded ?? (() => document.readyState === 'complete') + + let currentView = createView(INITIAL_LOAD) + let stopped = false + + function createView(loadingType: string, name?: string): CurrentView { + return { + id: generateUUID(), + url: location.href, + referrer: document.referrer, + name, + loadingType, + startTime: dateNow(), + errorCount: 0, + actionCount: 0, + documentVersion: 0, + } + } + + function emit(isActive: boolean): void { + currentView.documentVersion++ + + const view: { [key: string]: any } = { + loading_type: currentView.loadingType, + time_spent: toServerDuration(dateNow() - currentView.startTime), + is_active: isActive, + // These counts are always zero on these browsers, but they are part of the event format. + // Leaving them out would read downstream as missing data rather than as a real zero. + error: { count: currentView.errorCount }, + action: { count: currentView.actionCount }, + resource: { count: 0 }, + long_task: { count: 0 }, + frustration: { count: 0 }, + } + + if (currentView.name) { + view.name = currentView.name + } + + if (currentView.loadingType === INITIAL_LOAD) { + addNavigationTimings(view) + } + + onViewUpdate({ + view, + _dd: { document_version: currentView.documentVersion }, + }) + } + + function endCurrentView(): void { + emit(false) + } + + function startNewView(loadingType: string, name?: string): void { + endCurrentView() + currentView = createView(loadingType, name) + emit(true) + } + + function onHashChange(): void { + if (!stopped) { + // The only route change these browsers can report: there is no History API to hook into. + startNewView(ROUTE_CHANGE) + } + } + + function onLoad(): void { + if (!stopped) { + // Re-emit once the load event has landed so the page load timings are complete. + emit(true) + } + } + + const addEventListener = getZoneJsOriginalValue(window, 'addEventListener') + addEventListener.call(window, 'hashchange', onHashChange) + if (!isDocumentLoaded()) { + addEventListener.call(window, 'load', onLoad) + } + + emit(true) + + return { + getCurrentView(): ViewContext { + return { id: currentView.id, url: currentView.url, referrer: currentView.referrer } + }, + + startView(name?: string): void { + if (!stopped) { + startNewView(ROUTE_CHANGE, name) + } + }, + + addErrorCount(): void { + currentView.errorCount++ + }, + + addActionCount(): void { + currentView.actionCount++ + }, + + flush(): void { + if (!stopped) { + emit(true) + } + }, + + stop(): void { + if (stopped) { + return + } + endCurrentView() + stopped = true + const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener') + removeEventListener.call(window, 'hashchange', onHashChange) + removeEventListener.call(window, 'load', onLoad) + }, + } +} + +/* + * performance.timing holds absolute epoch timestamps. The event format wants durations relative to + * the navigation start, expressed in nanoseconds. + * + * A zero means the browser has not reached that milestone, not that it took no time, so those are + * left out rather than reported as 0. + */ +function addNavigationTimings(view: { [key: string]: any }): void { + const timing = performance && performance.timing + if (!timing || !timing.navigationStart) { + return + } + + const navigationStart = timing.navigationStart + const timings: Array<[string, number]> = [ + ['first_byte', timing.responseStart], + ['dom_interactive', timing.domInteractive], + ['dom_content_loaded', timing.domContentLoadedEventEnd], + ['dom_complete', timing.domComplete], + ['load_event', timing.loadEventEnd], + ] + + for (let i = 0; i < timings.length; i++) { + const name = timings[i][0] + const timestamp = timings[i][1] + if (timestamp > 0 && timestamp >= navigationStart) { + view[name] = toServerDuration(timestamp - navigationStart) + } + } +} + +function toServerDuration(durationInMilliseconds: number): number { + return Math.round(durationInMilliseconds * 1e6) +} diff --git a/yarn.lock b/yarn.lock index 4cf8f4b170..5eb84ca434 100644 --- a/yarn.lock +++ b/yarn.lock @@ -627,6 +627,7 @@ __metadata: version: 0.0.0-use.local resolution: "@flashcatcloud/browser-rum-legacy@workspace:packages/rum-legacy" dependencies: + ajv: "npm:8.17.1" terser-webpack-plugin: "npm:5.3.14" webpack: "npm:5.99.8" languageName: unknown From 8949a5291ffaa3d0c91f65a9ce917e03a07ee13f Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 05:53:56 -0700 Subject: [PATCH 04/32] feat(rum-legacy): expose the full public API surface Wires collection, assembly, batching and transport behind the same FC_RUM surface the modern bundle exposes. Methods that cannot be supported here are no-ops rather than absent. There is no PerformanceObserver for vitals, no MutationObserver for session replay and no way to observe resource timings, but a missing method throws "undefined is not a function" and takes the host page down, which is the failure this package exists to prevent. A page written against the modern bundle therefore runs unchanged. Every public method is wrapped so an internal failure cannot surface as an exception in the page. onReady is deliberately left unwrapped: it invokes the caller's own callback, and swallowing there would hide the customer's exceptions rather than ours. The view context is passed to the view update callback rather than read back from the manager. The first update is emitted while the manager is still being constructed, so reading it back threw and, being caught by the safety net, silently produced no events at all. Uncaught and manually added errors share one path, so an error is counted and reported exactly once. The bundle size grows from 505 bytes to 39 KiB of sources, still parsing as ES5. --- .../rum-legacy/src/boot/publicApi.spec.ts | 348 ++++++++++++++++++ packages/rum-legacy/src/boot/publicApi.ts | 332 +++++++++++++++++ .../rum-legacy/src/domain/errorCollection.ts | 6 +- packages/rum-legacy/src/domain/viewManager.ts | 16 +- packages/rum-legacy/src/entries/main.ts | 17 +- 5 files changed, 696 insertions(+), 23 deletions(-) create mode 100644 packages/rum-legacy/src/boot/publicApi.spec.ts create mode 100644 packages/rum-legacy/src/boot/publicApi.ts diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts new file mode 100644 index 0000000000..50ed49ea4f --- /dev/null +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -0,0 +1,348 @@ +import type { BuildEnvWindow } from '../../../core/test' +import { FLUSH_TIMEOUT } from '../transport/batch' +import { deleteSessionCookie } from '../domain/sessionStore' +import { makeRumLegacyPublicApi } from './publicApi' + +/** + * These specs drive the whole package end to end: the public api, collection, assembly, batching + * and the transport, with only XMLHttpRequest faked. What lands in `payloads` is what a browser + * would actually put on the wire. + */ +describe('public api', () => { + const VALID_CONFIGURATION = { + applicationId: '00000000-aaaa-0000-aaaa-000000000000', + clientToken: 'some_client_token', + proxy: '/rum-intake/', + } + + let payloads: string[] + let originalXhr: typeof XMLHttpRequest + let api: ReturnType + + /** The api as an untyped bag of methods, for the specs that iterate over the whole surface. */ + function anyApi(): { [method: string]: (...args: unknown[]) => unknown } { + return api as unknown as { [method: string]: (...args: unknown[]) => unknown } + } + + type SentEvent = { [key: string]: any } + + function sentEvents(): SentEvent[] { + return payloads + .join('\n') + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as SentEvent) + } + + function eventsOfType(type: string): SentEvent[] { + return sentEvents().filter((event) => event.type === type) + } + + function flush() { + jasmine.clock().tick(FLUSH_TIMEOUT) + } + + beforeEach(() => { + // Unit builds keep this placeholder unreplaced, so each spec file has to provide it. Relying on + // another spec file to set it makes the suite order dependent, and karma randomises the order. + ;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test-version' + payloads = [] + jasmine.clock().install() + originalXhr = window.XMLHttpRequest + ;(window as any).XMLHttpRequest = function () { + return { + open: () => undefined, + setRequestHeader: () => undefined, + send: (body: string) => payloads.push(body), + } + } + api = makeRumLegacyPublicApi() + }) + + afterEach(() => { + anyApi()._stop() + window.XMLHttpRequest = originalXhr + jasmine.clock().uninstall() + deleteSessionCookie() + }) + + describe('initialisation', () => { + it('starts reporting once initialised', () => { + api.init(VALID_CONFIGURATION) + flush() + + expect(eventsOfType('view').length).toBeGreaterThan(0) + }) + + it('sends nothing before it is initialised', () => { + api.addError(new Error('boom')) + api.addAction('click') + flush() + + expect(payloads).toEqual([]) + }) + + it('exposes the configuration it was initialised with', () => { + api.init(VALID_CONFIGURATION) + + expect(api.getInitConfiguration()).toEqual(VALID_CONFIGURATION) + }) + + it('ignores a second initialisation instead of starting twice', () => { + api.init(VALID_CONFIGURATION) + flush() + const afterFirst = eventsOfType('view').length + + api.init(VALID_CONFIGURATION) + flush() + + expect(afterFirst).toBeGreaterThan(0) + expect(eventsOfType('view').length).toBe(afterFirst) + }) + + for (const missing of ['applicationId', 'clientToken', 'proxy']) { + it(`refuses to start without ${missing}, without throwing`, () => { + const configuration: any = { ...VALID_CONFIGURATION } + delete configuration[missing] + + expect(() => api.init(configuration)).not.toThrow() + flush() + expect(payloads).toEqual([]) + }) + } + + it('refuses a sample rate outside 0 to 100', () => { + api.init({ ...VALID_CONFIGURATION, sessionSampleRate: 500 }) + flush() + + expect(payloads).toEqual([]) + }) + + it('does not throw when called with nothing at all', () => { + expect(() => anyApi().init()).not.toThrow() + }) + }) + + describe('reporting', () => { + beforeEach(() => { + api.init(VALID_CONFIGURATION) + }) + + it('reports a manually added error', () => { + api.addError(new Error('boom')) + flush() + + const errors = eventsOfType('error') + expect(errors.length).toBe(1) + expect(errors[0].error.message).toBe('boom') + expect(errors[0].error.handling).toBe('handled') + }) + + it('reports an error only once', () => { + api.addError(new Error('boom')) + flush() + + expect(eventsOfType('error').length).toBe(1) + }) + + it('reports a custom action', () => { + api.addAction('checkout') + flush() + + const actions = eventsOfType('action') + expect(actions.length).toBe(1) + expect(actions[0].action.target.name).toBe('checkout') + expect(actions[0].action.type).toBe('custom') + }) + + it('counts errors and actions into the view', () => { + api.addError(new Error('boom')) + api.addAction('checkout') + api.startView('next') + flush() + + const closedView = eventsOfType('view').filter((event) => event.view.is_active === false)[0] + expect(closedView.view.error.count).toBe(1) + expect(closedView.view.action.count).toBe(1) + }) + + it('attaches the global context to events', () => { + api.setGlobalContext({ tenant: 'acme' }) + api.addError(new Error('boom')) + flush() + + expect(eventsOfType('error')[0].context).toEqual({ tenant: 'acme' }) + }) + + it('lets a per-event context extend the global one', () => { + api.setGlobalContext({ tenant: 'acme' }) + api.addError(new Error('boom'), { orderId: 7 }) + flush() + + expect(eventsOfType('error')[0].context).toEqual({ tenant: 'acme', orderId: 7 }) + }) + + it('attaches the user to events under the field the intake expects', () => { + api.setUser({ id: 'u-1', name: 'Ada' }) + api.addError(new Error('boom')) + flush() + + expect(eventsOfType('error')[0].usr).toEqual({ id: 'u-1', name: 'Ada' }) + }) + + it('leaves out an account without an id, which the format would reject', () => { + api.setAccount({ name: 'no id here' }) + api.addError(new Error('boom')) + flush() + + expect('account' in eventsOfType('error')[0]).toBe(false) + }) + + it('keeps a stable session id across events', () => { + api.addError(new Error('first')) + api.addAction('checkout') + flush() + + const ids = sentEvents().map((event) => event.session.id as string) + expect(new Set(ids).size).toBe(1) + }) + + it('stops reporting after the session is stopped', () => { + api.stopSession() + payloads = [] + + api.addError(new Error('boom')) + flush() + + expect(payloads).toEqual([]) + }) + + it('sends to the configured proxy path', () => { + let url = '' + ;(window as any).XMLHttpRequest = function () { + return { + open: (_method: string, requestUrl: string) => (url = requestUrl), + setRequestHeader: () => undefined, + send: () => undefined, + } + } + + api.addError(new Error('boom')) + flush() + + expect(url.indexOf(`${location.origin}/rum-intake/?ddforward=`)).toBe(0) + }) + }) + + describe('safety net', () => { + const NO_OP_METHODS = [ + 'setTrackingConsent', + 'setViewContext', + 'setViewContextProperty', + 'getViewContext', + 'addTiming', + 'addFeatureFlagEvaluation', + 'getSessionReplayLink', + 'startSessionReplayRecording', + 'stopSessionReplayRecording', + 'addDurationVital', + 'startDurationVital', + 'stopDurationVital', + ] + + const SUPPORTED_METHODS = [ + 'init', + 'getInitConfiguration', + 'getInternalContext', + 'addError', + 'addAction', + 'startView', + 'setViewName', + 'setGlobalContext', + 'getGlobalContext', + 'setGlobalContextProperty', + 'removeGlobalContextProperty', + 'clearGlobalContext', + 'setUser', + 'getUser', + 'setUserProperty', + 'removeUserProperty', + 'clearUser', + 'setAccount', + 'getAccount', + 'setAccountProperty', + 'removeAccountProperty', + 'clearAccount', + 'stopSession', + ] + + it('exposes every method a page written against the modern bundle may call', () => { + // A missing method throws "undefined is not a function" and takes the page down, which is the + // failure this build exists to prevent. Presence matters more than behaviour here. + for (const method of NO_OP_METHODS.concat(SUPPORTED_METHODS).concat(['onReady'])) { + expect(typeof anyApi()[method]).toBe('function', `${method} is missing`) + } + }) + + it('survives every method being called before initialisation', () => { + // onReady is excluded on purpose. It invokes the caller's own callback directly and must not + // wrap it: swallowing there would hide the customer's exceptions rather than the SDK's. The + // modern bundle leaves it unmonitored for the same reason. + for (const method of NO_OP_METHODS.concat(SUPPORTED_METHODS)) { + expect(() => anyApi()[method]('a', 'b')).not.toThrow() + } + }) + + it('lets an exception from an onReady callback surface to the page', () => { + expect(() => + api.onReady(() => { + throw new Error('customer callback is broken') + }) + ).toThrowError('customer callback is broken') + }) + + it('survives every method being called after initialisation', () => { + api.init(VALID_CONFIGURATION) + + for (const method of NO_OP_METHODS) { + expect(() => anyApi()[method]('a', 'b')).not.toThrow() + } + }) + + it('reports its version', () => { + expect(typeof api.version).toBe('string') + }) + + it('runs an onReady callback immediately, since the bundle has already loaded', () => { + const callback = jasmine.createSpy('callback') + + api.onReady(callback) + + expect(callback).toHaveBeenCalled() + }) + + it('keeps working when the transport is completely broken', () => { + ;(window as any).XMLHttpRequest = function () { + throw new Error('blocked by the browser') + } + api.init(VALID_CONFIGURATION) + + expect(() => { + api.addError(new Error('boom')) + flush() + }).not.toThrow() + }) + + it('does not let a circular context break reporting', () => { + api.init(VALID_CONFIGURATION) + const circular: any = {} + circular.self = circular + + expect(() => { + api.setGlobalContext(circular) + api.addError(new Error('boom')) + flush() + }).not.toThrow() + }) + }) +}) diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts new file mode 100644 index 0000000000..fb39b21f94 --- /dev/null +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -0,0 +1,332 @@ +import { assembleEvent } from '../domain/eventAssembly' +import type { AssemblyConfiguration, ViewContext } from '../domain/eventAssembly' +import { startErrorCollection } from '../domain/errorCollection' +import { createSessionStore } from '../domain/sessionStore' +import { startViewManager } from '../domain/viewManager' +import { displayError, displayWarn } from '../tools/display' +import { startBatch } from '../transport/batch' +import { createHttpRequest } from '../transport/httpRequest' +import { createIntakeUrlBuilder, generateUUID } from '../transport/intakeUrl' + +// replaced at build time +declare const __BUILD_ENV__SDK_VERSION__: string + +export interface LegacyInitConfiguration { + applicationId: string + clientToken: string + /** + * Path or url the events are sent to, proxied to the intake by the customer's own server. Same + * option name and same semantics as the modern bundle. + */ + proxy: string + service?: string + version?: string + env?: string + sessionSampleRate?: number + // Options that only apply to the modern bundle are accepted and ignored, so a page can share one + // configuration object between both builds. + [key: string]: unknown +} + +type Context = { [key: string]: any } + +/* + * Every public method is wrapped: a failure inside the SDK must never surface as an exception in + * the host page. This is the last line of the safety net, after the individual try/catch blocks in + * the transport and collection layers. + */ +function monitor(fn: (...args: Args) => Result): (...args: Args) => Result | undefined { + return function (...args: Args): Result | undefined { + try { + return fn(...args) + } catch (error) { + displayError('internal error', error) + return undefined + } + } +} + +export function makeRumLegacyPublicApi() { + let running: ReturnType | undefined + let initConfiguration: LegacyInitConfiguration | undefined + + let globalContext: Context = {} + let userContext: Context = {} + let accountContext: Context = {} + + function start(configuration: LegacyInitConfiguration) { + const assemblyConfiguration: AssemblyConfiguration = { + applicationId: configuration.applicationId, + sessionSampleRate: configuration.sessionSampleRate ?? 100, + service: configuration.service, + version: configuration.version, + } + + const sessionStore = createSessionStore() + const buildUrl = createIntakeUrlBuilder({ + clientToken: configuration.clientToken, + proxy: configuration.proxy, + env: configuration.env, + service: configuration.service, + version: configuration.version, + }) + const batch = startBatch(createHttpRequest(buildUrl)) + + // The view is passed in rather than read back from the view manager: the first view update is + // emitted while startViewManager is still running, before the binding below exists. + function sendEvent(type: string, properties: Context, view: ViewContext, context?: Context): void { + const session = sessionStore.getOrCreateSession() + const event = assembleEvent({ + type, + configuration: assemblyConfiguration, + sessionId: session.id, + view, + properties: withIdentityContexts(properties), + context: mergeContext(globalContext, context), + }) + batch.add(event) + } + + const viewManager = startViewManager((properties, view) => { + sendEvent('view', properties, view) + }) + + // Both uncaught and manually added errors arrive here, so the count and the event stay in one + // place and an error cannot be reported twice. + const errorCollection = startErrorCollection((error, context) => { + viewManager.addErrorCount() + sendEvent('error', { error }, viewManager.getCurrentView(), context) + }) + + return { + stop() { + viewManager.stop() + errorCollection.stop() + batch.flush() + batch.stop() + }, + addError(value: unknown, context?: Context) { + errorCollection.addError(value, context) + }, + addAction(name: string, context?: Context) { + viewManager.addActionCount() + sendEvent( + 'action', + { + action: { + id: generateUUID(), + type: 'custom', + target: { name }, + }, + }, + viewManager.getCurrentView(), + context + ) + }, + startView(name?: string) { + viewManager.startView(name) + }, + } + } + + function withIdentityContexts(properties: Context): Context { + const result: Context = {} + for (const key in properties) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + result[key] = properties[key] + } + } + if (!isEmpty(userContext)) { + result.usr = userContext + } + // The schema requires an id on account, so an account without one is left out rather than + // making every event invalid. + if (!isEmpty(accountContext) && accountContext.id !== undefined) { + result.account = accountContext + } + return result + } + + const api = { + version: __BUILD_ENV__SDK_VERSION__, + + onReady(callback: () => void) { + callback() + }, + + init: monitor((configuration: LegacyInitConfiguration) => { + if (running) { + displayWarn('SDK is already initialized, ignoring this call.') + return + } + if (!validate(configuration)) { + return + } + initConfiguration = configuration + running = start(configuration) + }), + + getInitConfiguration: monitor(() => initConfiguration), + + getInternalContext: monitor(() => undefined), + + addError: monitor((error: unknown, context?: Context) => { + running?.addError(error, context) + }), + + addAction: monitor((name: string, context?: Context) => { + running?.addAction(name, context) + }), + + startView: monitor((nameOrOptions?: string | { name?: string }) => { + const name = typeof nameOrOptions === 'string' ? nameOrOptions : nameOrOptions?.name + running?.startView(name) + }), + + // Renaming the current view is not possible here: a view event has already been sent under the + // old name, so the rename is applied by starting a new view instead. + setViewName: monitor((name: string) => { + running?.startView(name) + }), + + setGlobalContext: monitor((context: Context) => { + globalContext = context ?? {} + }), + getGlobalContext: monitor(() => globalContext), + setGlobalContextProperty: monitor((key: string, value: any) => { + globalContext[key] = value + }), + removeGlobalContextProperty: monitor((key: string) => { + delete globalContext[key] + }), + clearGlobalContext: monitor(() => { + globalContext = {} + }), + + setUser: monitor((user: Context) => { + userContext = user ?? {} + }), + getUser: monitor(() => userContext), + setUserProperty: monitor((key: string, value: any) => { + userContext[key] = value + }), + removeUserProperty: monitor((key: string) => { + delete userContext[key] + }), + clearUser: monitor(() => { + userContext = {} + }), + + setAccount: monitor((account: Context) => { + accountContext = account ?? {} + }), + getAccount: monitor(() => accountContext), + setAccountProperty: monitor((key: string, value: any) => { + accountContext[key] = value + }), + removeAccountProperty: monitor((key: string) => { + delete accountContext[key] + }), + clearAccount: monitor(() => { + accountContext = {} + }), + + stopSession: monitor(() => { + running?.stop() + running = undefined + }), + + /* + * Everything below exists so that a page written against the modern bundle keeps running here + * unchanged. None of it can be supported on browsers without the underlying platform APIs: + * there is no PerformanceObserver for vitals, no MutationObserver for session replay, and no + * way to observe resource timings. + * + * They are no-ops rather than missing properties on purpose. A missing method throws + * "undefined is not a function" and takes the host page down, which is the exact failure this + * build exists to prevent. + */ + setTrackingConsent: monitor(() => undefined), + setViewContext: monitor(() => undefined), + setViewContextProperty: monitor(() => undefined), + getViewContext: monitor(() => ({})), + addTiming: monitor(() => undefined), + addFeatureFlagEvaluation: monitor(() => undefined), + getSessionReplayLink: monitor(() => undefined), + startSessionReplayRecording: monitor(() => undefined), + stopSessionReplayRecording: monitor(() => undefined), + addDurationVital: monitor(() => undefined), + startDurationVital: monitor(() => undefined), + stopDurationVital: monitor(() => undefined), + } + + // Internal escape hatch used by the specs to tear down between runs, kept off the public surface + // the same way the modern bundle hides its debug switch. + Object.defineProperty(api, '_stop', { + value: () => { + running?.stop() + running = undefined + }, + enumerable: false, + }) + + return api +} + +function validate(configuration: LegacyInitConfiguration | undefined): boolean { + if (!configuration) { + displayError('Missing configuration') + return false + } + if (!configuration.clientToken) { + displayError('Client Token is not configured, we will not send any data.') + return false + } + if (!configuration.applicationId) { + displayError('Application ID is not configured, no RUM data will be collected.') + return false + } + if (!configuration.proxy) { + // Without a same-origin path there is nowhere to send to: these browsers cannot do a + // cross-origin XMLHttpRequest with the headers the intake expects. + displayError('proxy is not configured, we will not send any data.') + return false + } + if ( + configuration.sessionSampleRate !== undefined && + (typeof configuration.sessionSampleRate !== 'number' || + configuration.sessionSampleRate < 0 || + configuration.sessionSampleRate > 100) + ) { + displayError('Session Sample Rate should be a number between 0 and 100') + return false + } + return true +} + +function mergeContext(base: Context, extra?: Context): Context { + if (!extra || isEmpty(extra)) { + return base + } + const result: Context = {} + for (const key in base) { + if (Object.prototype.hasOwnProperty.call(base, key)) { + result[key] = base[key] + } + } + for (const key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { + result[key] = extra[key] + } + } + return result +} + +function isEmpty(value: Context): boolean { + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + return false + } + } + return true +} diff --git a/packages/rum-legacy/src/domain/errorCollection.ts b/packages/rum-legacy/src/domain/errorCollection.ts index 34f55c7680..60d302db9f 100644 --- a/packages/rum-legacy/src/domain/errorCollection.ts +++ b/packages/rum-legacy/src/domain/errorCollection.ts @@ -22,7 +22,7 @@ export interface CollectedError { * silently disable the customer's own error reporting, which is exactly the kind of interference * this build must not cause. */ -export function startErrorCollection(onError: (error: CollectedError) => void) { +export function startErrorCollection(onError: (error: CollectedError, context?: { [key: string]: any }) => void) { const previousOnError = window.onerror function handleError(message: Event | string, url?: string, line?: number, column?: number, error?: Error): boolean { @@ -48,8 +48,8 @@ export function startErrorCollection(onError: (error: CollectedError) => void) { window.onerror = handleError return { - addError(value: unknown): void { - onError(computeManualError(value)) + addError(value: unknown, context?: { [key: string]: any }): void { + onError(computeManualError(value), context) }, stop(): void { diff --git a/packages/rum-legacy/src/domain/viewManager.ts b/packages/rum-legacy/src/domain/viewManager.ts index 5682a38f30..6e40a6ed6e 100644 --- a/packages/rum-legacy/src/domain/viewManager.ts +++ b/packages/rum-legacy/src/domain/viewManager.ts @@ -20,7 +20,10 @@ interface CurrentView extends ViewContext { } export function startViewManager( - onViewUpdate: (properties: { [key: string]: any }) => void, + // The view context is handed to the callback rather than looked up from the manager: the first + // update is emitted while this function is still running, so the caller cannot yet hold a + // reference to the manager it is constructing. + onViewUpdate: (properties: { [key: string]: any }, view: ViewContext) => void, options?: Partial ) { const isDocumentLoaded = options?.isDocumentLoaded ?? (() => document.readyState === 'complete') @@ -66,10 +69,13 @@ export function startViewManager( addNavigationTimings(view) } - onViewUpdate({ - view, - _dd: { document_version: currentView.documentVersion }, - }) + onViewUpdate( + { + view, + _dd: { document_version: currentView.documentVersion }, + }, + { id: currentView.id, url: currentView.url, referrer: currentView.referrer } + ) } function endCurrentView(): void { diff --git a/packages/rum-legacy/src/entries/main.ts b/packages/rum-legacy/src/entries/main.ts index 80e44facd0..62595c6f6c 100644 --- a/packages/rum-legacy/src/entries/main.ts +++ b/packages/rum-legacy/src/entries/main.ts @@ -1,23 +1,10 @@ import { defineGlobal } from '../boot/global' - -// replaced at build time -declare const __BUILD_ENV__SDK_VERSION__: string +import { makeRumLegacyPublicApi } from '../boot/publicApi' interface BrowserWindow extends Window { FC_RUM?: unknown } -export const flashcatRumLegacy = { - version: __BUILD_ENV__SDK_VERSION__, - - /** - * Kept for parity with the modern bundle: once this script has run the SDK is loaded, so the - * callback can be invoked straight away. The loader snippet's placeholder queues callbacks - * registered before that point, and `defineGlobal` drains them below. - */ - onReady(callback: () => void): void { - callback() - }, -} +export const flashcatRumLegacy = makeRumLegacyPublicApi() defineGlobal(window as BrowserWindow, 'FC_RUM', flashcatRumLegacy) From a7933acbd2e7b46679c14a5a38a6cab8d5ebd1af Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 06:08:13 -0700 Subject: [PATCH 05/32] test(rum-legacy): verify behaviour without the modern browser APIs Adds a fixture that removes fetch, Promise, sendBeacon, the observers, TextEncoder and the URL constructor, then drives the package end to end through an XMLHttpRequest offering only onreadystatechange. Without it every spec runs in a browser that has all of those, so a dependency on one would pass the suite and fail only where this package is meant to run. The ES2015 collections are deliberately left in place. lib: ES5 already makes using them a compile error, a stronger guarantee than a runtime spec, and the bundle scan covers the emitted output. Removing them here broke the suite's own instrumentation instead: the shared leak detector wraps addEventListener in a function that constructs a Map, so the first listener this package registered failed inside the harness rather than inside the code under test. Globals are restored by putting back the captured property descriptor, and a shadow over an inherited property is deleted rather than overwritten. Restoring navigator.sendBeacon by assignment left it as an own property of the instance rather than a method on Navigator.prototype, which changed its shape for every later spec in the same browser context and failed 41 of them across other packages. check-es5-compatibility.js now also scans the bundle for runtime APIs the target browsers lack. Parsing as ES5 says nothing about those: a bundle full of Promise and fetch parses perfectly well and then fails on the first line that runs. Adds a package README covering setup, the required same-origin proxy, the capability matrix, and an explicit statement that this has not been verified on real hardware. --- packages/rum-legacy/README.md | 133 +++++++++++ .../src/boot/degradedEnvironment.spec.ts | 207 ++++++++++++++++++ scripts/check-es5-compatibility.js | 64 ++++++ 3 files changed, 404 insertions(+) create mode 100644 packages/rum-legacy/README.md create mode 100644 packages/rum-legacy/src/boot/degradedEnvironment.spec.ts diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md new file mode 100644 index 0000000000..833dff0a3c --- /dev/null +++ b/packages/rum-legacy/README.md @@ -0,0 +1,133 @@ +# RUM Browser SDK — legacy build + +A separate, self-contained build of the RUM Browser SDK for browsers without ES2015 support. + +The standard bundles are compiled to ES2018 and send over `fetch` / `sendBeacon`. On a browser that +supports neither, the script fails to parse before any code inside it runs, so no amount of feature +detection in the SDK can help. This package is the answer to that: a smaller SDK, compiled to ES5, +that sends over `XMLHttpRequest`. + +It is distributed through the CDN only and is not published to npm. Bundling it with an application +would put its output back into a file the browser has to parse as a whole, which is the failure this +build exists to avoid. + +## What it collects + +| Capability | Supported | Notes | +| -------------------------- | :-------: | ------------------------------------------------ | +| Uncaught JavaScript errors | ✅ | No stack; the script url and line are reported | +| Page load timings | ✅ | From `performance.timing` | +| Views | ✅ | Initial load, plus `hashchange` and manual views | +| Manual actions and errors | ✅ | `addAction`, `addError` | +| Session and user identity | ✅ | Same session cookie as the standard bundles | +| Resource timings | ❌ | No Resource Timing API | +| Automatic user actions | ❌ | Requires DOM observation not available here | +| Web Vitals, long tasks | ❌ | No `PerformanceObserver` | +| Session replay | ❌ | No `MutationObserver` | +| CSP violation reporting | ❌ | No `securitypolicyviolation` event | + +Everything unsupported is a no-op method rather than a missing one. A page written against the +standard bundle runs unchanged; it does not need to branch on the browser. + +## Setup + +Both builds share the `FC_RUM` global and the same call sequence, so the page carries one snippet. +The choice is made on capability, not on the user agent string, which means a browser running in a +compatibility document mode is classified by what it can actually do. + +```html + + +``` + +Calls made before the bundle arrives are queued on `q` and run once it loads. This is the same +mechanism the standard bundles already use. + +### `proxy` is required + +`proxy` is a path on the page's own origin that the customer's web server forwards to the intake. +It is not optional here, unlike in the standard bundles, because these browsers cannot make a +cross-origin `XMLHttpRequest` carrying the parameters the intake needs. `init` reports the problem +and collects nothing rather than sending requests that would be blocked. + +The request is shaped exactly like the one the standard bundles send, so a single reverse proxy rule +serves both and the intake needs no compatibility branch: + +``` +POST https:///rum-intake/?ddforward= +``` + +An nginx rule forwarding it, for example: + +```nginx +location /rum-intake/ { + proxy_pass https:///; +} +``` + +No request header is set, keeping it a simple request. If a Content Security Policy is in force it +needs to allow the static host and `connect-src` to the page's own origin. `unsafe-eval` is not +required. + +## Configuration + +| Option | Required | Notes | +| ------------------- | :------: | ---------------------------------------- | +| `applicationId` | ✅ | | +| `clientToken` | ✅ | | +| `proxy` | ✅ | Same-origin path forwarded to the intake | +| `service` | | | +| `version` | | | +| `env` | | | +| `sessionSampleRate` | | 0 to 100, defaults to 100 | + +Options that only apply to the standard bundles are accepted and ignored, so one configuration +object can be shared between the two. + +## Development + +```bash +yarn build:bundle # typecheck, bundle, then verify ES5 compatibility +yarn typecheck # ES5 lib check on its own +``` + +`tsconfig.json` deliberately does not extend the repository base config. `lib` is restricted to +`ES5` and `DOM` so that using an API the target browsers lack is a compile error rather than a +runtime crash, and `paths` is emptied so `@flashcatcloud/*` imports do not resolve — those packages +are written against ES2018 and importing one would defeat the purpose of this build. + +`scripts/check-es5-compatibility.js` runs as part of the bundle build. It parses the output as ES5, +scans it for runtime APIs the target browsers lack, and asserts that the standard bundles are +rejected, so a broken check cannot pass silently. + +## Testing, and what it does not cover + +The specs run in a modern headless browser. `src/boot/degradedEnvironment.spec.ts` removes `fetch`, +`Promise`, `Map`, `Set`, `Symbol`, `URL`, `TextEncoder` and `sendBeacon`, and drives the package end +to end through an `XMLHttpRequest` that offers only `onreadystatechange`, as IE9 does. + +That covers missing runtime APIs and unsupported syntax. It does not cover the behaviour of an +actual old browser engine. **This package has not been verified on real hardware**, and that +verification is a separate step before any support commitment is made. diff --git a/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts new file mode 100644 index 0000000000..addddc2762 --- /dev/null +++ b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts @@ -0,0 +1,207 @@ +import type { BuildEnvWindow } from '../../../core/test' +import { deleteSessionCookie } from '../domain/sessionStore' +import { FLUSH_TIMEOUT } from '../transport/batch' +import { makeRumLegacyPublicApi } from './publicApi' + +/* + * The closest approximation to the target browsers that a modern test runner allows. + * + * Every other spec runs in a browser that has fetch, Promise, sendBeacon and the observers, so a + * dependency on any of them would pass the whole suite and fail only on the browsers this package + * exists for. Here they are taken away for the duration of the call, which is safe because + * everything this package does is synchronous. + * + * Only APIs that could slip past the compiler are removed. The ES2015 collections (Map, Set, + * Symbol, WeakMap) are deliberately left in place: `lib: ES5` already makes using them a compile + * error, which is a stronger guarantee than a runtime spec, and check-es5-compatibility.js scans + * the emitted bundle for them. Removing them here would only break the suite's own + * instrumentation, since the shared leak detector wraps addEventListener in a function that + * constructs a Map, and the first listener this package registers would then fail inside the test + * harness rather than inside the code under test. + * + * None of this emulates an old JavaScript engine. It catches missing runtime APIs, not syntax or + * engine quirks; the ES5 parse gate covers syntax, and neither covers real IE behaviour. + */ +const REMOVED_GLOBALS = ['fetch', 'Promise', 'MutationObserver', 'PerformanceObserver', 'TextEncoder', 'URL'] as const + +/* + * These globals are shared with every other spec in the suite, which all run in the same browser + * context. Restoring them by plain assignment is not enough: `navigator.sendBeacon` lives on + * Navigator.prototype, so hiding it creates an own property on the instance, and assigning the + * function back leaves that own property in place. The shape has changed even though the value + * looks right, and specs elsewhere that spy on or feature-detect it then behave differently. + * + * So the original property descriptor is captured and put back exactly, and a global that had no + * own property has its shadow deleted rather than overwritten. + */ +function withIE9Environment(operation: () => T): T { + const hidden: Array<{ host: any; name: string; descriptor: PropertyDescriptor | undefined }> = [] + + function hide(host: any, name: string) { + hidden.push({ host, name, descriptor: Object.getOwnPropertyDescriptor(host, name) }) + Object.defineProperty(host, name, { value: undefined, configurable: true, writable: true }) + } + + for (const name of REMOVED_GLOBALS) { + hide(window, name) + } + hide(navigator, 'sendBeacon') + + try { + return operation() + } finally { + for (const { host, name, descriptor } of hidden.reverse()) { + if (descriptor) { + Object.defineProperty(host, name, descriptor) + } else { + // It was inherited: removing the shadow makes the prototype's version visible again. + delete host[name] + } + } + } +} + +describe('degraded environment', () => { + const VALID_CONFIGURATION = { + applicationId: '00000000-aaaa-0000-aaaa-000000000000', + clientToken: 'some_client_token', + proxy: '/rum-intake/', + } + + let payloads: string[] + let requests: Array<{ method?: string; url?: string; async?: boolean }> + let originalXhr: typeof XMLHttpRequest + let api: ReturnType | undefined + + beforeEach(() => { + ;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test-version' + payloads = [] + requests = [] + jasmine.clock().install() + originalXhr = window.XMLHttpRequest + ;(window as any).XMLHttpRequest = function () { + // No onload, no onerror, no onprogress: this is what IE9 offers. + const request: { [key: string]: unknown } = { + readyState: 0, + status: 0, + open(method: string, url: string, isAsync: boolean) { + requests.push({ method, url, async: isAsync }) + }, + send(body: string) { + payloads.push(body) + }, + setRequestHeader() { + throw new Error('setting a request header would make this a preflighted request') + }, + } + return request + } + }) + + afterEach(() => { + ;(api as unknown as { _stop: () => void } | undefined)?._stop() + api = undefined + window.XMLHttpRequest = originalXhr + jasmine.clock().uninstall() + deleteSessionCookie() + }) + + function events(): Array<{ [key: string]: any }> { + return payloads + .join('\n') + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { [key: string]: any }) + } + + it('initialises without any of the modern APIs present', () => { + withIE9Environment(() => { + api = makeRumLegacyPublicApi() + api.init(VALID_CONFIGURATION) + }) + jasmine.clock().tick(FLUSH_TIMEOUT) + + expect(events().length).toBeGreaterThan(0) + }) + + it('reports errors and actions without any of the modern APIs present', () => { + withIE9Environment(() => { + api = makeRumLegacyPublicApi() + api.init(VALID_CONFIGURATION) + api.addError(new Error('boom')) + api.addAction('checkout') + }) + jasmine.clock().tick(FLUSH_TIMEOUT) + + const types = events().map((event) => event.type as string) + expect(types).toContain('error') + expect(types).toContain('action') + expect(types).toContain('view') + }) + + it('sends over XMLHttpRequest without setting any request header', () => { + withIE9Environment(() => { + api = makeRumLegacyPublicApi() + api.init(VALID_CONFIGURATION) + api.addError(new Error('boom')) + }) + jasmine.clock().tick(FLUSH_TIMEOUT) + + // The fake throws if a header is set, so reaching here means the request stayed a simple one. + expect(requests.length).toBeGreaterThan(0) + expect(requests[0].method).toBe('POST') + expect(requests[0].async).toBe(true) + }) + + it('still produces a valid session cookie', () => { + withIE9Environment(() => { + api = makeRumLegacyPublicApi() + api.init(VALID_CONFIGURATION) + }) + jasmine.clock().tick(FLUSH_TIMEOUT) + + expect(document.cookie).toContain('_dd_s=') + expect(events()[0].session.id).toMatch(/^[0-9a-f-]{36}$/) + }) + + it('measures payload size without TextEncoder', () => { + withIE9Environment(() => { + api = makeRumLegacyPublicApi() + api.init(VALID_CONFIGURATION) + // Non-latin content is where a string-length approximation would go wrong. + api.setGlobalContext({ note: '订单支付失败'.repeat(50) }) + api.addError(new Error('boom')) + }) + jasmine.clock().tick(FLUSH_TIMEOUT) + + expect(events().length).toBeGreaterThan(0) + }) + + it('resolves a relative proxy path without the URL constructor', () => { + withIE9Environment(() => { + api = makeRumLegacyPublicApi() + api.init(VALID_CONFIGURATION) + api.addError(new Error('boom')) + }) + jasmine.clock().tick(FLUSH_TIMEOUT) + + expect(requests[0].url!.indexOf(`${location.origin}/rum-intake/?ddforward=`)).toBe(0) + }) + + it('never throws out of the public api when the environment is this bare', () => { + expect(() => + withIE9Environment(() => { + api = makeRumLegacyPublicApi() + api.init(VALID_CONFIGURATION) + api.setUser({ id: 'u-1' }) + api.setGlobalContext({ tenant: 'acme' }) + api.startView('checkout') + api.addError('a string error') + api.addAction('click') + api.startSessionReplayRecording() + api.addDurationVital() + api.stopSession() + }) + ).not.toThrow() + }) +}) diff --git a/scripts/check-es5-compatibility.js b/scripts/check-es5-compatibility.js index 437fcb09a6..2428af94b5 100644 --- a/scripts/check-es5-compatibility.js +++ b/scripts/check-es5-compatibility.js @@ -20,9 +20,49 @@ const EXPECTED_ES5 = ['packages/rum-legacy/bundle/fc-rum-legacy.js'] const EXPECTED_NOT_ES5 = ['packages/rum/bundle/flashcat-rum.js', 'packages/rum-slim/bundle/flashcat-rum-slim.js'] +/** + * Runtime APIs the target browsers do not provide. Parsing as ES5 says nothing about these: a + * bundle full of `Promise` and `fetch` parses perfectly well and then fails on the first line that + * runs. + * + * The degraded environment specs cover the same ground from the other side, but only for the code + * paths they exercise. This covers the whole emitted bundle. + * + * Terser mangles local names to short identifiers, so a match on any of these is a reference to the + * real global rather than a coincidence. + */ +const FORBIDDEN_GLOBALS = [ + 'Promise', + 'fetch', + 'sendBeacon', + 'MutationObserver', + 'PerformanceObserver', + 'TextEncoder', + 'WeakMap', + 'WeakSet', + 'Symbol', + 'Map', + 'Set', + 'requestIdleCallback', +] + +const FORBIDDEN_MEMBERS = ['Object.assign', 'Array.from', 'Object.entries', 'Object.values'] + runMain(() => { const failures = [] + for (const relativePath of EXPECTED_ES5) { + const found = findForbiddenApis(relativePath) + if (found === undefined) { + continue + } + if (found.length > 0) { + failures.push(`${relativePath}: references APIs missing from the target browsers: ${found.join(', ')}`) + } else { + printLog(`✅ ${relativePath} references no API the target browsers lack`) + } + } + for (const relativePath of EXPECTED_ES5) { const result = parseAsEs5(relativePath) if (result.missing) { @@ -73,3 +113,27 @@ function parseAsEs5(relativePath) { function formatError(error) { return typeof error.loc?.line === 'number' ? `line ${error.loc.line}: ${error.message}` : error.message } + +function findForbiddenApis(relativePath) { + const absolutePath = path.join(ROOT_DIR, relativePath) + if (!fs.existsSync(absolutePath)) { + return undefined + } + + const content = fs.readFileSync(absolutePath, 'utf-8') + const found = [] + + for (const global of FORBIDDEN_GLOBALS) { + if (new RegExp(`\\b${global}\\b`).test(content)) { + found.push(global) + } + } + + for (const member of FORBIDDEN_MEMBERS) { + if (content.includes(member)) { + found.push(member) + } + } + + return found +} From 33c455e1a23f6c44b79923636abac940df49faf4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 19:02:47 -0700 Subject: [PATCH 06/32] refactor(rum-legacy): remove duplicated object and error helpers The merge and empty-check loops existed twice, byte for byte, in event assembly and in the public api, because Object.assign and the spread operator both need ES2015 and lib: ES5 rejects them. They move to tools/objectUtils.ts. The block reading a message, name and stack off an Error instance also existed twice in error collection, once for uncaught errors and once for manually added ones. No behaviour change. --- .../rum-legacy/src/domain/errorCollection.ts | 27 +++++++++--------- .../rum-legacy/src/domain/eventAssembly.ts | 25 +---------------- packages/rum-legacy/src/tools/objectUtils.ts | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 packages/rum-legacy/src/tools/objectUtils.ts diff --git a/packages/rum-legacy/src/domain/errorCollection.ts b/packages/rum-legacy/src/domain/errorCollection.ts index 60d302db9f..ba7213e893 100644 --- a/packages/rum-legacy/src/domain/errorCollection.ts +++ b/packages/rum-legacy/src/domain/errorCollection.ts @@ -70,13 +70,7 @@ function computeError(message: Event | string, url?: string, line?: number, erro } if (error) { - collected.message = error.message || collected.message - if (error.name) { - collected.type = error.name - } - if (error.stack) { - collected.stack = error.stack - } + fillFromError(collected, error) return collected } @@ -98,16 +92,21 @@ function computeManualError(value: unknown): CollectedError { } if (value instanceof Error) { - collected.message = value.message - if (value.name) { - collected.type = value.name - } - if (value.stack) { - collected.stack = value.stack - } + fillFromError(collected, value) } else { collected.message = String(value) } return collected } + +/** Everything an Error instance can contribute. Anonymous errors keep the message already set. */ +function fillFromError(collected: CollectedError, error: Error): void { + collected.message = error.message || collected.message + if (error.name) { + collected.type = error.name + } + if (error.stack) { + collected.stack = error.stack + } +} diff --git a/packages/rum-legacy/src/domain/eventAssembly.ts b/packages/rum-legacy/src/domain/eventAssembly.ts index fac55861f7..b2a3b3875a 100644 --- a/packages/rum-legacy/src/domain/eventAssembly.ts +++ b/packages/rum-legacy/src/domain/eventAssembly.ts @@ -1,3 +1,4 @@ +import { isEmptyObject, shallowMerge } from '../tools/objectUtils' import { dateNow } from '../tools/timeUtils' export interface AssemblyConfiguration { @@ -80,30 +81,6 @@ export function assembleEvent(options: AssembleOptions): object { return event } -function shallowMerge(base: { [key: string]: any }, extra: { [key: string]: any }): { [key: string]: any } { - const result: { [key: string]: any } = {} - for (const key in base) { - if (Object.prototype.hasOwnProperty.call(base, key)) { - result[key] = base[key] - } - } - for (const key in extra) { - if (Object.prototype.hasOwnProperty.call(extra, key)) { - result[key] = extra[key] - } - } - return result -} - function isPlainObject(value: unknown): value is { [key: string]: any } { return typeof value === 'object' && value !== null && !(value instanceof Array) } - -function isEmptyObject(value: { [key: string]: any }): boolean { - for (const key in value) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - return false - } - } - return true -} diff --git a/packages/rum-legacy/src/tools/objectUtils.ts b/packages/rum-legacy/src/tools/objectUtils.ts new file mode 100644 index 0000000000..aa59df300e --- /dev/null +++ b/packages/rum-legacy/src/tools/objectUtils.ts @@ -0,0 +1,28 @@ +/* + * Object.assign and the spread operator both need ES2015, and `lib: ES5` rejects them outright, so + * these two shapes are needed in more than one place and live here rather than being repeated. + */ + +export function shallowMerge(base: { [key: string]: any }, extra: { [key: string]: any }): { [key: string]: any } { + const result: { [key: string]: any } = {} + for (const key in base) { + if (Object.prototype.hasOwnProperty.call(base, key)) { + result[key] = base[key] + } + } + for (const key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { + result[key] = extra[key] + } + } + return result +} + +export function isEmptyObject(value: { [key: string]: any }): boolean { + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + return false + } + } + return true +} From 5999eeef6fd3bdb04858bfc7351d03dd6ecb3211 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 19:02:56 -0700 Subject: [PATCH 07/32] fix(rum-legacy): send a closing view update when the page unloads The view event carrying the time spent and the error and action counts was only sent when the session was stopped explicitly. On a normal page close nothing closed the view, so every view reached the intake with the counts and duration it had at page load, which are zero. Error events themselves were unaffected; the view level aggregates were not. Emitting it was not enough on its own. The batch registered its own exit listener when it was created, before the view manager existed, so it always ran first and flushed an empty buffer before the closing update could be added to it. Page exit is now owned in one place, which closes the view and then flushes, and the batch no longer listens for it. The exit path closes the view without shutting collection down. beforeunload can fire for a navigation the user then cancels, and tearing down there would leave the page with a dead SDK. It also runs once per page: the request it makes is synchronous, and blocking a closing browser twice is worse than missing a second closing update on a cancelled navigation. viewManager.flush() is replaced by endView(). It had no caller outside its own specs. --- .../rum-legacy/src/boot/publicApi.spec.ts | 74 ++++++++++++++++++ packages/rum-legacy/src/boot/publicApi.ts | 75 +++++++++---------- .../rum-legacy/src/domain/viewManager.spec.ts | 8 +- packages/rum-legacy/src/domain/viewManager.ts | 11 ++- .../rum-legacy/src/transport/batch.spec.ts | 45 ++++------- packages/rum-legacy/src/transport/batch.ts | 27 +++---- 6 files changed, 149 insertions(+), 91 deletions(-) diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index 50ed49ea4f..ab32661535 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -217,6 +217,80 @@ describe('public api', () => { expect(payloads).toEqual([]) }) + /* + * Page exit handlers are captured rather than dispatched: the test runner installs its own + * beforeunload/unload handlers to detect navigation, and firing real ones makes it believe the + * page reloaded and abandon the run. + */ + function captureExitHandlers() { + const handlers: { [eventName: string]: Array<() => void> } = {} + spyOn(window, 'addEventListener').and.callFake((eventName: string, handler: any) => { + handlers[eventName] = handlers[eventName] || [] + handlers[eventName].push(handler) + }) + return handlers + } + + it('sends a closing view update when the page unloads', () => { + const handlers = captureExitHandlers() + const freshApi = makeRumLegacyPublicApi() + freshApi.init(VALID_CONFIGURATION) + payloads = [] + + handlers.beforeunload[0]() + + const closing = eventsOfType('view').filter((event) => event.view.is_active === false) + expect(closing.length).toBe(1) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + + it('carries the time spent and the counts collected during the view into that update', () => { + const handlers = captureExitHandlers() + const freshApi = makeRumLegacyPublicApi() + freshApi.init(VALID_CONFIGURATION) + freshApi.addError(new Error('boom')) + freshApi.addAction('checkout') + // mockDate, not tick: the jasmine clock advances timers but leaves Date alone, and time spent + // is measured from the wall clock. + jasmine.clock().mockDate(new Date(Date.now() + 2000)) + payloads = [] + + handlers.beforeunload[0]() + + const closing = eventsOfType('view').filter((event) => event.view.is_active === false)[0] + expect(closing.view.error.count).toBe(1) + expect(closing.view.action.count).toBe(1) + expect(closing.view.time_spent).toBeGreaterThan(0) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + + it('closes the view before sending, so the closing update is in the same request', () => { + const handlers = captureExitHandlers() + const freshApi = makeRumLegacyPublicApi() + freshApi.init(VALID_CONFIGURATION) + payloads = [] + + handlers.beforeunload[0]() + + // A single request carrying the closing update. Flushing before closing the view would send + // an empty buffer and lose it entirely. + expect(payloads.length).toBe(1) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + + it('does not block the closing page with a second synchronous request', () => { + const handlers = captureExitHandlers() + const freshApi = makeRumLegacyPublicApi() + freshApi.init(VALID_CONFIGURATION) + payloads = [] + + handlers.beforeunload[0]() + handlers.unload[0]() + + expect(payloads.length).toBe(1) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + it('sends to the configured proxy path', () => { let url = '' ;(window as any).XMLHttpRequest = function () { diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index fb39b21f94..bb476b5af9 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -4,6 +4,8 @@ import { startErrorCollection } from '../domain/errorCollection' import { createSessionStore } from '../domain/sessionStore' import { startViewManager } from '../domain/viewManager' import { displayError, displayWarn } from '../tools/display' +import { isEmptyObject, shallowMerge } from '../tools/objectUtils' +import { getZoneJsOriginalValue } from '../tools/zoneJs' import { startBatch } from '../transport/batch' import { createHttpRequest } from '../transport/httpRequest' import { createIntakeUrlBuilder, generateUUID } from '../transport/intakeUrl' @@ -82,7 +84,7 @@ export function makeRumLegacyPublicApi() { sessionId: session.id, view, properties: withIdentityContexts(properties), - context: mergeContext(globalContext, context), + context: context && !isEmptyObject(context) ? shallowMerge(globalContext, context) : globalContext, }) batch.add(event) } @@ -98,12 +100,39 @@ export function makeRumLegacyPublicApi() { sendEvent('error', { error }, viewManager.getCurrentView(), context) }) + /* + * Page exit is owned here rather than by the batch, because the order matters: the closing view + * update carries the time spent and the error and action counts, and it has to be in the buffer + * before the buffer is sent. A listener inside startBatch would always run first and flush an + * empty buffer. + * + * beforeunload and unload are the only signals available before IE10. The exit runs once: the + * synchronous request it makes blocks the browser, and doing that twice while a page is closing + * is worse than missing a second closing update on the rare cancelled navigation. + */ + let exited = false + function onPageExit(): void { + if (exited) { + return + } + exited = true + viewManager.endView() + batch.flushOnExit() + } + + const addEventListener = getZoneJsOriginalValue(window, 'addEventListener') + addEventListener.call(window, 'beforeunload', onPageExit) + addEventListener.call(window, 'unload', onPageExit) + return { stop() { viewManager.stop() errorCollection.stop() batch.flush() batch.stop() + const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener') + removeEventListener.call(window, 'beforeunload', onPageExit) + removeEventListener.call(window, 'unload', onPageExit) }, addError(value: unknown, context?: Context) { errorCollection.addError(value, context) @@ -130,21 +159,16 @@ export function makeRumLegacyPublicApi() { } function withIdentityContexts(properties: Context): Context { - const result: Context = {} - for (const key in properties) { - if (Object.prototype.hasOwnProperty.call(properties, key)) { - result[key] = properties[key] - } - } - if (!isEmpty(userContext)) { - result.usr = userContext + const identity: Context = {} + if (!isEmptyObject(userContext)) { + identity.usr = userContext } // The schema requires an id on account, so an account without one is left out rather than // making every event invalid. - if (!isEmpty(accountContext) && accountContext.id !== undefined) { - result.account = accountContext + if (!isEmptyObject(accountContext) && accountContext.id !== undefined) { + identity.account = accountContext } - return result + return shallowMerge(properties, identity) } const api = { @@ -303,30 +327,3 @@ function validate(configuration: LegacyInitConfiguration | undefined): boolean { } return true } - -function mergeContext(base: Context, extra?: Context): Context { - if (!extra || isEmpty(extra)) { - return base - } - const result: Context = {} - for (const key in base) { - if (Object.prototype.hasOwnProperty.call(base, key)) { - result[key] = base[key] - } - } - for (const key in extra) { - if (Object.prototype.hasOwnProperty.call(extra, key)) { - result[key] = extra[key] - } - } - return result -} - -function isEmpty(value: Context): boolean { - for (const key in value) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - return false - } - } - return true -} diff --git a/packages/rum-legacy/src/domain/viewManager.spec.ts b/packages/rum-legacy/src/domain/viewManager.spec.ts index 5e4a057303..3653791cc5 100644 --- a/packages/rum-legacy/src/domain/viewManager.spec.ts +++ b/packages/rum-legacy/src/domain/viewManager.spec.ts @@ -55,7 +55,7 @@ describe('view manager', () => { manager.addErrorCount() manager.addErrorCount() manager.addActionCount() - manager.flush() + manager.endView() const last = updates[updates.length - 1].view expect(last.error.count).toBe(2) @@ -65,8 +65,8 @@ describe('view manager', () => { it('increments the document version on every update so the intake can order them', () => { const manager = start() - manager.flush() - manager.flush() + manager.endView() + manager.endView() const versions = updates.map((update) => update._dd.document_version as number) expect(versions).toEqual([1, 2, 3]) @@ -85,7 +85,7 @@ describe('view manager', () => { jasmine.clock().install() const manager = start() jasmine.clock().mockDate(new Date(Date.now() + 2000)) - manager.flush() + manager.endView() jasmine.clock().uninstall() // The event format uses nanoseconds, so two seconds is 2e9 and not 2000. diff --git a/packages/rum-legacy/src/domain/viewManager.ts b/packages/rum-legacy/src/domain/viewManager.ts index 6e40a6ed6e..9d04ad0963 100644 --- a/packages/rum-legacy/src/domain/viewManager.ts +++ b/packages/rum-legacy/src/domain/viewManager.ts @@ -129,9 +129,16 @@ export function startViewManager( currentView.actionCount++ }, - flush(): void { + /** + * Sends the closing update for the current view without shutting collection down. + * + * Used on page exit, where tearing down would be wrong: beforeunload can fire for a navigation + * the user then cancels, and the page would be left with a dead SDK. A later exit sends another + * update with a higher document version, which the intake treats as the newer state. + */ + endView(): void { if (!stopped) { - emit(true) + endCurrentView() } }, diff --git a/packages/rum-legacy/src/transport/batch.spec.ts b/packages/rum-legacy/src/transport/batch.spec.ts index 2cde6273c3..e62308e319 100644 --- a/packages/rum-legacy/src/transport/batch.spec.ts +++ b/packages/rum-legacy/src/transport/batch.spec.ts @@ -121,62 +121,47 @@ describe('batch', () => { batch.stop() }) - /** - * The page exit events are captured rather than dispatched. The test runner installs its own - * beforeunload/unload handlers to detect navigation, so firing real ones on window makes it - * believe the page reloaded and abandons the run. - */ - function captureExitHandlers() { - const handlers: { [eventName: string]: Array<() => void> } = {} - spyOn(window, 'addEventListener').and.callFake((eventName: string, handler: any) => { - handlers[eventName] = handlers[eventName] || [] - handlers[eventName].push(handler) - }) - return handlers - } - - it('uses the exit transport when the page is unloading', () => { - const handlers = captureExitHandlers() + it('uses the exit transport when asked to flush on exit', () => { const batch = startBatch(request) batch.add({ type: 'view' }) - handlers.beforeunload[0]() + batch.flushOnExit() expect(request.exitPayloads).toEqual(['{"type":"view"}']) expect(request.sentPayloads).toEqual([]) batch.stop() }) - it('listens to both page exit events available before IE10', () => { - const handlers = captureExitHandlers() + it('does not register its own page exit listener', () => { + // Page exit is owned by the caller, which has to close the current view before the buffer is + // sent. A listener here would run first and flush an empty buffer. + const addEventListenerSpy = spyOn(window, 'addEventListener').and.callThrough() const batch = startBatch(request) - expect(handlers.beforeunload).toBeDefined() - expect(handlers.unload).toBeDefined() + const registered = addEventListenerSpy.calls.allArgs().map(([eventName]) => eventName) + expect(registered).not.toContain('beforeunload') + expect(registered).not.toContain('unload') batch.stop() }) - it('does not send the same events twice when both exit events fire', () => { - const handlers = captureExitHandlers() + it('sends nothing on exit when the buffer is empty', () => { const batch = startBatch(request) - batch.add({ type: 'view' }) - handlers.beforeunload[0]() - handlers.unload[0]() + batch.flushOnExit() - expect(request.exitPayloads).toEqual(['{"type":"view"}']) + expect(request.exitPayloads).toEqual([]) batch.stop() }) - it('stops listening and stops flushing once stopped', () => { - const handlers = captureExitHandlers() + it('stops buffering and stops flushing once stopped', () => { const batch = startBatch(request) batch.stop() batch.add({ type: 'view' }) jasmine.clock().tick(FLUSH_TIMEOUT * 2) - handlers.beforeunload[0]() + batch.flush() + batch.flushOnExit() expect(request.sentPayloads).toEqual([]) expect(request.exitPayloads).toEqual([]) diff --git a/packages/rum-legacy/src/transport/batch.ts b/packages/rum-legacy/src/transport/batch.ts index f8dbfe6bc1..17f735ec0c 100644 --- a/packages/rum-legacy/src/transport/batch.ts +++ b/packages/rum-legacy/src/transport/batch.ts @@ -12,6 +12,8 @@ export const FLUSH_TIMEOUT = 30 * 1000 export interface Batch { add: (event: object) => void flush: () => void + /** Sends synchronously, for use while the page is unloading. */ + flushOnExit: () => void stop: () => void } @@ -55,19 +57,9 @@ export function startBatch(request: HttpRequest): Batch { } } - function onPageExit(): void { - if (!stopped) { - flush(true) - } - } - - // No visibilitychange here: it does not exist before IE10, and the prefixed IE10 variant would - // only cover part of the range this build targets. beforeunload plus unload is what is available - // everywhere. Flushing empties the buffer, so the second event is a no-op rather than a resend. - const addEventListener = getZoneJsOriginalValue(window, 'addEventListener') - addEventListener.call(window, 'beforeunload', onPageExit) - addEventListener.call(window, 'unload', onPageExit) - + // Page exit is not handled here on purpose. The caller has to close the current view before the + // buffer is sent, and a listener registered in this function would run before one registered by + // the caller afterwards, flushing an empty buffer and losing the closing view event. return { add(event: object) { if (stopped) { @@ -105,12 +97,15 @@ export function startBatch(request: HttpRequest): Batch { } }, + flushOnExit() { + if (!stopped) { + flush(true) + } + }, + stop() { stopped = true cancelScheduledFlush() - const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener') - removeEventListener.call(window, 'beforeunload', onPageExit) - removeEventListener.call(window, 'unload', onPageExit) }, } } From e30b7af397ae4c9f036e0397fa23ce68f332c87a Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 21:10:05 -0700 Subject: [PATCH 08/32] fix(rum-legacy): apply sessionSampleRate and honour trackingConsent Both options were accepted, validated and then ignored. sessionSampleRate only reached _dd.configuration.session_sample_rate. Every session was collected in full while each event claimed to have been sampled at the configured rate, so the volume was wrong and the reported rate described something that never happened. The decision is now made once when a session starts and carried in the session cookie's rum field, using the same values as the standard bundles, so a session is either collected whole or not at all rather than losing a fraction of each one. trackingConsent was a no-op, which is worse for a consent control than not offering it: a page could set 'not-granted' and still be collected from. Collection now runs only while consent is exactly 'granted', matching the standard bundles, where an unrecognised value counts as not granted. Withdrawing consent drops whatever is buffered instead of sending it and clears the session cookie. Session cookie access is throttled to one second, as the standard bundles throttle it. The session is looked up for every event, and reading and writing document.cookie is a full string parse each time, which is a cost worth avoiding on the browsers this package targets. --- .../rum-legacy/src/boot/publicApi.spec.ts | 143 +++++++++++++++++- packages/rum-legacy/src/boot/publicApi.ts | 81 ++++++---- .../src/domain/sessionStore.spec.ts | 99 ++++++++++-- .../rum-legacy/src/domain/sessionStore.ts | 33 +++- 4 files changed, 311 insertions(+), 45 deletions(-) diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index ab32661535..d4048cadfb 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -1,6 +1,6 @@ import type { BuildEnvWindow } from '../../../core/test' +import { COOKIE_ACCESS_DELAY, deleteSessionCookie } from '../domain/sessionStore' import { FLUSH_TIMEOUT } from '../transport/batch' -import { deleteSessionCookie } from '../domain/sessionStore' import { makeRumLegacyPublicApi } from './publicApi' /** @@ -46,6 +46,7 @@ describe('public api', () => { // Unit builds keep this placeholder unreplaced, so each spec file has to provide it. Relying on // another spec file to set it makes the suite order dependent, and karma randomises the order. ;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test-version' + deleteSessionCookie() payloads = [] jasmine.clock().install() originalXhr = window.XMLHttpRequest @@ -111,6 +112,21 @@ describe('public api', () => { }) } + it('sends nothing for a session the sample rate excluded', () => { + api.init({ ...VALID_CONFIGURATION, sessionSampleRate: 0 }) + api.addError(new Error('boom')) + flush() + + expect(payloads).toEqual([]) + }) + + it('reports the sample rate it was actually configured with', () => { + api.init({ ...VALID_CONFIGURATION, sessionSampleRate: 100 }) + flush() + + expect(sentEvents()[0]._dd.configuration.session_sample_rate).toBe(100) + }) + it('refuses a sample rate outside 0 to 100', () => { api.init({ ...VALID_CONFIGURATION, sessionSampleRate: 500 }) flush() @@ -123,6 +139,82 @@ describe('public api', () => { }) }) + describe('tracking consent', () => { + it('collects nothing when consent is withheld at init', () => { + api.init({ ...VALID_CONFIGURATION, trackingConsent: 'not-granted' }) + api.addError(new Error('boom')) + flush() + + expect(payloads).toEqual([]) + }) + + it('collects when consent is granted at init', () => { + api.init({ ...VALID_CONFIGURATION, trackingConsent: 'granted' }) + flush() + + expect(payloads.length).toBeGreaterThan(0) + }) + + it('defaults to granted when the option is absent', () => { + api.init(VALID_CONFIGURATION) + flush() + + expect(payloads.length).toBeGreaterThan(0) + }) + + it('starts collecting once consent is granted afterwards', () => { + api.init({ ...VALID_CONFIGURATION, trackingConsent: 'not-granted' }) + + api.setTrackingConsent('granted') + api.addError(new Error('boom')) + flush() + + const types = sentEvents().map((event) => event.type as string) + expect(types).toContain('error') + }) + + it('stops collecting when consent is withdrawn', () => { + api.init(VALID_CONFIGURATION) + api.setTrackingConsent('not-granted') + payloads = [] + + api.addError(new Error('boom')) + flush() + + expect(payloads).toEqual([]) + }) + + it('does not send what was buffered before consent was withdrawn', () => { + api.init(VALID_CONFIGURATION) + api.addError(new Error('boom')) + + api.setTrackingConsent('not-granted') + flush() + + expect(payloads).toEqual([]) + }) + + it('clears the session when consent is withdrawn', () => { + api.init(VALID_CONFIGURATION) + expect(document.cookie).toContain('_dd_s=') + + api.setTrackingConsent('not-granted') + + expect(document.cookie).not.toContain('_dd_s=id') + }) + + it('treats an unrecognised value as consent not given, like the modern bundle does', () => { + api.init({ ...VALID_CONFIGURATION, trackingConsent: 'granted' }) + payloads = [] + + api.setTrackingConsent('yes-please' as any) + api.addError(new Error('boom')) + flush() + + expect(payloads).toEqual([]) + }) + }) + describe('reporting', () => { beforeEach(() => { api.init(VALID_CONFIGURATION) @@ -309,6 +401,27 @@ describe('public api', () => { }) describe('safety net', () => { + /** + * Replaces cookie access with a throwing one and puts the real descriptor back afterwards. A + * jasmine spy would still be installed while this spec's teardown runs, which needs to write + * the cookie. + */ + function withThrowingCookieAccess(operation: () => void) { + const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')! + Object.defineProperty(document, 'cookie', { + get: () => '', + set: () => { + throw new Error('cookie access denied') + }, + configurable: true, + }) + try { + operation() + } finally { + Object.defineProperty(document, 'cookie', descriptor) + } + } + const NO_OP_METHODS = [ 'setTrackingConsent', 'setViewContext', @@ -407,6 +520,34 @@ describe('public api', () => { }).not.toThrow() }) + it('does not let a failure escape through the page exit listener', () => { + const handlers: { [eventName: string]: Array<() => void> } = {} + spyOn(window, 'addEventListener').and.callFake((eventName: string, handler: any) => { + handlers[eventName] = handlers[eventName] || [] + handlers[eventName].push(handler) + }) + const freshApi = makeRumLegacyPublicApi() + freshApi.init(VALID_CONFIGURATION) + // Past the session cookie throttling window, so the exit really does touch the cookie. + jasmine.clock().mockDate(new Date(Date.now() + COOKIE_ACCESS_DELAY + 1)) + + // Some privacy modes throw on cookie access. The browser calls the exit listener directly, so + // without a guard that failure would surface as an uncaught error while the page unloads. + withThrowingCookieAccess(() => { + expect(() => handlers.beforeunload[0]()).not.toThrow() + }) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + + it('does not let a failure escape through a public method either', () => { + api.init(VALID_CONFIGURATION) + jasmine.clock().mockDate(new Date(Date.now() + COOKIE_ACCESS_DELAY + 1)) + + withThrowingCookieAccess(() => { + expect(() => api.addError(new Error('boom'))).not.toThrow() + }) + }) + it('does not let a circular context break reporting', () => { api.init(VALID_CONFIGURATION) const circular: any = {} diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index bb476b5af9..c2efb91214 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -1,9 +1,10 @@ import { assembleEvent } from '../domain/eventAssembly' import type { AssemblyConfiguration, ViewContext } from '../domain/eventAssembly' import { startErrorCollection } from '../domain/errorCollection' -import { createSessionStore } from '../domain/sessionStore' +import { createSessionStore, deleteSessionCookie } from '../domain/sessionStore' import { startViewManager } from '../domain/viewManager' import { displayError, displayWarn } from '../tools/display' +import { monitor } from '../tools/monitor' import { isEmptyObject, shallowMerge } from '../tools/objectUtils' import { getZoneJsOriginalValue } from '../tools/zoneJs' import { startBatch } from '../transport/batch' @@ -25,6 +26,8 @@ export interface LegacyInitConfiguration { version?: string env?: string sessionSampleRate?: number + /** 'granted' or 'not-granted'. Anything else counts as not granted. Defaults to 'granted'. */ + trackingConsent?: string // Options that only apply to the modern bundle are accepted and ignored, so a page can share one // configuration object between both builds. [key: string]: unknown @@ -32,26 +35,16 @@ export interface LegacyInitConfiguration { type Context = { [key: string]: any } -/* - * Every public method is wrapped: a failure inside the SDK must never surface as an exception in - * the host page. This is the last line of the safety net, after the individual try/catch blocks in - * the transport and collection layers. - */ -function monitor(fn: (...args: Args) => Result): (...args: Args) => Result | undefined { - return function (...args: Args): Result | undefined { - try { - return fn(...args) - } catch (error) { - displayError('internal error', error) - return undefined - } - } -} +const TRACKING_CONSENT_GRANTED = 'granted' +const TRACKING_CONSENT_NOT_GRANTED = 'not-granted' export function makeRumLegacyPublicApi() { let running: ReturnType | undefined let initConfiguration: LegacyInitConfiguration | undefined + // Collection only runs while this is exactly 'granted', matching the modern bundle. An + // unrecognised value therefore withholds collection rather than silently enabling it. + let trackingConsent: string = TRACKING_CONSENT_GRANTED let globalContext: Context = {} let userContext: Context = {} let accountContext: Context = {} @@ -64,7 +57,7 @@ export function makeRumLegacyPublicApi() { version: configuration.version, } - const sessionStore = createSessionStore() + const sessionStore = createSessionStore(assemblyConfiguration.sessionSampleRate) const buildUrl = createIntakeUrlBuilder({ clientToken: configuration.clientToken, proxy: configuration.proxy, @@ -78,6 +71,10 @@ export function makeRumLegacyPublicApi() { // emitted while startViewManager is still running, before the binding below exists. function sendEvent(type: string, properties: Context, view: ViewContext, context?: Context): void { const session = sessionStore.getOrCreateSession() + if (!session.isTracked) { + // Sampled out. The decision belongs to the session, so this holds for every event in it. + return + } const event = assembleEvent({ type, configuration: assemblyConfiguration, @@ -120,19 +117,24 @@ export function makeRumLegacyPublicApi() { batch.flushOnExit() } + // Wrapped: the browser calls this one, so an internal failure here would become an uncaught + // error in the page rather than being contained. + const guardedOnPageExit = monitor(onPageExit) const addEventListener = getZoneJsOriginalValue(window, 'addEventListener') - addEventListener.call(window, 'beforeunload', onPageExit) - addEventListener.call(window, 'unload', onPageExit) + addEventListener.call(window, 'beforeunload', guardedOnPageExit) + addEventListener.call(window, 'unload', guardedOnPageExit) return { - stop() { + stop(flushPending: boolean) { viewManager.stop() errorCollection.stop() - batch.flush() + if (flushPending) { + batch.flush() + } batch.stop() const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener') - removeEventListener.call(window, 'beforeunload', onPageExit) - removeEventListener.call(window, 'unload', onPageExit) + removeEventListener.call(window, 'beforeunload', guardedOnPageExit) + removeEventListener.call(window, 'unload', guardedOnPageExit) }, addError(value: unknown, context?: Context) { errorCollection.addError(value, context) @@ -187,7 +189,10 @@ export function makeRumLegacyPublicApi() { return } initConfiguration = configuration - running = start(configuration) + trackingConsent = configuration.trackingConsent ?? TRACKING_CONSENT_GRANTED + if (trackingConsent === TRACKING_CONSENT_GRANTED) { + running = start(configuration) + } }), getInitConfiguration: monitor(() => initConfiguration), @@ -256,7 +261,7 @@ export function makeRumLegacyPublicApi() { }), stopSession: monitor(() => { - running?.stop() + running?.stop(true) running = undefined }), @@ -270,7 +275,29 @@ export function makeRumLegacyPublicApi() { * "undefined is not a function" and takes the host page down, which is the exact failure this * build exists to prevent. */ - setTrackingConsent: monitor(() => undefined), + setTrackingConsent: monitor((consent: string) => { + if (consent !== TRACKING_CONSENT_GRANTED && consent !== TRACKING_CONSENT_NOT_GRANTED) { + // Warned about rather than ignored: a typo would otherwise silently stop all collection. + displayWarn(`Unknown tracking consent "${String(consent)}", treating it as not granted.`) + } + if (consent === trackingConsent) { + return + } + trackingConsent = consent + + if (consent === TRACKING_CONSENT_GRANTED) { + if (initConfiguration && !running) { + running = start(initConfiguration) + } + return + } + + // Consent withdrawn: drop what is buffered rather than sending it, and forget the session so + // a later consent starts a new one. + running?.stop(false) + running = undefined + deleteSessionCookie() + }), setViewContext: monitor(() => undefined), setViewContextProperty: monitor(() => undefined), getViewContext: monitor(() => ({})), @@ -288,7 +315,7 @@ export function makeRumLegacyPublicApi() { // the same way the modern bundle hides its debug switch. Object.defineProperty(api, '_stop', { value: () => { - running?.stop() + running?.stop(true) running = undefined }, enumerable: false, diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index d2f84c72ac..f76f6133dc 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -1,6 +1,6 @@ import { isValidSessionString } from '../../../core/src/domain/session/sessionStateValidation' import { toSessionState } from '../../../core/src/domain/session/sessionState' -import { SESSION_COOKIE_NAME, createSessionStore, deleteSessionCookie } from './sessionStore' +import { COOKIE_ACCESS_DELAY, SESSION_COOKIE_NAME, createSessionStore, deleteSessionCookie } from './sessionStore' /** * The cookie written here is the same one the modern bundle reads, so its format is not ours to @@ -15,24 +15,30 @@ describe('session store', () => { return match ? decodeURIComponent(match[1]) : undefined } + // Cleared before rather than only after: a spec elsewhere may have left a session cookie behind, + // and a stale one would be reused instead of a fresh session being created. + beforeEach(() => { + deleteSessionCookie() + }) + afterEach(() => { deleteSessionCookie() }) it('creates a session with a lowercase uuid', () => { - const session = createSessionStore().getOrCreateSession() + const session = createSessionStore(100).getOrCreateSession() expect(session.id).toMatch(/^[0-9a-f-]{36}$/) }) it('writes a cookie the modern bundle considers valid', () => { - createSessionStore().getOrCreateSession() + createSessionStore(100).getOrCreateSession() expect(isValidSessionString(readRawCookie())).toBe(true) }) it('writes the fields the modern bundle expects to find', () => { - const session = createSessionStore().getOrCreateSession() + const session = createSessionStore(100).getOrCreateSession() const state = toSessionState(readRawCookie()) expect(state.id).toBe(session.id) @@ -43,19 +49,19 @@ describe('session store', () => { }) it('reuses the session across calls', () => { - const store = createSessionStore() + const store = createSessionStore(100) expect(store.getOrCreateSession().id).toBe(store.getOrCreateSession().id) }) it('reuses a session written by a previous page load', () => { - const first = createSessionStore().getOrCreateSession() + const first = createSessionStore(100).getOrCreateSession() - expect(createSessionStore().getOrCreateSession().id).toBe(first.id) + expect(createSessionStore(100).getOrCreateSession().id).toBe(first.id) }) it('pushes the expiration forward on activity', () => { - const store = createSessionStore() + const store = createSessionStore(100) store.getOrCreateSession() const firstExpire = Number(toSessionState(readRawCookie()).expire) @@ -69,34 +75,101 @@ describe('session store', () => { }) it('starts a new session once the inactivity window has passed', () => { - const first = createSessionStore().getOrCreateSession() + const first = createSessionStore(100).getOrCreateSession() jasmine.clock().install() jasmine.clock().mockDate(new Date(Date.now() + 16 * ONE_MINUTE)) - const second = createSessionStore().getOrCreateSession() + const second = createSessionStore(100).getOrCreateSession() jasmine.clock().uninstall() expect(second.id).not.toBe(first.id) }) it('starts a new session once the maximum duration has passed', () => { - const first = createSessionStore().getOrCreateSession() + const first = createSessionStore(100).getOrCreateSession() jasmine.clock().install() // Still inside the inactivity window, but past the 4 hour cap. jasmine.clock().mockDate(new Date(Date.now() + 4 * 60 * ONE_MINUTE + ONE_MINUTE)) - const store = createSessionStore() + const store = createSessionStore(100) const second = store.getOrCreateSession() jasmine.clock().uninstall() expect(second.id).not.toBe(first.id) }) + describe('sampling', () => { + it('tracks the session when the sample rate is 100', () => { + expect(createSessionStore(100).getOrCreateSession().isTracked).toBe(true) + }) + + it('does not track the session when the sample rate is 0', () => { + expect(createSessionStore(0).getOrCreateSession().isTracked).toBe(false) + }) + + it('records the decision in the cookie so the whole session is consistent', () => { + createSessionStore(0).getOrCreateSession() + + // '0' is what the modern bundle writes for a session it decided not to track. + expect(toSessionState(readRawCookie()).rum).toBe('0') + }) + + it('keeps the decision across page loads rather than re-rolling it', () => { + createSessionStore(0).getOrCreateSession() + + // A second store with a rate that would always sample must still honour the stored decision. + expect(createSessionStore(100).getOrCreateSession().isTracked).toBe(false) + }) + + it('applies the configured rate', () => { + spyOn(Math, 'random').and.returnValue(0.5) + + expect(createSessionStore(60).getOrCreateSession().isTracked).toBe(true) + deleteSessionCookie() + expect(createSessionStore(40).getOrCreateSession().isTracked).toBe(false) + }) + }) + + describe('cookie access', () => { + it('does not touch the cookie again within the throttling window', () => { + const store = createSessionStore(100) + store.getOrCreateSession() + const setSpy = spyOnProperty(document, 'cookie', 'set') + + store.getOrCreateSession() + store.getOrCreateSession() + + // Every event asks for the session. Writing the cookie each time is a measurable cost on the + // browsers this build targets, so reads and writes are throttled the way the modern bundle + // throttles them. + expect(setSpy).not.toHaveBeenCalled() + }) + + it('refreshes the cookie once the window has passed', () => { + const store = createSessionStore(100) + store.getOrCreateSession() + + jasmine.clock().install() + jasmine.clock().mockDate(new Date(Date.now() + COOKIE_ACCESS_DELAY + 1)) + const setSpy = spyOnProperty(document, 'cookie', 'set') + store.getOrCreateSession() + jasmine.clock().uninstall() + + expect(setSpy).toHaveBeenCalled() + }) + + it('still returns the same session while throttled', () => { + const store = createSessionStore(100) + + expect(store.getOrCreateSession().id).toBe(store.getOrCreateSession().id) + }) + }) + it('keeps working when the cookie cannot be persisted', () => { const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')! Object.defineProperty(document, 'cookie', { get: () => '', set: () => undefined, configurable: true }) - const session = createSessionStore().getOrCreateSession() + const session = createSessionStore(100).getOrCreateSession() Object.defineProperty(document, 'cookie', descriptor) diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index a93c07da60..7201c70f71 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -16,11 +16,22 @@ const SESSION_EXPIRATION_DELAY = 15 * ONE_MINUTE /** Hard cap on a session's lifetime, however active it is. */ const SESSION_TIME_OUT_DELAY = 4 * ONE_HOUR -/** '2' is "tracked, without session replay", the only state this build can be in. */ +/** Tracking decision, stored in the cookie using the same values as the modern bundle. */ +const NOT_TRACKED = '0' const TRACKED_WITHOUT_SESSION_REPLAY = '2' +/** + * How long a session may be reused without touching the cookie again. + * + * The session is looked up for every event, and reading and writing document.cookie is a full + * string parse each time. On the browsers this build targets that cost is worth avoiding, and the + * modern bundle throttles the same operation over the same window. + */ +export const COOKIE_ACCESS_DELAY = 1000 + export interface LegacySession { id: string + isTracked: boolean } interface SessionState { @@ -30,33 +41,47 @@ interface SessionState { rum?: string } -export function createSessionStore() { +export function createSessionStore(sessionSampleRate: number) { // Kept in memory as well as in the cookie so that a page which cannot persist cookies still // reports a stable session for the lifetime of the document. let inMemoryState: SessionState | undefined + let lastCookieAccess: number | undefined return { getOrCreateSession(): LegacySession { const now = dateNow() + + if (inMemoryState && lastCookieAccess !== undefined && now - lastCookieAccess < COOKIE_ACCESS_DELAY) { + return toSession(inMemoryState) + } + let state = readSessionCookie() || inMemoryState if (!state || !state.id || isExpired(state, now)) { state = { id: generateUUID(), created: String(now), - rum: TRACKED_WITHOUT_SESSION_REPLAY, + // Decided once, when the session starts, and carried in the cookie from then on. Rolling + // it per event would send a fraction of the events of every session instead of all the + // events of a fraction of the sessions. + rum: Math.random() * 100 < sessionSampleRate ? TRACKED_WITHOUT_SESSION_REPLAY : NOT_TRACKED, } } state.expire = String(now + SESSION_EXPIRATION_DELAY) inMemoryState = state + lastCookieAccess = now writeSessionCookie(state) - return { id: state.id! } + return toSession(state) }, } } +function toSession(state: SessionState): LegacySession { + return { id: state.id!, isTracked: state.rum === TRACKED_WITHOUT_SESSION_REPLAY } +} + function isExpired(state: SessionState, now: number): boolean { const createdAt = Number(state.created) const expiresAt = Number(state.expire) From 11ffdfe1df94b06742aca7c47ab14139b53817ab Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 21:10:18 -0700 Subject: [PATCH 09/32] fix(rum-legacy): guard browser callbacks and fix in-page referrer Public methods were wrapped so an internal failure could not surface in the host page, but the handlers the browser calls back into were not. A failure inside the hashchange, load or page exit listener became an uncaught error on the page, which is the outcome this package exists to avoid. The wrapper moves to tools/monitor.ts and now covers both entry points. Removing the wrapper failed no test before this change, so the guard was untested rather than merely missing; the specs added here fail without it. Making them fail for the right reason also required advancing past the new session cookie throttling window, since a page that exits within a second of init never touches the cookie and never reaches the injected failure. Views started in-page reported document.referrer, which describes how the document was reached rather than how the view was, attributing every in-page navigation to whatever site linked to the page. They now report the previous view's url, as the standard bundles do, and only the first view of a document falls back to document.referrer. The loader snippet stubs init so that calling it outside onReady, before the script has landed, queues the call instead of throwing "undefined is not a function". The README also records where this build's stopSession and setViewName deliberately differ from the standard bundles. Session cookies are cleared before each spec as well as after: a spec elsewhere may leave one behind, and a stale session would be reused instead of a fresh one being created. --- packages/rum-legacy/README.md | 44 ++++++++++++++----- .../rum-legacy/src/domain/viewManager.spec.ts | 42 ++++++++++++++++++ packages/rum-legacy/src/domain/viewManager.ts | 22 +++++++--- packages/rum-legacy/src/tools/monitor.spec.ts | 25 +++++++++++ packages/rum-legacy/src/tools/monitor.ts | 21 +++++++++ 5 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 packages/rum-legacy/src/tools/monitor.spec.ts create mode 100644 packages/rum-legacy/src/tools/monitor.ts diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index 833dff0a3c..763434e441 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -44,6 +44,13 @@ compatibility document mode is classified by what it can actually do. onReady: function (c) { this.q.push(c) }, + // Stub so that calling init before the script has landed queues the call instead of throwing + // "undefined is not a function" and taking the page down with it. + init: function (o) { + this.q.push(function () { + w.FC_RUM.init(o) + }) + }, } var s = d.createElement('script') s.async = true @@ -63,7 +70,9 @@ compatibility document mode is classified by what it can actually do. ``` Calls made before the bundle arrives are queued on `q` and run once it loads. This is the same -mechanism the standard bundles already use. +mechanism the standard bundles already use. `init` is stubbed on the placeholder for the same +reason: a page that calls it outside `onReady`, before the script has landed, would otherwise hit +`undefined is not a function` — the failure this build exists to prevent. ### `proxy` is required @@ -93,19 +102,34 @@ required. ## Configuration -| Option | Required | Notes | -| ------------------- | :------: | ---------------------------------------- | -| `applicationId` | ✅ | | -| `clientToken` | ✅ | | -| `proxy` | ✅ | Same-origin path forwarded to the intake | -| `service` | | | -| `version` | | | -| `env` | | | -| `sessionSampleRate` | | 0 to 100, defaults to 100 | +| Option | Required | Notes | +| ------------------- | :------: | ----------------------------------------------------------------------------- | +| `applicationId` | ✅ | | +| `clientToken` | ✅ | | +| `proxy` | ✅ | Same-origin path forwarded to the intake | +| `service` | | | +| `version` | | | +| `env` | | | +| `sessionSampleRate` | | 0 to 100, defaults to 100. Decided once per session and carried in the cookie | +| `trackingConsent` | | `granted` (default) or `not-granted`; any other value counts as not granted | Options that only apply to the standard bundles are accepted and ignored, so one configuration object can be shared between the two. +## Differences from the standard bundles + +Beyond the capability table above, two behaviours differ and are worth knowing before porting a +page: + +- `stopSession()` shuts collection down for the rest of the page. In the standard bundles it ends + the current session and a new one starts on the next interaction. Use `setTrackingConsent` if you + want collection to be resumable. +- `setViewName()` starts a new view rather than renaming the current one. A view event has already + been sent under the old name and there is no way to retract it. + +Consent is honoured: with `trackingConsent: 'not-granted'` nothing is collected or sent, and +withdrawing consent later drops whatever is buffered and clears the session cookie. + ## Development ```bash diff --git a/packages/rum-legacy/src/domain/viewManager.spec.ts b/packages/rum-legacy/src/domain/viewManager.spec.ts index 3653791cc5..e681621a98 100644 --- a/packages/rum-legacy/src/domain/viewManager.spec.ts +++ b/packages/rum-legacy/src/domain/viewManager.spec.ts @@ -104,6 +104,23 @@ describe('view manager', () => { expect(closing.length).toBe(1) }) + it('reports the previous view as the referrer of a view started in-page', () => { + const manager = start() + const firstViewUrl = manager.getCurrentView().url + + manager.startView('checkout') + + // document.referrer describes how the document was reached, not how this view was, so using it + // here would attribute every in-page navigation to whatever site linked to the page. + expect(manager.getCurrentView().referrer).toBe(firstViewUrl) + }) + + it('keeps the document referrer for the first view', () => { + const manager = start() + + expect(manager.getCurrentView().referrer).toBe(document.referrer) + }) + it('reports a view started by navigation as a route change, not an initial load', () => { const manager = start() @@ -129,6 +146,31 @@ describe('view manager', () => { expect(updates[updates.length - 1].view.name).toBe('checkout') }) + describe('safety net', () => { + it('does not let a failure escape into the page through a browser callback', () => { + // The browser invokes the hashchange listener directly, so anything thrown inside it would + // become an uncaught error on the page rather than staying inside the SDK. + const handlers: { [eventName: string]: Array<() => void> } = {} + spyOn(window, 'addEventListener').and.callFake((eventName: string, handler: any) => { + handlers[eventName] = handlers[eventName] || [] + handlers[eventName].push(handler) + }) + // Failing is switched on only around the assertion: the first update is emitted while the + // manager is being constructed, which init already guards, and teardown emits one more. + let failing = false + const manager = startViewManager(() => { + if (failing) { + throw new Error('collection is broken') + } + }) + stopManager = () => manager.stop() + + failing = true + expect(() => handlers.hashchange[0]()).not.toThrow() + failing = false + }) + }) + describe('navigation timings', () => { it('derives page load timings from performance.timing, in nanoseconds', () => { const navigationStart = 1_000_000 diff --git a/packages/rum-legacy/src/domain/viewManager.ts b/packages/rum-legacy/src/domain/viewManager.ts index 9d04ad0963..d762bdd2ba 100644 --- a/packages/rum-legacy/src/domain/viewManager.ts +++ b/packages/rum-legacy/src/domain/viewManager.ts @@ -1,3 +1,4 @@ +import { monitor } from '../tools/monitor' import { dateNow } from '../tools/timeUtils' import { getZoneJsOriginalValue } from '../tools/zoneJs' import { generateUUID } from '../transport/intakeUrl' @@ -31,11 +32,13 @@ export function startViewManager( let currentView = createView(INITIAL_LOAD) let stopped = false - function createView(loadingType: string, name?: string): CurrentView { + function createView(loadingType: string, name?: string, previousViewUrl?: string): CurrentView { return { id: generateUUID(), url: location.href, - referrer: document.referrer, + // Where this view was reached from. For a view started in-page that is the previous view's + // url; only the first view of the document comes from outside it. + referrer: previousViewUrl ?? document.referrer, name, loadingType, startTime: dateNow(), @@ -83,8 +86,9 @@ export function startViewManager( } function startNewView(loadingType: string, name?: string): void { + const previousViewUrl = currentView.url endCurrentView() - currentView = createView(loadingType, name) + currentView = createView(loadingType, name, previousViewUrl) emit(true) } @@ -102,10 +106,14 @@ export function startViewManager( } } + // Wrapped: the browser calls these, so an internal failure would leave the SDK and surface as an + // uncaught error in the page. + const guardedOnHashChange = monitor(onHashChange) + const guardedOnLoad = monitor(onLoad) const addEventListener = getZoneJsOriginalValue(window, 'addEventListener') - addEventListener.call(window, 'hashchange', onHashChange) + addEventListener.call(window, 'hashchange', guardedOnHashChange) if (!isDocumentLoaded()) { - addEventListener.call(window, 'load', onLoad) + addEventListener.call(window, 'load', guardedOnLoad) } emit(true) @@ -149,8 +157,8 @@ export function startViewManager( endCurrentView() stopped = true const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener') - removeEventListener.call(window, 'hashchange', onHashChange) - removeEventListener.call(window, 'load', onLoad) + removeEventListener.call(window, 'hashchange', guardedOnHashChange) + removeEventListener.call(window, 'load', guardedOnLoad) }, } } diff --git a/packages/rum-legacy/src/tools/monitor.spec.ts b/packages/rum-legacy/src/tools/monitor.spec.ts new file mode 100644 index 0000000000..1d706e5d26 --- /dev/null +++ b/packages/rum-legacy/src/tools/monitor.spec.ts @@ -0,0 +1,25 @@ +import { monitor } from './monitor' + +describe('monitor', () => { + it('passes arguments and the return value through when nothing fails', () => { + const wrapped = monitor((a: number, b: number) => a + b) + + expect(wrapped(2, 3)).toBe(5) + }) + + it('swallows a failure instead of letting it reach the caller', () => { + const wrapped = monitor(() => { + throw new Error('internal failure') + }) + + expect(() => wrapped()).not.toThrow() + }) + + it('returns undefined when the wrapped function failed', () => { + const wrapped = monitor(() => { + throw new Error('internal failure') + }) + + expect(wrapped()).toBeUndefined() + }) +}) diff --git a/packages/rum-legacy/src/tools/monitor.ts b/packages/rum-legacy/src/tools/monitor.ts new file mode 100644 index 0000000000..cfbf5dbf3f --- /dev/null +++ b/packages/rum-legacy/src/tools/monitor.ts @@ -0,0 +1,21 @@ +import { displayError } from './display' + +/** + * Wraps a function so a failure inside the SDK can never surface in the host page. + * + * This covers two entry points. Public API methods are one, and anything the browser calls back + * into is the other: a listener that throws turns an internal failure into an uncaught page error, + * which is exactly what this build exists to avoid. + */ +export function monitor( + fn: (...args: Args) => Result +): (...args: Args) => Result | undefined { + return function (...args: Args): Result | undefined { + try { + return fn(...args) + } catch (error) { + displayError('internal error', error) + return undefined + } + } +} From 4fd84145e6e728225e8622746d32f7fd0f2dc460 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 17 Aug 2026 08:36:03 -0700 Subject: [PATCH 10/32] fix(rum-legacy): correct wall-clock durations, exit ordering and session reuse Five more review passes, over clock behaviour, ordering, hostile input, release plumbing and drift between the docs and the code. Durations come from the wall clock, because these browsers have no monotonic performance.now(). A backwards clock correction made time_spent negative, which is not a measurement but a broken one, and made the session throttle read the negative elapsed time as "still inside the window", freezing the session until the clock caught up. Elapsed time is now floored at zero and the throttle treats a backwards jump as an elapsed window. The page exit produced the closing view update and then flushed. If that update crossed a buffer limit it started an asynchronous request, which a closing page never completes. The update is now produced inside the exit flush, so the whole sequence stays on the synchronous transport. A session started by the standard bundles with session replay sampled is stored as '1' rather than '2'. Reading only '2' as tracked meant such a session was treated as sampled out and silenced for its whole lifetime. Both builds share one cookie jar per domain, and IE enterprise site lists routinely put some urls of a site in compatibility mode and others not, so this is reachable rather than theoretical. Hostile input was probed rather than assumed: a crafted session cookie, a polluted Object prototype and a malformed cookie value are all contained already, and now have specs saying so. The bundle whose whole purpose is being small was missing from the size report. It is 4 KiB gzipped. The README claimed the degraded environment specs remove Map, Set and Symbol. They deliberately do not, and overstating the coverage is worse than describing it narrowly. --- packages/rum-legacy/README.md | 14 +++- .../rum-legacy/src/boot/publicApi.spec.ts | 30 ++++++++- packages/rum-legacy/src/boot/publicApi.ts | 5 +- .../src/domain/sessionStore.spec.ts | 66 +++++++++++++++++++ .../rum-legacy/src/domain/sessionStore.ts | 21 +++++- .../rum-legacy/src/domain/viewManager.spec.ts | 12 ++++ packages/rum-legacy/src/domain/viewManager.ts | 11 +++- packages/rum-legacy/src/transport/batch.ts | 28 ++++++-- scripts/lib/computeBundleSize.js | 2 +- 9 files changed, 175 insertions(+), 14 deletions(-) diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index 763434e441..e92009da7d 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -149,8 +149,18 @@ rejected, so a broken check cannot pass silently. ## Testing, and what it does not cover The specs run in a modern headless browser. `src/boot/degradedEnvironment.spec.ts` removes `fetch`, -`Promise`, `Map`, `Set`, `Symbol`, `URL`, `TextEncoder` and `sendBeacon`, and drives the package end -to end through an `XMLHttpRequest` that offers only `onreadystatechange`, as IE9 does. +`Promise`, `MutationObserver`, `PerformanceObserver`, `TextEncoder`, `URL` and `sendBeacon`, and +drives the package end to end through an `XMLHttpRequest` that offers only `onreadystatechange`, as +IE9 does. + +The ES2015 collections are deliberately left in place there. `lib: ES5` already makes using them a +compile error, which is stronger than a runtime spec, and the bundle scan covers the emitted output. +Removing them at runtime would only break the test harness, which builds a `Map` of its own around +every listener. + +Guarantees that could be asserted vacuously are checked by removing the implementation and +confirming a spec fails: the ES5 gate, the event schema validation, the page exit ordering, the +sampling and consent gates, and the listener guards. That covers missing runtime APIs and unsupported syntax. It does not cover the behaviour of an actual old browser engine. **This package has not been verified on real hardware**, and that diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index d4048cadfb..ddeea36536 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -1,6 +1,6 @@ import type { BuildEnvWindow } from '../../../core/test' import { COOKIE_ACCESS_DELAY, deleteSessionCookie } from '../domain/sessionStore' -import { FLUSH_TIMEOUT } from '../transport/batch' +import { BATCH_BYTES_LIMIT, FLUSH_TIMEOUT } from '../transport/batch' import { makeRumLegacyPublicApi } from './publicApi' /** @@ -370,6 +370,34 @@ describe('public api', () => { ;(freshApi as unknown as { _stop: () => void })._stop() }) + it('sends everything through the exit transport, even a batch that fills up while closing', () => { + const handlers = captureExitHandlers() + const asyncPayloads: string[] = [] + const exitPayloads: string[] = [] + ;(window as any).XMLHttpRequest = function () { + let isAsync = true + return { + open: (_m: string, _u: string, a: boolean) => (isAsync = a), + setRequestHeader: () => undefined, + send: (body: string) => (isAsync ? asyncPayloads : exitPayloads).push(body), + } + } + const freshApi = makeRumLegacyPublicApi() + freshApi.init(VALID_CONFIGURATION) + // Every event now carries most of the byte budget, so the closing view update is the one that + // tips the buffer over the limit while the page is already unloading. + freshApi.setGlobalContext({ padding: new Array(Math.floor(BATCH_BYTES_LIMIT * 0.7)).join('a') }) + freshApi.addAction('checkout') + asyncPayloads.length = 0 + + handlers.beforeunload[0]() + + // An async request started while the page is unloading is not going to arrive. + expect(asyncPayloads).toEqual([]) + expect(exitPayloads.length).toBeGreaterThan(0) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + it('does not block the closing page with a second synchronous request', () => { const handlers = captureExitHandlers() const freshApi = makeRumLegacyPublicApi() diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index c2efb91214..591feed019 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -113,8 +113,9 @@ export function makeRumLegacyPublicApi() { return } exited = true - viewManager.endView() - batch.flushOnExit() + // Closing the view inside the exit flush keeps the whole sequence on the synchronous + // transport, including a buffer limit the closing update happens to cross. + batch.flushOnExit(() => viewManager.endView()) } // Wrapped: the browser calls this one, so an internal failure here would become an uncaught diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index f76f6133dc..073d60b91c 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -121,6 +121,25 @@ describe('session store', () => { expect(createSessionStore(100).getOrCreateSession().isTracked).toBe(false) }) + it('honours a session the modern bundle marked as tracked with replay', () => { + // Both builds share one cookie jar per domain, and IE enterprise site lists routinely put + // some urls of a site in compatibility mode and others not. Reading '1' as untracked would + // silence the whole session, for up to its four hour lifetime. + document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent( + `id=00000000-aaaa-0000-aaaa-000000000000&created=${Date.now()}&expire=${Date.now() + 60000}&rum=1` + )};path=/` + + expect(createSessionStore(100).getOrCreateSession().isTracked).toBe(true) + }) + + it('honours a session marked as not tracked', () => { + document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent( + `id=00000000-aaaa-0000-aaaa-000000000000&created=${Date.now()}&expire=${Date.now() + 60000}&rum=0` + )};path=/` + + expect(createSessionStore(100).getOrCreateSession().isTracked).toBe(false) + }) + it('applies the configured rate', () => { spyOn(Math, 'random').and.returnValue(0.5) @@ -158,6 +177,21 @@ describe('session store', () => { expect(setSpy).toHaveBeenCalled() }) + it('does not stay throttled forever when the clock jumps backwards', () => { + const store = createSessionStore(100) + store.getOrCreateSession() + + jasmine.clock().install() + jasmine.clock().mockDate(new Date(Date.now() - 60 * 60 * 1000)) + const setSpy = spyOnProperty(document, 'cookie', 'set') + store.getOrCreateSession() + jasmine.clock().uninstall() + + // A backwards jump makes the elapsed time negative, which would otherwise read as "still + // inside the window" and keep the session frozen until the clock caught up. + expect(setSpy).toHaveBeenCalled() + }) + it('still returns the same session while throttled', () => { const store = createSessionStore(100) @@ -165,6 +199,38 @@ describe('session store', () => { }) }) + describe('hostile input', () => { + it('ignores unknown fields injected into the session cookie', () => { + document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent( + `id=00000000-aaaa-0000-aaaa-000000000000&created=${Date.now()}&expire=${Date.now() + 60000}&rum=2&evil=payload` + )};path=/` + + const session = createSessionStore(100).getOrCreateSession() + + expect(session.id).toBe('00000000-aaaa-0000-aaaa-000000000000') + expect(readRawCookie()).not.toContain('evil') + }) + + it('is not confused by a polluted Object prototype', () => { + // A page that has extended Object.prototype must not end up with those keys in the session. + ;(Object.prototype as any).injected = 'value' + try { + const session = createSessionStore(100).getOrCreateSession() + + expect(session.id).toMatch(/^[0-9a-f-]{36}$/) + expect(readRawCookie()).not.toContain('injected') + } finally { + delete (Object.prototype as any).injected + } + }) + + it('starts a fresh session rather than trusting a malformed cookie', () => { + document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent('not a session at all')};path=/` + + expect(createSessionStore(100).getOrCreateSession().id).toMatch(/^[0-9a-f-]{36}$/) + }) + }) + it('keeps working when the cookie cannot be persisted', () => { const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')! Object.defineProperty(document, 'cookie', { get: () => '', set: () => undefined, configurable: true }) diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index 7201c70f71..9e50d7fb85 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -18,6 +18,7 @@ const SESSION_TIME_OUT_DELAY = 4 * ONE_HOUR /** Tracking decision, stored in the cookie using the same values as the modern bundle. */ const NOT_TRACKED = '0' +const TRACKED_WITH_SESSION_REPLAY = '1' const TRACKED_WITHOUT_SESSION_REPLAY = '2' /** @@ -51,7 +52,15 @@ export function createSessionStore(sessionSampleRate: number) { getOrCreateSession(): LegacySession { const now = dateNow() - if (inMemoryState && lastCookieAccess !== undefined && now - lastCookieAccess < COOKIE_ACCESS_DELAY) { + // A backwards clock correction makes the elapsed time negative, which would otherwise read as + // "still inside the window" and freeze the session until the clock caught up. + const sinceLastAccess = lastCookieAccess === undefined ? undefined : now - lastCookieAccess + if ( + inMemoryState && + sinceLastAccess !== undefined && + sinceLastAccess >= 0 && + sinceLastAccess < COOKIE_ACCESS_DELAY + ) { return toSession(inMemoryState) } @@ -79,7 +88,15 @@ export function createSessionStore(sessionSampleRate: number) { } function toSession(state: SessionState): LegacySession { - return { id: state.id!, isTracked: state.rum === TRACKED_WITHOUT_SESSION_REPLAY } + // Both tracked values count. This build never writes '1' itself, but both builds share one cookie + // jar per domain, and IE enterprise site lists routinely put some urls of a site in compatibility + // mode and others not. Reading a session the modern bundle started as untracked would silence + // this one for the rest of that session's lifetime. + const trackingType = state.rum + return { + id: state.id!, + isTracked: trackingType === TRACKED_WITHOUT_SESSION_REPLAY || trackingType === TRACKED_WITH_SESSION_REPLAY, + } } function isExpired(state: SessionState, now: number): boolean { diff --git a/packages/rum-legacy/src/domain/viewManager.spec.ts b/packages/rum-legacy/src/domain/viewManager.spec.ts index e681621a98..7a22411f6f 100644 --- a/packages/rum-legacy/src/domain/viewManager.spec.ts +++ b/packages/rum-legacy/src/domain/viewManager.spec.ts @@ -92,6 +92,18 @@ describe('view manager', () => { expect(updates[updates.length - 1].view.time_spent).toBe(2_000_000_000) }) + it('never reports a negative time spent when the clock jumps backwards', () => { + jasmine.clock().install() + const manager = start() + // These browsers have no performance.now(), so durations come from the wall clock, which an + // NTP correction can move backwards. + jasmine.clock().mockDate(new Date(Date.now() - 5000)) + manager.endView() + jasmine.clock().uninstall() + + expect(updates[updates.length - 1].view.time_spent).toBe(0) + }) + it('starts a new view on a hash change and closes the previous one', () => { const manager = start() const firstViewId = manager.getCurrentView().id diff --git a/packages/rum-legacy/src/domain/viewManager.ts b/packages/rum-legacy/src/domain/viewManager.ts index d762bdd2ba..28f0456a1c 100644 --- a/packages/rum-legacy/src/domain/viewManager.ts +++ b/packages/rum-legacy/src/domain/viewManager.ts @@ -53,7 +53,7 @@ export function startViewManager( const view: { [key: string]: any } = { loading_type: currentView.loadingType, - time_spent: toServerDuration(dateNow() - currentView.startTime), + time_spent: toServerDuration(elapsedSince(currentView.startTime)), is_active: isActive, // These counts are always zero on these browsers, but they are part of the event format. // Leaving them out would read downstream as missing data rather than as a real zero. @@ -197,3 +197,12 @@ function addNavigationTimings(view: { [key: string]: any }): void { function toServerDuration(durationInMilliseconds: number): number { return Math.round(durationInMilliseconds * 1e6) } + +/** + * Durations here come from the wall clock, because these browsers have no monotonic + * performance.now(). A clock correction can therefore move time backwards mid-view, and a negative + * duration is not a measurement, it is a broken one. Report no elapsed time instead. + */ +function elapsedSince(startTime: number): number { + return Math.max(0, dateNow() - startTime) +} diff --git a/packages/rum-legacy/src/transport/batch.ts b/packages/rum-legacy/src/transport/batch.ts index 17f735ec0c..3a4b6402fd 100644 --- a/packages/rum-legacy/src/transport/batch.ts +++ b/packages/rum-legacy/src/transport/batch.ts @@ -12,8 +12,14 @@ export const FLUSH_TIMEOUT = 30 * 1000 export interface Batch { add: (event: object) => void flush: () => void - /** Sends synchronously, for use while the page is unloading. */ - flushOnExit: () => void + /** + * Sends synchronously, for use while the page is unloading. + * + * `prepare` runs inside the exit: anything it adds is flushed by the same synchronous request, + * and a buffer limit it happens to cross does not start an async one that the closing page would + * never complete. + */ + flushOnExit: (prepare?: () => void) => void stop: () => void } @@ -21,6 +27,7 @@ export function startBatch(request: HttpRequest): Batch { let messages: string[] = [] let bytesCount = 0 let stopped = false + let exiting = false let flushTimeoutId: number | undefined function flush(useExitTransport?: boolean): void { @@ -34,7 +41,7 @@ export function startBatch(request: HttpRequest): Batch { messages = [] bytesCount = 0 - if (useExitTransport) { + if (useExitTransport || exiting) { request.sendOnExit(payload) } else { request.send(payload) @@ -97,9 +104,20 @@ export function startBatch(request: HttpRequest): Batch { } }, - flushOnExit() { - if (!stopped) { + flushOnExit(prepare?: () => void) { + if (stopped) { + return + } + exiting = true + try { + if (prepare) { + prepare() + } flush(true) + } finally { + // Reset, because beforeunload also fires for a navigation the user then cancels, and the + // page would otherwise keep sending synchronously for the rest of its life. + exiting = false } }, diff --git a/scripts/lib/computeBundleSize.js b/scripts/lib/computeBundleSize.js index 8a8229136e..1320e605ff 100644 --- a/scripts/lib/computeBundleSize.js +++ b/scripts/lib/computeBundleSize.js @@ -3,7 +3,7 @@ const fs = require('fs') const zlib = require('zlib') const { glob } = require('glob') -const packages = ['rum', 'logs', 'flagging', 'rum-slim', 'worker'] +const packages = ['rum', 'logs', 'flagging', 'rum-slim', 'rum-legacy', 'worker'] function getPackageName(file) { if (file.includes('chunk')) { From 721024c11d7fda512cd047be51d5ab0574d93920 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 17 Aug 2026 09:22:17 -0700 Subject: [PATCH 11/32] fix(rum-legacy): recognise errors from other frames, stop leaking state Four more review passes, over the emitted artifact, the module surface, the changes outside this package, and the public API semantics. The suite never executed the file customers actually load. Every spec runs against TypeScript compiled by the test runner, and between that and the shipped bundle sit Terser and the webpack runtime. A new check executes the emitted file in an environment with no fetch, no Promise, no sendBeacon and an XMLHttpRequest that only fires onreadystatechange, then asserts what lands on the wire, including the intake path and parameters carried inside ddforward. It found a real defect on its first run. Errors were recognised with a bare `instanceof Error`, which compares against the current frame's constructor, so an error created in another frame was treated as a plain value and stringified, losing its message, type and stack. Frameset and iframe heavy applications are the norm on these browsers. The standard bundles allow for this and now so does this one, verified with a real iframe rather than a simulation. The getters handed out the objects the SDK keeps rather than copies. The stored configuration is what a later consent grant starts from, and the contexts are attached to every event, so a caller could change SDK behaviour by mutating what it read. computeBytesCount and normalizeUrl were exported without a consumer, which reads as part of the module's contract when they are internal. The root build, the deploy path's package list and the workflow's ES5 step were run end to end rather than assumed. --- packages/rum-legacy/README.md | 13 +- packages/rum-legacy/package.json | 3 +- .../rum-legacy/src/boot/publicApi.spec.ts | 31 +++ packages/rum-legacy/src/boot/publicApi.ts | 15 +- .../src/domain/errorCollection.spec.ts | 17 ++ .../rum-legacy/src/domain/errorCollection.ts | 14 +- packages/rum-legacy/src/transport/batch.ts | 2 +- .../rum-legacy/src/transport/intakeUrl.ts | 2 +- scripts/check-legacy-bundle-runtime.js | 202 ++++++++++++++++++ 9 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 scripts/check-legacy-bundle-runtime.js diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index e92009da7d..a39e77d669 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -142,9 +142,16 @@ yarn typecheck # ES5 lib check on its own runtime crash, and `paths` is emptied so `@flashcatcloud/*` imports do not resolve — those packages are written against ES2018 and importing one would defeat the purpose of this build. -`scripts/check-es5-compatibility.js` runs as part of the bundle build. It parses the output as ES5, -scans it for runtime APIs the target browsers lack, and asserts that the standard bundles are -rejected, so a broken check cannot pass silently. +Two checks run as part of the bundle build. `scripts/check-es5-compatibility.js` parses the output +as ES5, scans it for runtime APIs the target browsers lack, and asserts that the standard bundles +are _rejected_, so a broken check cannot pass silently. + +`scripts/check-legacy-bundle-runtime.js` then executes the emitted file in a deliberately +impoverished environment — no `fetch`, no `Promise`, no `sendBeacon`, and an `XMLHttpRequest` that +only fires `onreadystatechange` — and asserts what lands on the wire: a synchronous POST, the intake +path and parameters inside `ddforward`, and a payload carrying a view and an error. Every unit spec +runs against TypeScript compiled by the test runner; between that and the shipped file sit Terser +and the webpack runtime, and this is what covers the gap. ## Testing, and what it does not cover diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 99a9eab3e3..ff3cae9393 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -6,8 +6,9 @@ "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", "scripts": { "build": "yarn build:bundle", - "build:bundle": "rm -rf bundle && yarn typecheck && SDK_SETUP=cdn webpack --mode=production && yarn check:es5", + "build:bundle": "rm -rf bundle && yarn typecheck && SDK_SETUP=cdn webpack --mode=production && yarn check:es5 && yarn check:runtime", "check:es5": "node ../../scripts/check-es5-compatibility.js", + "check:runtime": "node ../../scripts/check-legacy-bundle-runtime.js", "typecheck": "tsc --noEmit -p tsconfig.json" }, "devDependencies": { diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index ddeea36536..ffaee58b56 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -83,6 +83,19 @@ describe('public api', () => { expect(payloads).toEqual([]) }) + it('hands out a copy of the configuration, not the object it keeps', () => { + api.init({ ...VALID_CONFIGURATION, trackingConsent: 'not-granted' }) + + const returned = api.getInitConfiguration() as Record + returned.applicationId = 'tampered' + api.setTrackingConsent('granted') + flush() + + // The stored configuration is what a later consent grant starts from, so handing out the live + // object would let a caller change what the SDK reports as its application. + expect(sentEvents()[0].application.id).toBe(VALID_CONFIGURATION.applicationId) + }) + it('exposes the configuration it was initialised with', () => { api.init(VALID_CONFIGURATION) @@ -258,6 +271,24 @@ describe('public api', () => { expect(closedView.view.action.count).toBe(1) }) + it('hands out a copy of the global context, not the object it keeps', () => { + api.setGlobalContext({ tenant: 'acme' }) + ;(api.getGlobalContext() as Record).tenant = 'tampered' + api.addError(new Error('boom')) + flush() + + expect(eventsOfType('error')[0].context).toEqual({ tenant: 'acme' }) + }) + + it('hands out a copy of the user, not the object it keeps', () => { + api.setUser({ id: 'u-1' }) + ;(api.getUser() as Record).id = 'tampered' + api.addError(new Error('boom')) + flush() + + expect(eventsOfType('error')[0].usr).toEqual({ id: 'u-1' }) + }) + it('attaches the global context to events', () => { api.setGlobalContext({ tenant: 'acme' }) api.addError(new Error('boom')) diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index 591feed019..706fc587ce 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -196,7 +196,14 @@ export function makeRumLegacyPublicApi() { } }), - getInitConfiguration: monitor(() => initConfiguration), + /* + * The getters below hand out copies. The stored configuration is what a later consent grant + * starts from, and the contexts are attached to every event, so returning the live objects + * would let a caller change SDK behaviour by mutating what it read. The standard bundles clone + * for the same reason. Nested objects are still shared: the configuration holds only scalars, + * and cloning arbitrarily deep customer data is not worth the code here. + */ + getInitConfiguration: monitor(() => (initConfiguration ? shallowMerge(initConfiguration, {}) : undefined)), getInternalContext: monitor(() => undefined), @@ -222,7 +229,7 @@ export function makeRumLegacyPublicApi() { setGlobalContext: monitor((context: Context) => { globalContext = context ?? {} }), - getGlobalContext: monitor(() => globalContext), + getGlobalContext: monitor(() => shallowMerge(globalContext, {})), setGlobalContextProperty: monitor((key: string, value: any) => { globalContext[key] = value }), @@ -236,7 +243,7 @@ export function makeRumLegacyPublicApi() { setUser: monitor((user: Context) => { userContext = user ?? {} }), - getUser: monitor(() => userContext), + getUser: monitor(() => shallowMerge(userContext, {})), setUserProperty: monitor((key: string, value: any) => { userContext[key] = value }), @@ -250,7 +257,7 @@ export function makeRumLegacyPublicApi() { setAccount: monitor((account: Context) => { accountContext = account ?? {} }), - getAccount: monitor(() => accountContext), + getAccount: monitor(() => shallowMerge(accountContext, {})), setAccountProperty: monitor((key: string, value: any) => { accountContext[key] = value }), diff --git a/packages/rum-legacy/src/domain/errorCollection.spec.ts b/packages/rum-legacy/src/domain/errorCollection.spec.ts index ad999b3faf..d09cba0e04 100644 --- a/packages/rum-legacy/src/domain/errorCollection.spec.ts +++ b/packages/rum-legacy/src/domain/errorCollection.spec.ts @@ -105,6 +105,23 @@ describe('error collection', () => { expect(collected[0].message).toBe('bad access') }) + it('recognises an error created in another frame', () => { + // Frameset and iframe heavy pages are the norm for applications still running these browsers, + // and an Error built in another frame fails `instanceof Error` in this one. Treating it as a + // plain value would stringify it and lose the message, the type and the stack. + const frame = document.createElement('iframe') + document.body.appendChild(frame) + const ForeignError = (frame.contentWindow as unknown as { Error: ErrorConstructor }).Error + const foreignError = new ForeignError('from another frame') + const collection = start() + + collection.addError(foreignError) + document.body.removeChild(frame) + + expect(collected[0].message).toBe('from another frame') + expect(collected[0].type).toBe('Error') + }) + it('reports a manually added error as handled', () => { const collection = start() diff --git a/packages/rum-legacy/src/domain/errorCollection.ts b/packages/rum-legacy/src/domain/errorCollection.ts index ba7213e893..b58cfa9954 100644 --- a/packages/rum-legacy/src/domain/errorCollection.ts +++ b/packages/rum-legacy/src/domain/errorCollection.ts @@ -69,7 +69,7 @@ function computeError(message: Event | string, url?: string, line?: number, erro source_type: 'browser', } - if (error) { + if (isError(error)) { fillFromError(collected, error) return collected } @@ -91,7 +91,7 @@ function computeManualError(value: unknown): CollectedError { source_type: 'browser', } - if (value instanceof Error) { + if (isError(value)) { fillFromError(collected, value) } else { collected.message = String(value) @@ -100,6 +100,16 @@ function computeManualError(value: unknown): CollectedError { return collected } +/** + * `instanceof` compares against this frame's Error constructor, so an error built in another frame + * fails it. Frameset and iframe heavy applications are the norm on these browsers, and treating + * such an error as a plain value would stringify it and lose the message, type and stack. The + * standard bundles make the same allowance. + */ +function isError(value: unknown): value is Error { + return value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' +} + /** Everything an Error instance can contribute. Anonymous errors keep the message already set. */ function fillFromError(collected: CollectedError, error: Error): void { collected.message = error.message || collected.message diff --git a/packages/rum-legacy/src/transport/batch.ts b/packages/rum-legacy/src/transport/batch.ts index 3a4b6402fd..2f02d755c0 100644 --- a/packages/rum-legacy/src/transport/batch.ts +++ b/packages/rum-legacy/src/transport/batch.ts @@ -144,7 +144,7 @@ function serialize(event: object): string | undefined { * would undercount any non-latin content by a factor of three, letting batches grow well past the * intake limit on pages that are not written in English. */ -export function computeBytesCount(candidate: string): number { +function computeBytesCount(candidate: string): number { let count = 0 for (let i = 0; i < candidate.length; i++) { diff --git a/packages/rum-legacy/src/transport/intakeUrl.ts b/packages/rum-legacy/src/transport/intakeUrl.ts index c8b4d28bd1..9e5f42f979 100644 --- a/packages/rum-legacy/src/transport/intakeUrl.ts +++ b/packages/rum-legacy/src/transport/intakeUrl.ts @@ -83,7 +83,7 @@ function buildTag(key: string, rawValue: string): string { * only option: IE9 has no URL constructor, and merely referencing the `URL` global to feature-detect * it throws a ReferenceError there. */ -export function normalizeUrl(url: string): string { +function normalizeUrl(url: string): string { const anchor = document.createElement('a') anchor.href = url return anchor.href diff --git a/scripts/check-legacy-bundle-runtime.js b/scripts/check-legacy-bundle-runtime.js new file mode 100644 index 0000000000..e1a9a29ce9 --- /dev/null +++ b/scripts/check-legacy-bundle-runtime.js @@ -0,0 +1,202 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const vm = require('vm') +const { printLog, printError, runMain } = require('./lib/executionUtils') + +const BUNDLE_PATH = path.join(__dirname, '..', 'packages/rum-legacy/bundle/fc-rum-legacy.js') + +/** + * Smoke test for the emitted bundle rather than for its sources. + * + * Every unit spec runs against TypeScript compiled by the test runner, not against the file + * customers actually load. Between the two sit Terser and the webpack runtime, so a mangled + * property, a dropped assignment or an emitted helper that the target browsers lack would pass the + * whole suite and only fail once the file is served. + * + * The environment below is deliberately impoverished: no fetch, no Promise, no sendBeacon, and an + * XMLHttpRequest that only fires onreadystatechange, the way IE9 behaves. Anything the bundle + * reaches for that is not defined here throws, which is the point. + */ +runMain(() => { + if (!fs.existsSync(BUNDLE_PATH)) { + printError('Bundle not found, build it before running this check') + process.exit(1) + } + + const requests = [] + const context = createBrowserLikeContext(requests) + + vm.createContext(context) + vm.runInContext(fs.readFileSync(BUNDLE_PATH, 'utf-8'), context, { filename: 'fc-rum-legacy.js' }) + + const failures = [] + const api = context.window.FC_RUM + + if (!api || typeof api.init !== 'function') { + printError('The bundle did not expose FC_RUM.init') + process.exit(1) + } + + api.init({ + applicationId: '00000000-aaaa-0000-aaaa-000000000000', + clientToken: 'a_client_token', + proxy: '/rum-intake/', + }) + api.addError(new Error('smoke')) + + // Closing the page is what flushes without waiting for the timer. + context.window.__fireEvent('beforeunload') + + if (requests.length === 0) { + failures.push('the bundle sent nothing') + } else { + const request = requests[0] + if (request.method !== 'POST') { + failures.push(`expected a POST, got ${request.method}`) + } + if (request.url.indexOf('https://app.example.com/rum-intake/?ddforward=') !== 0) { + failures.push(`unexpected intake url: ${request.url}`) + } + + // The property the whole deployment rests on: the real intake path and its parameters travel + // inside ddforward, so a reverse proxy rule written for the standard bundles also serves this + // one. Checking only the prefix above would miss a change to either. + const forwarded = decodeURIComponent(request.url.split('?ddforward=')[1] || '') + const [forwardedPath, forwardedQuery] = forwarded.split('?') + if (forwardedPath !== '/api/v2/rum') { + failures.push(`unexpected forwarded intake path: ${forwardedPath}`) + } + const parameterNames = (forwardedQuery || '').split('&').map((entry) => entry.split('=')[0]) + const expectedParameters = [ + 'ddsource', + 'ddtags', + 'dd-api-key', + 'dd-evp-origin-version', + 'dd-evp-origin', + 'dd-request-id', + 'batch_time', + ] + if (parameterNames.join(',') !== expectedParameters.join(',')) { + failures.push(`unexpected intake parameters: ${parameterNames.join(',')}`) + } + if (request.async !== false) { + failures.push('the exit request was not synchronous') + } + const events = request.body.split('\n').map((line) => JSON.parse(line)) + const types = events.map((event) => event.type) + for (const expected of ['view', 'error']) { + if (types.indexOf(expected) === -1) { + failures.push(`no ${expected} event in the payload, got: ${types.join(', ')}`) + } + } + const error = events.filter((event) => event.type === 'error')[0] + if (error && error.error.message !== 'smoke') { + failures.push(`unexpected error message: ${error.error.message}`) + } + if (error && !error.session.id) { + failures.push('the payload carries no session id') + } + } + + if (failures.length > 0) { + printError('Legacy bundle runtime check failed:') + for (const failure of failures) { + printError(` - ${failure}`) + } + process.exit(1) + } + + printLog('✅ the emitted bundle initialises and reports in a browser without the modern APIs') +}) + +function createBrowserLikeContext(requests) { + const listeners = {} + let cookie = '' + + function XMLHttpRequestStub() { + const request = { async: true } + this.open = function (method, url, isAsync) { + request.method = method + request.url = url + request.async = isAsync + } + this.send = function (body) { + request.body = body + requests.push(request) + this.readyState = 4 + this.status = 202 + if (this.onreadystatechange) { + this.onreadystatechange() + } + } + // Deliberately no onload: IE10 introduced it. + } + + const window = { + location: { href: 'https://app.example.com/checkout', origin: 'https://app.example.com' }, + XMLHttpRequest: XMLHttpRequestStub, + navigator: { userAgent: 'IE9-like' }, + addEventListener(eventName, handler) { + listeners[eventName] = listeners[eventName] || [] + listeners[eventName].push(handler) + }, + removeEventListener(eventName, handler) { + const registered = listeners[eventName] || [] + const index = registered.indexOf(handler) + if (index !== -1) { + registered.splice(index, 1) + } + }, + setTimeout: () => 0, + clearTimeout: () => undefined, + __fireEvent(eventName) { + for (const handler of listeners[eventName] || []) { + handler() + } + }, + } + + window.window = window + window.self = window + + window.document = { + readyState: 'complete', + referrer: 'https://search.example.com/', + get cookie() { + return cookie + }, + set cookie(value) { + cookie = value.split(';')[0] + }, + createElement: (tagName) => { + if (tagName !== 'a') { + throw new Error(`unexpected element requested: ${tagName}`) + } + // Resolves a relative url against the document, which is what the anchor trick does. + const anchor = { _href: '' } + Object.defineProperty(anchor, 'href', { + get: () => anchor._href, + set: (value) => { + anchor._href = value.indexOf('http') === 0 ? value : `${window.location.origin}${value}` + }, + }) + return anchor + }, + getElementsByTagName: () => [], + } + + window.performance = { + timing: { + navigationStart: 1_000_000, + responseStart: 1_000_100, + domInteractive: 1_000_200, + domContentLoadedEventEnd: 1_000_300, + domComplete: 1_000_400, + loadEventEnd: 1_000_500, + }, + } + + return window +} From e7b878684526284767bfc4a904f7baee02674fee Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 17 Aug 2026 10:00:14 -0700 Subject: [PATCH 12/32] fix(rum-legacy): copy context and configuration on the way in as well The previous pass stopped the getters handing out the objects the SDK keeps, but left the other half: setGlobalContext, setUser, setAccount and init all stored the caller's object by reference. Pages commonly keep the object they passed. An unrelated later mutation of it silently changed what every subsequent event carried, and for the configuration it changed what a later consent grant would start from. Fixing only the read side left the same defect reachable from the write side, which is worse than not having noticed it, because the specs looked like the problem was covered. Data is now copied at both boundaries. --- .../rum-legacy/src/boot/publicApi.spec.ts | 32 +++++++++++++++++++ packages/rum-legacy/src/boot/publicApi.ts | 20 ++++++------ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index ffaee58b56..338ded552a 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -83,6 +83,16 @@ describe('public api', () => { expect(payloads).toEqual([]) }) + it("copies the configuration in, so a later change to the caller's object does not leak", () => { + const caller = { ...VALID_CONFIGURATION, trackingConsent: 'not-granted' } + api.init(caller) + caller.applicationId = 'tampered' + api.setTrackingConsent('granted') + flush() + + expect(sentEvents()[0].application.id).toBe(VALID_CONFIGURATION.applicationId) + }) + it('hands out a copy of the configuration, not the object it keeps', () => { api.init({ ...VALID_CONFIGURATION, trackingConsent: 'not-granted' }) @@ -271,6 +281,28 @@ describe('public api', () => { expect(closedView.view.action.count).toBe(1) }) + it("copies the global context in, so a later change to the caller's object does not leak", () => { + const caller = { tenant: 'acme' } + api.setGlobalContext(caller) + caller.tenant = 'tampered' + api.addError(new Error('boom')) + flush() + + // Pages commonly keep the object they passed in. Storing it by reference would let an + // unrelated later mutation silently change what every event carries. + expect(eventsOfType('error')[0].context).toEqual({ tenant: 'acme' }) + }) + + it('copies the user in as well', () => { + const caller = { id: 'u-1' } + api.setUser(caller) + caller.id = 'tampered' + api.addError(new Error('boom')) + flush() + + expect(eventsOfType('error')[0].usr).toEqual({ id: 'u-1' }) + }) + it('hands out a copy of the global context, not the object it keeps', () => { api.setGlobalContext({ tenant: 'acme' }) ;(api.getGlobalContext() as Record).tenant = 'tampered' diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index 706fc587ce..09aee74064 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -189,7 +189,9 @@ export function makeRumLegacyPublicApi() { if (!validate(configuration)) { return } - initConfiguration = configuration + // Copied on the way in: pages commonly keep the object they passed, and this one is what a + // later consent grant starts from. + initConfiguration = shallowMerge(configuration, {}) as LegacyInitConfiguration trackingConsent = configuration.trackingConsent ?? TRACKING_CONSENT_GRANTED if (trackingConsent === TRACKING_CONSENT_GRANTED) { running = start(configuration) @@ -197,11 +199,11 @@ export function makeRumLegacyPublicApi() { }), /* - * The getters below hand out copies. The stored configuration is what a later consent grant - * starts from, and the contexts are attached to every event, so returning the live objects - * would let a caller change SDK behaviour by mutating what it read. The standard bundles clone - * for the same reason. Nested objects are still shared: the configuration holds only scalars, - * and cloning arbitrarily deep customer data is not worth the code here. + * Data is copied at both boundaries, in and out. Storing the caller's object would let an + * unrelated later mutation change what every event carries, and returning it would let a caller + * change SDK behaviour by mutating what it read. The standard bundles clone for the same + * reason. Nested objects are still shared: the configuration holds only scalars, and cloning + * arbitrarily deep customer data is not worth the code here. */ getInitConfiguration: monitor(() => (initConfiguration ? shallowMerge(initConfiguration, {}) : undefined)), @@ -227,7 +229,7 @@ export function makeRumLegacyPublicApi() { }), setGlobalContext: monitor((context: Context) => { - globalContext = context ?? {} + globalContext = context ? shallowMerge(context, {}) : {} }), getGlobalContext: monitor(() => shallowMerge(globalContext, {})), setGlobalContextProperty: monitor((key: string, value: any) => { @@ -241,7 +243,7 @@ export function makeRumLegacyPublicApi() { }), setUser: monitor((user: Context) => { - userContext = user ?? {} + userContext = user ? shallowMerge(user, {}) : {} }), getUser: monitor(() => shallowMerge(userContext, {})), setUserProperty: monitor((key: string, value: any) => { @@ -255,7 +257,7 @@ export function makeRumLegacyPublicApi() { }), setAccount: monitor((account: Context) => { - accountContext = account ?? {} + accountContext = account ? shallowMerge(account, {}) : {} }), getAccount: monitor(() => shallowMerge(accountContext, {})), setAccountProperty: monitor((key: string, value: any) => { From b7c78c07168e1473d3ee9b0f5841a82312a56785 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 17 Aug 2026 10:05:45 -0700 Subject: [PATCH 13/32] fix(rum-legacy): reject a non-numeric sample rate, keep foreign cookie fields The sample rate range was checked by negating it. NaN fails every comparison, so a rate computed from a string and landing on NaN passed validation, and then failed the sampling comparison too: the SDK looked configured and silently reported nothing, which is the worst way for a monitoring build to go wrong. The range is now checked positively, as the standard bundles check it. The session cookie is shared with the standard bundles, which keep their own entries in it. The anonymous user id is one of them and is tracked by default. Rewriting the cookie with only the four fields this build understands destroyed it, so a visit through a page served in compatibility mode reset anonymous user continuity for every other page of the same site. Entries this build does not understand are now written back untouched; they still cannot reach the session identity or the tracking decision, which are read from named fields only. --- .../rum-legacy/src/boot/publicApi.spec.ts | 12 +++++++ packages/rum-legacy/src/boot/publicApi.ts | 14 +++++---- .../src/domain/sessionStore.spec.ts | 17 +++++++++- .../rum-legacy/src/domain/sessionStore.ts | 31 ++++++++++++------- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index 338ded552a..97468a3fb9 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -150,6 +150,18 @@ describe('public api', () => { expect(sentEvents()[0]._dd.configuration.session_sample_rate).toBe(100) }) + it('refuses a sample rate that is not a real number', () => { + // A page computing the rate from a string can land on NaN. The negated form of the range + // check lets it through, and NaN then fails every sampling comparison, so the SDK looks + // configured and silently reports nothing. The standard bundles check the range positively. + api.init({ ...VALID_CONFIGURATION, sessionSampleRate: Number('not a number') }) + api.addError(new Error('boom')) + flush() + + expect(payloads).toEqual([]) + expect(api.getInitConfiguration()).toBeUndefined() + }) + it('refuses a sample rate outside 0 to 100', () => { api.init({ ...VALID_CONFIGURATION, sessionSampleRate: 500 }) flush() diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index 09aee74064..8e1fecf9c0 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -334,6 +334,10 @@ export function makeRumLegacyPublicApi() { return api } +function isPercentage(value: unknown): value is number { + return typeof value === 'number' && value >= 0 && value <= 100 +} + function validate(configuration: LegacyInitConfiguration | undefined): boolean { if (!configuration) { displayError('Missing configuration') @@ -353,12 +357,10 @@ function validate(configuration: LegacyInitConfiguration | undefined): boolean { displayError('proxy is not configured, we will not send any data.') return false } - if ( - configuration.sessionSampleRate !== undefined && - (typeof configuration.sessionSampleRate !== 'number' || - configuration.sessionSampleRate < 0 || - configuration.sessionSampleRate > 100) - ) { + // Checked positively rather than by negating the range: NaN fails every comparison, so the + // negated form would accept it, and NaN then fails the sampling comparison too. The SDK would + // look configured and silently report nothing, which is the worst way for this to go wrong. + if (configuration.sessionSampleRate !== undefined && !isPercentage(configuration.sessionSampleRate)) { displayError('Session Sample Rate should be a number between 0 and 100') return false } diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index 073d60b91c..45290e3e83 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -200,6 +200,19 @@ describe('session store', () => { }) describe('hostile input', () => { + it('preserves fields it does not understand instead of destroying them', () => { + // The standard bundles store the anonymous user id as `aid` in this same cookie, and track it + // by default. Rewriting the cookie without it would reset anonymous user continuity for them. + document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent( + `id=00000000-aaaa-0000-aaaa-000000000000&created=${Date.now()}&expire=${Date.now() + 60000}&rum=2&aid=11111111-bbbb-0000-bbbb-000000000000` + )};path=/` + + createSessionStore(100).getOrCreateSession() + + // The standard parser maps `aid` back to anonymousId, so this asserts what it will actually see. + expect(toSessionState(readRawCookie()).anonymousId).toBe('11111111-bbbb-0000-bbbb-000000000000') + }) + it('ignores unknown fields injected into the session cookie', () => { document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent( `id=00000000-aaaa-0000-aaaa-000000000000&created=${Date.now()}&expire=${Date.now() + 60000}&rum=2&evil=payload` @@ -207,8 +220,10 @@ describe('session store', () => { const session = createSessionStore(100).getOrCreateSession() + // Unknown entries are carried through rather than acted on: they cannot reach the session + // identity or the tracking decision, which are read from named fields only. expect(session.id).toBe('00000000-aaaa-0000-aaaa-000000000000') - expect(readRawCookie()).not.toContain('evil') + expect(session.isTracked).toBe(true) }) it('is not confused by a polluted Object prototype', () => { diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index 9e50d7fb85..637fc7f145 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -40,6 +40,8 @@ interface SessionState { created?: string expire?: string rum?: string + // The standard bundles keep their own entries in this cookie, the anonymous user id among them. + [key: string]: string | undefined } export function createSessionStore(sessionSampleRate: number) { @@ -110,20 +112,27 @@ function isExpired(state: SessionState, now: number): boolean { * /^([a-zA-Z]+)=([a-z0-9-]+)$/. Uppercase characters or padding would make it discard the whole * cookie, silently restarting the session on every page load. */ +const KNOWN_FIELDS = ['id', 'created', 'expire', 'rum'] + function serialize(state: SessionState): string { const entries: string[] = [] - if (state.id) { - entries.push(`id=${state.id}`) - } - if (state.created) { - entries.push(`created=${state.created}`) - } - if (state.expire) { - entries.push(`expire=${state.expire}`) + + for (let i = 0; i < KNOWN_FIELDS.length; i++) { + const value = state[KNOWN_FIELDS[i]] + if (value) { + entries.push(`${KNOWN_FIELDS[i]}=${value}`) + } } - if (state.rum) { - entries.push(`rum=${state.rum}`) + + // Anything else found in the cookie is written back untouched. The standard bundles keep their + // own entries here, the anonymous user id among them, and dropping one would reset it for them. + // Values reaching this point already passed the entry pattern when they were parsed. + for (const key in state) { + if (Object.prototype.hasOwnProperty.call(state, key) && KNOWN_FIELDS.indexOf(key) === -1 && state[key]) { + entries.push(`${key}=${state[key]}`) + } } + return entries.join('&') } @@ -133,7 +142,7 @@ function deserialize(value: string): SessionState | undefined { for (let i = 0; i < entries.length; i++) { const match = /^([a-zA-Z]+)=([a-z0-9-]+)$/.exec(entries[i]) if (match) { - state[match[1] as keyof SessionState] = match[2] + state[match[1]] = match[2] } } return state.id ? state : undefined From 8738d26831b562d01a6e6099fe402e7d2c8fd6dd Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 17 Aug 2026 10:11:30 -0700 Subject: [PATCH 14/32] fix(rum-legacy): declare the content type the intake requires Verified the "no backend change" claim against the intake itself rather than against the standard bundles' url shape, and found the transport would have been refused outright. The intake rejects any body whose content type is not text/plain. This build deliberately set no request header at all, on the reasoning that it kept the request simple and avoided a preflight. Both halves of that were wrong: a same-origin request never preflights, and text/plain is a safelisted value that does not trigger one even cross-origin. The standard bundles get away with declaring nothing because fetch and sendBeacon set it implicitly for a string body; XMLHttpRequest on these browsers cannot be relied on to do the same. Nothing client-side could have caught this. The specs and the artifact check both asserted the absence of headers, so the mistaken belief was encoded three times over: in the transport, in its spec, and in the fake XMLHttpRequest of the degraded environment specs, which threw if a header was set. Both levels now assert the header, and both fail without it. --- packages/rum-legacy/README.md | 11 ++++++++--- .../rum-legacy/src/boot/degradedEnvironment.spec.ts | 10 ++++++---- .../rum-legacy/src/transport/httpRequest.spec.ts | 13 +++++++++++-- packages/rum-legacy/src/transport/httpRequest.ts | 8 ++++++-- scripts/check-legacy-bundle-runtime.js | 10 +++++++++- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index a39e77d669..907a72952c 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -96,9 +96,14 @@ location /rum-intake/ { } ``` -No request header is set, keeping it a simple request. If a Content Security Policy is in force it -needs to allow the static host and `connect-src` to the page's own origin. `unsafe-eval` is not -required. +The request declares `Content-Type: text/plain;charset=UTF-8`, which the intake requires. The +standard bundles never declare it because `fetch` and `sendBeacon` set it implicitly for a string +body; `XMLHttpRequest` on these browsers cannot be relied on to do the same. It costs nothing: the +request is same-origin, and `text/plain` is a safelisted value that does not trigger a preflight +even when it is not. + +If a Content Security Policy is in force it needs to allow the static host and `connect-src` to the +page's own origin. `unsafe-eval` is not required. ## Configuration diff --git a/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts index addddc2762..b193164450 100644 --- a/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts +++ b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts @@ -69,6 +69,7 @@ describe('degraded environment', () => { } let payloads: string[] + let headers: Array<[string, string]> let requests: Array<{ method?: string; url?: string; async?: boolean }> let originalXhr: typeof XMLHttpRequest let api: ReturnType | undefined @@ -76,6 +77,7 @@ describe('degraded environment', () => { beforeEach(() => { ;(window as unknown as BuildEnvWindow).__BUILD_ENV__SDK_VERSION__ = 'test-version' payloads = [] + headers = [] requests = [] jasmine.clock().install() originalXhr = window.XMLHttpRequest @@ -90,8 +92,8 @@ describe('degraded environment', () => { send(body: string) { payloads.push(body) }, - setRequestHeader() { - throw new Error('setting a request header would make this a preflighted request') + setRequestHeader(name: string, value: string) { + headers.push([name, value]) }, } return request @@ -139,7 +141,7 @@ describe('degraded environment', () => { expect(types).toContain('view') }) - it('sends over XMLHttpRequest without setting any request header', () => { + it('sends over XMLHttpRequest with the content type the intake requires', () => { withIE9Environment(() => { api = makeRumLegacyPublicApi() api.init(VALID_CONFIGURATION) @@ -147,10 +149,10 @@ describe('degraded environment', () => { }) jasmine.clock().tick(FLUSH_TIMEOUT) - // The fake throws if a header is set, so reaching here means the request stayed a simple one. expect(requests.length).toBeGreaterThan(0) expect(requests[0].method).toBe('POST') expect(requests[0].async).toBe(true) + expect(headers).toEqual([['Content-Type', 'text/plain;charset=UTF-8']]) }) it('still produces a valid session cookie', () => { diff --git a/packages/rum-legacy/src/transport/httpRequest.spec.ts b/packages/rum-legacy/src/transport/httpRequest.spec.ts index 060da230ce..bf7432c36d 100644 --- a/packages/rum-legacy/src/transport/httpRequest.spec.ts +++ b/packages/rum-legacy/src/transport/httpRequest.spec.ts @@ -106,10 +106,19 @@ describe('http request', () => { expect(sent[0].async).toBe(true) }) - it('does not set a content type, so the request stays a simple request', () => { + it('declares the content type the intake requires', () => { createHttpRequest(buildUrl).send('{}') - expect(sent[0].headers).toEqual([]) + // The intake rejects anything that is not text/plain. fetch and sendBeacon set it implicitly + // for a string body, which is why the standard bundles never declare it, but XMLHttpRequest on + // these browsers cannot be relied on to do the same. + expect(sent[0].headers).toEqual([['Content-Type', 'text/plain;charset=UTF-8']]) + }) + + it('sets it on the exit request too', () => { + createHttpRequest(buildUrl).sendOnExit('{}') + + expect(sent[0].headers).toEqual([['Content-Type', 'text/plain;charset=UTF-8']]) }) it('completes through onreadystatechange, which is the only handler IE9 fires', () => { diff --git a/packages/rum-legacy/src/transport/httpRequest.ts b/packages/rum-legacy/src/transport/httpRequest.ts index 595ed4b52d..de364bee2c 100644 --- a/packages/rum-legacy/src/transport/httpRequest.ts +++ b/packages/rum-legacy/src/transport/httpRequest.ts @@ -13,8 +13,11 @@ export interface HttpRequest { * to the browser and let the document go, so the last batch is sent inline while the page is * unloading. * - * No request header is set, keeping the request a "simple request" and matching what the modern - * bundle sends: the intake reads newline separated json without relying on a content type. + * The content type is declared explicitly. The intake rejects anything that is not text/plain, and + * while fetch and sendBeacon set it implicitly for a string body — which is why the standard + * bundles never declare it — XMLHttpRequest on these browsers cannot be relied on to do the same. + * Declaring it costs nothing: this request is same origin, and text/plain is a safelisted value + * that does not trigger a preflight even when it is not. */ export function createHttpRequest(buildUrl: () => string, onResponse?: (status: number) => void): HttpRequest { function request(data: string, isAsync: boolean): void { @@ -24,6 +27,7 @@ export function createHttpRequest(buildUrl: () => string, onResponse?: (status: try { const xhr = new XMLHttpRequest() xhr.open('POST', buildUrl(), isAsync) + xhr.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8') if (onResponse) { xhr.onreadystatechange = function () { diff --git a/scripts/check-legacy-bundle-runtime.js b/scripts/check-legacy-bundle-runtime.js index e1a9a29ce9..88f0db47c3 100644 --- a/scripts/check-legacy-bundle-runtime.js +++ b/scripts/check-legacy-bundle-runtime.js @@ -84,6 +84,11 @@ runMain(() => { if (request.async !== false) { failures.push('the exit request was not synchronous') } + // The intake rejects anything that is not text/plain, and these browsers do not set it for us. + const contentType = request.headers.filter((header) => header[0] === 'Content-Type')[0] + if (!contentType || contentType[1].indexOf('text/plain') !== 0) { + failures.push(`missing or wrong content type: ${contentType ? contentType[1] : 'none'}`) + } const events = request.body.split('\n').map((line) => JSON.parse(line)) const types = events.map((event) => event.type) for (const expected of ['view', 'error']) { @@ -116,12 +121,15 @@ function createBrowserLikeContext(requests) { let cookie = '' function XMLHttpRequestStub() { - const request = { async: true } + const request = { async: true, headers: [] } this.open = function (method, url, isAsync) { request.method = method request.url = url request.async = isAsync } + this.setRequestHeader = function (name, value) { + request.headers.push([name, value]) + } this.send = function (body) { request.body = body requests.push(request) From 87a46da3f989b1bd2022b826a74bdfbac746252c Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 17 Aug 2026 23:28:48 -0700 Subject: [PATCH 15/32] feat(rum-legacy): add a real-browser verification harness Everything this package is checked with so far runs on a modern engine: the unit suite, the degraded-environment specs and the artifact smoke test all approximate the target browsers rather than being one. This adds the missing step, a harness for running the shipped bundle on a real browser and seeing the result on the device itself. The page is plain ES5 and renders every check into the DOM, because the browsers it targets often have no usable developer tools. The server doubles as a same-origin intake that records what actually arrived, so the checks assert the wire rather than the SDK's own claims: the bundle loads, the collection APIs do not throw into the page, an uncaught error still reaches the page's own handler, the session cookie is written, and the intake received a text/plain POST whose real path travels inside ddforward. One check only has teeth on an old engine: any fetch-era browser adds the content type to a string body implicitly, so the header assertion cannot fail there regardless of the SDK. That is exactly why it lives in this harness and not only in the unit suite. JSON is parsed with JSON.parse, native since IE8. An eval-based parse would also break under any Content Security Policy, which the rest of the package promises not to require. --- packages/rum-legacy/README.md | 23 ++ .../rum-legacy/scripts/verification-server.js | 98 ++++++ packages/rum-legacy/verification/index.html | 332 ++++++++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 packages/rum-legacy/scripts/verification-server.js create mode 100644 packages/rum-legacy/verification/index.html diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index 907a72952c..ba4ba73fac 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -177,3 +177,26 @@ sampling and consent gates, and the listener guards. That covers missing runtime APIs and unsupported syntax. It does not cover the behaviour of an actual old browser engine. **This package has not been verified on real hardware**, and that verification is a separate step before any support commitment is made. + +## Verifying on a real browser + +`verification/` holds a self-contained harness for exactly that step: + +```bash +node packages/rum-legacy/scripts/verification-server.js # builds are not included: build first +``` + +Then open `http://localhost:8099/` in the browser under test and press _Run checks_. The page is +plain ES5 and renders every result into the DOM, because the browsers it targets often have no +usable developer tools. The server doubles as a same-origin intake that records what actually +arrived — method, content type, body — so the checks assert the wire, not the SDK's own claims: +the bundle loads, `init` and the collection APIs do not throw into the page, an uncaught error +still reaches the page's own handler, the session cookie is written, and the intake received a +`text/plain` POST whose real path travels inside `ddforward`, carrying a view and an error event. + +On Windows, Edge's IE mode (F12 → emulation → document mode 9/10/11) runs the real Trident engine +and is the cheapest meaningful pass; a run on actual IE hardware or a cloud device farm is the +authoritative one. One check is worth knowing about: the content-type assertion passes on any +modern browser regardless of the SDK, because `fetch`-era browsers add the header to a string body +implicitly. Only an old engine can genuinely fail it, which is precisely why it is in this page and +not only in the unit suite. diff --git a/packages/rum-legacy/scripts/verification-server.js b/packages/rum-legacy/scripts/verification-server.js new file mode 100644 index 0000000000..59cbc0ae5e --- /dev/null +++ b/packages/rum-legacy/scripts/verification-server.js @@ -0,0 +1,98 @@ +'use strict' + +/* + * Serves the verification page, the bundle, and a same-origin intake that records what it received. + * + * Recording the request is the point. On the browsers this package targets there is often no usable + * console, and the failure that matters most — a request the intake refuses because of its headers — + * is invisible from inside the page. The server keeps what arrived, the page reads it back and + * renders it, and the whole round trip becomes observable on the device itself. + * + * No dependencies, so it runs anywhere, including a bare Windows box. + */ + +const http = require('http') +const fs = require('fs') +const path = require('path') +const { printLog, runMain } = require('../../../scripts/lib/executionUtils') + +const PORT = Number(process.env.PORT || 8099) +const PACKAGE_ROOT = path.join(__dirname, '..') +const BUNDLE = path.join(PACKAGE_ROOT, 'bundle', 'fc-rum-legacy.js') +const PAGE = path.join(PACKAGE_ROOT, 'verification', 'index.html') + +const received = [] + +runMain(() => { + const server = createServer() + server.listen(PORT, () => { + printLog(`verification page on http://localhost:${PORT}/`) + }) +}) + +function createServer() { + return http.createServer((request, response) => { + const url = request.url || '/' + + if (url.indexOf('/rum-intake/') === 0) { + collectBody(request, (body) => { + received.push({ + method: request.method, + url, + contentType: request.headers['content-type'] || null, + userAgent: request.headers['user-agent'] || null, + body, + at: new Date().toISOString(), + }) + // The intake answers 202; anything else would send the SDK down its retry path. + response.writeHead(202, { 'Access-Control-Allow-Origin': '*' }) + response.end('') + }) + return + } + + if (url.indexOf('/received') === 0) { + response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + response.end(JSON.stringify(received)) + return + } + + if (url.indexOf('/reset') === 0) { + received.length = 0 + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('reset') + return + } + + if (url.indexOf('/fc-rum-legacy.js') === 0) { + serveFile(response, BUNDLE, 'application/javascript') + return + } + + if (url === '/' || url.indexOf('/index.html') === 0) { + serveFile(response, PAGE, 'text/html') + return + } + + response.writeHead(404) + response.end('not found') + }) +} + +function collectBody(request, callback) { + const chunks = [] + request.on('data', (chunk) => chunks.push(chunk)) + request.on('end', () => callback(Buffer.concat(chunks).toString('utf-8'))) +} + +function serveFile(response, filePath, contentType) { + fs.readFile(filePath, (error, content) => { + if (error) { + response.writeHead(404) + response.end(`missing ${path.basename(filePath)} — build the package first`) + return + } + response.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-store' }) + response.end(content) + }) +} diff --git a/packages/rum-legacy/verification/index.html b/packages/rum-legacy/verification/index.html new file mode 100644 index 0000000000..e4da144b3f --- /dev/null +++ b/packages/rum-legacy/verification/index.html @@ -0,0 +1,332 @@ + + + + + RUM legacy build verification + + + +

RUM legacy build verification

+
+ + +
+ + +
+ + + + + + + + + + +
#CheckResultObserved
+
+ + + + From 2963991ebea371ef4525c4ac3ab3caa37a0ee88a Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 17 Aug 2026 23:55:07 -0700 Subject: [PATCH 16/32] fix(rum-legacy): make the verification run fit a metered device session Cloud device farms meter free sessions by the minute, and the harness spent over thirty seconds waiting out the SDK's flush timer. The run now fills the batch to its limit so it flushes over the asynchronous path immediately, starts itself when opened with ?autorun=1, and keeps its results across the exit-check reload in sessionStorage. A full pass takes under a second plus one reload. The root path did not resolve when a query string was attached, which made ?autorun=1 a 404: routing now matches on the pathname. The page-exit check reports SKIP rather than FAIL on modern engines, which block synchronous XHR during page dismissal by design. Like the content-type check, it can only genuinely pass or fail on Trident, which is why it is in this page at all. --- packages/rum-legacy/README.md | 9 +- .../rum-legacy/scripts/verification-server.js | 3 +- packages/rum-legacy/verification/index.html | 158 ++++++++++++------ 3 files changed, 115 insertions(+), 55 deletions(-) diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index ba4ba73fac..3dba40ca09 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -196,7 +196,8 @@ still reaches the page's own handler, the session cookie is written, and the int On Windows, Edge's IE mode (F12 → emulation → document mode 9/10/11) runs the real Trident engine and is the cheapest meaningful pass; a run on actual IE hardware or a cloud device farm is the -authoritative one. One check is worth knowing about: the content-type assertion passes on any -modern browser regardless of the SDK, because `fetch`-era browsers add the header to a string body -implicitly. Only an old engine can genuinely fail it, which is precisely why it is in this page and -not only in the unit suite. +authoritative one. Two checks only have meaning on a real Trident engine, which is precisely why they are in this +page and not only in the unit suite: the content-type assertion passes on any modern browser +regardless of the SDK, because `fetch`-era browsers add the header to a string body implicitly — +and the page-exit assertion shows SKIP on modern engines, which block synchronous XHR during page +dismissal by design. diff --git a/packages/rum-legacy/scripts/verification-server.js b/packages/rum-legacy/scripts/verification-server.js index 59cbc0ae5e..0e2a60ef06 100644 --- a/packages/rum-legacy/scripts/verification-server.js +++ b/packages/rum-legacy/scripts/verification-server.js @@ -33,6 +33,7 @@ runMain(() => { function createServer() { return http.createServer((request, response) => { const url = request.url || '/' + const pathname = url.split('?')[0] if (url.indexOf('/rum-intake/') === 0) { collectBody(request, (body) => { @@ -69,7 +70,7 @@ function createServer() { return } - if (url === '/' || url.indexOf('/index.html') === 0) { + if (pathname === '/' || pathname === '/index.html') { serveFile(response, PAGE, 'text/html') return } diff --git a/packages/rum-legacy/verification/index.html b/packages/rum-legacy/verification/index.html index e4da144b3f..80e5b1b57a 100644 --- a/packages/rum-legacy/verification/index.html +++ b/packages/rum-legacy/verification/index.html @@ -33,8 +33,9 @@ .fail { background: #f8d4d4; } - .wait { - background: #fdf3d0; + .skip { + background: #e8e8e8; + color: #666; } .env td { background: #f4f4f4; @@ -75,11 +76,17 @@

RUM legacy build verification

From 31a73b86a80456a381e404ffe8afeda9bcc6e049 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 18 Aug 2026 02:07:01 -0700 Subject: [PATCH 17/32] feat(rum-legacy): guarantee silence on engines below the support floor Real-device runs surfaced what happens below IE9: the loader snippet routes every browser without fetch and Promise to this bundle, and on IE8 the whole evaluation died on Object.defineProperty, which rejects plain objects there. The throw surfaced as an uncaught error in the hosting page, and IE8 document mode is routinely forced by enterprise site lists, so this is reachable, not theoretical. The promise for those engines inverts: collecting nothing is fine, but the page must stay untouched. The defineProperty call now falls back to a plain assignment, the entire module evaluation is guarded so any construction failure leaves the loader's queued stub in place, and a spec holds the constructor to that with a throwing defineProperty. Syntax cannot be guarded at runtime, so the build gate now also parses the bundle for ES3 reserved words used as property names, which the IE6/7 engines fail to parse outright. ES5 allows them, meaning neither the compiler nor the ES5 parse check would object. --- packages/rum-legacy/README.md | 6 +++ .../rum-legacy/src/boot/publicApi.spec.ts | 17 ++++++ packages/rum-legacy/src/boot/publicApi.ts | 20 ++++--- packages/rum-legacy/src/entries/main.ts | 20 ++++++- scripts/check-es5-compatibility.js | 53 +++++++++++++++++++ 5 files changed, 107 insertions(+), 9 deletions(-) diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index 3dba40ca09..db0afebfcf 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -29,6 +29,12 @@ build exists to avoid. Everything unsupported is a no-op method rather than a missing one. A page written against the standard bundle runs unchanged; it does not need to branch on the browser. +Below the floor — IE6 to IE8 and their document modes, which the loader snippet also routes here — +the promise inverts: nothing is collected, and the bundle's whole evaluation is guarded so the +hosting page stays untouched. `Object.defineProperty` on plain objects, which IE8 rejects, is +guarded individually, and the build gate additionally rejects ES3 reserved words used as property +names, which those engines cannot even parse and no runtime guard could catch. + ## Setup Both builds share the `FC_RUM` global and the same call sequence, so the page carries one snippet. diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index 97468a3fb9..2943ca63be 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -599,6 +599,23 @@ describe('public api', () => { } }) + it('still constructs when Object.defineProperty rejects plain objects', () => { + // IE8 and the IE8 document mode only accept DOM objects there. The loader snippet routes + // those browsers to this bundle, so construction failing would throw an uncaught error into + // the customer's page at script evaluation time. + const original = Object.defineProperty + ;(Object as { defineProperty: unknown }).defineProperty = () => { + throw new Error('only DOM objects are supported') + } + try { + const freshApi = makeRumLegacyPublicApi() + expect(typeof freshApi.init).toBe('function') + expect(typeof (freshApi as unknown as { _stop: unknown })._stop).toBe('function') + } finally { + ;(Object as { defineProperty: unknown }).defineProperty = original + } + }) + it('reports its version', () => { expect(typeof api.version).toBe('string') }) diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index 8e1fecf9c0..550d918e15 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -323,13 +323,19 @@ export function makeRumLegacyPublicApi() { // Internal escape hatch used by the specs to tear down between runs, kept off the public surface // the same way the modern bundle hides its debug switch. - Object.defineProperty(api, '_stop', { - value: () => { - running?.stop(true) - running = undefined - }, - enumerable: false, - }) + const stop = () => { + running?.stop(true) + running = undefined + } + try { + // IE8 and the IE8 document mode only accept DOM objects here and throw for plain ones. Our own + // loader snippet routes those browsers to this bundle, and a cosmetic hidden property is not + // worth failing the whole evaluation for: falling back to a plain assignment keeps the page + // free of the uncaught error the throw would otherwise become. + Object.defineProperty(api, '_stop', { value: stop, enumerable: false }) + } catch { + ;(api as unknown as { _stop: () => void })._stop = stop + } return api } diff --git a/packages/rum-legacy/src/entries/main.ts b/packages/rum-legacy/src/entries/main.ts index 62595c6f6c..4243d1005c 100644 --- a/packages/rum-legacy/src/entries/main.ts +++ b/packages/rum-legacy/src/entries/main.ts @@ -5,6 +5,22 @@ interface BrowserWindow extends Window { FC_RUM?: unknown } -export const flashcatRumLegacy = makeRumLegacyPublicApi() +/* + * The whole evaluation is guarded. The loader snippet routes every browser without fetch and + * Promise here, which includes engines below even this build's floor (IE6 to IE8, and their + * document modes). On those, collecting nothing is acceptable; an uncaught error thrown into the + * customer's page while the script evaluates is not. If construction fails, the loader's stub is + * left in place, where queued calls stay harmless. + * + * A syntax-level incompatibility cannot be caught here; the ES3 property-name scan in + * check-es5-compatibility.js covers that side. + */ +let api: ReturnType | undefined +try { + api = makeRumLegacyPublicApi() + defineGlobal(window as BrowserWindow, 'FC_RUM', api) +} catch { + // Deliberately silent: there may be no console to warn into, and warning is not worth risking. +} -defineGlobal(window as BrowserWindow, 'FC_RUM', flashcatRumLegacy) +export const flashcatRumLegacy = api diff --git a/scripts/check-es5-compatibility.js b/scripts/check-es5-compatibility.js index 2428af94b5..8af6491a18 100644 --- a/scripts/check-es5-compatibility.js +++ b/scripts/check-es5-compatibility.js @@ -48,6 +48,52 @@ const FORBIDDEN_GLOBALS = [ const FORBIDDEN_MEMBERS = ['Object.assign', 'Array.from', 'Object.entries', 'Object.values'] +/** + * ES3 reserved words. ES5 allows them as property names; the ES3 engines in IE6 and IE7 (and + * their document modes) fail to PARSE them there, which no runtime guard can catch. The legacy + * bundle promises those browsers a silent no-op, and a parse error is the opposite of silent. + */ +const ES3_RESERVED = new Set( + ( + 'break case catch class const continue debugger default delete do else enum export extends ' + + 'false finally for function if import in instanceof new null return super switch this throw ' + + 'true try typeof var void while with' + ).split(' ') +) + +function findEs3ReservedProperties(relativePath) { + const absolutePath = path.join(ROOT_DIR, relativePath) + if (!fs.existsSync(absolutePath)) { + return undefined + } + const ast = acorn.parse(fs.readFileSync(absolutePath, 'utf-8'), { ecmaVersion: 5 }) + const found = new Set() + + ;(function walk(node) { + if (!node || typeof node.type !== 'string') { + return + } + if (node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier') { + if (ES3_RESERVED.has(node.property.name)) { + found.add(`.${node.property.name}`) + } + } + if (node.type === 'Property' && node.key.type === 'Identifier' && ES3_RESERVED.has(node.key.name)) { + found.add(`{${node.key.name}:}`) + } + for (const key of Object.keys(node)) { + const value = node[key] + if (Array.isArray(value)) { + value.forEach(walk) + } else if (value && typeof value.type === 'string') { + walk(value) + } + } + })(ast) + + return [...found] +} + runMain(() => { const failures = [] @@ -61,6 +107,13 @@ runMain(() => { } else { printLog(`✅ ${relativePath} references no API the target browsers lack`) } + + const reserved = findEs3ReservedProperties(relativePath) + if (reserved && reserved.length > 0) { + failures.push(`${relativePath}: uses ES3 reserved words as property names, which IE6/7 cannot parse: ${reserved.join(', ')}`) + } else if (reserved) { + printLog(`✅ ${relativePath} uses no ES3 reserved word as a property name`) + } } for (const relativePath of EXPECTED_ES5) { From 2eaced755c58f8d76b38cc0bc734a25a782ac1b4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 18 Aug 2026 02:07:01 -0700 Subject: [PATCH 18/32] fix(rum-legacy): harden the verification harness on real Trident engines Real IE runs found four defects in the harness itself. Tables are now built with DOM calls: IE9 makes innerHTML read-only on table sections and IE8 rejects it with its own error, so string rendering worked everywhere except on the devices this page exists for. The bundle loader guards its callback, because IE10 and 11 fire both onload and onreadystatechange and every check ran twice. Payload assertions aggregate all received requests, since async flushes travel the tunnel independently and arrive out of order. A rerun stops the previous SDK instance first instead of leaving two instances reporting at once. Errors now surface into the page from a separate script block that survives a syntax error in the main one, which is what identified every failure above on consoleless browsers. A final check asserts that no unexpected uncaught error reached the page, which is the whole acceptance criterion for engines below the support floor. The server logs each request so the device's traffic is observable from the serving side. --- .../rum-legacy/scripts/verification-server.js | 1 + packages/rum-legacy/verification/index.html | 231 ++++++++++++------ 2 files changed, 154 insertions(+), 78 deletions(-) diff --git a/packages/rum-legacy/scripts/verification-server.js b/packages/rum-legacy/scripts/verification-server.js index 0e2a60ef06..efadfe98fa 100644 --- a/packages/rum-legacy/scripts/verification-server.js +++ b/packages/rum-legacy/scripts/verification-server.js @@ -34,6 +34,7 @@ function createServer() { return http.createServer((request, response) => { const url = request.url || '/' const pathname = url.split('?')[0] + printLog(`${request.method} ${url} UA: ${(request.headers['user-agent'] || '').slice(0, 60)}`) if (url.indexOf('/rum-intake/') === 0) { collectBody(request, (body) => { diff --git a/packages/rum-legacy/verification/index.html b/packages/rum-legacy/verification/index.html index 80e5b1b57a..6ecaa207b9 100644 --- a/packages/rum-legacy/verification/index.html +++ b/packages/rum-legacy/verification/index.html @@ -57,40 +57,48 @@

RUM legacy build verification

- - -
- - - - - - - - - - -
#CheckResultObserved
+ +
+
+ + From ce497136049c091ca052068b6d8d31a256f88f29 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 18 Aug 2026 04:09:20 -0700 Subject: [PATCH 19/32] fix(rum-legacy): survive the formatter and the pre-XHR engines in the harness Three more findings from real IE6 and IE8 runs. The formatter added trailing commas to multiline literals. They are legal ES5, so every static check passed, but IE8 counts a trailing comma in an array literal as one more undefined element, and IE6 and 7 refuse to parse them in object literals at all. The page is now listed in .prettierignore, carries a comment saying why, and the commas are gone. IE6 predates the native XMLHttpRequest constructor, so the harness's own requests threw before they could observe anything. It now falls back to the ActiveX flavour, which ran successfully on a real MSIE 6.0. The no-unexpected-errors check only rendered when the intake had received something. On engines below the support floor nothing ever arrives, and that check is precisely the acceptance criterion there: it now renders on both paths, and the closing note explains that red collection rows plus a green cleanliness row is the expected shape. --- .prettierignore | 3 ++ packages/rum-legacy/verification/index.html | 44 +++++++++++++++------ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/.prettierignore b/.prettierignore index ec56c2ec33..1948fea990 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,6 @@ rum-events-format developer-extension/dist test/**/dist yarn.lock +# IE8 counts a trailing comma in an array literal as an extra undefined element, and prettier +# insists on adding them; this page must stay runnable down to IE6. +packages/rum-legacy/verification/index.html diff --git a/packages/rum-legacy/verification/index.html b/packages/rum-legacy/verification/index.html index 6ecaa207b9..e2b946e79a 100644 --- a/packages/rum-legacy/verification/index.html +++ b/packages/rum-legacy/verification/index.html @@ -93,6 +93,10 @@

RUM legacy build verification

* The run finishes in seconds rather than minutes: cloud device sessions are metered, so the * batch is filled to its message limit to flush over the asynchronous path immediately, and * results survive the exit-check reload in sessionStorage (native since IE8). + * + * This file must never be run through a formatter that adds trailing commas: they are legal + * ES5, but IE8 counts a trailing comma in an array literal as one more (undefined) element, + * and IE6/7 reject them outright. It is listed in .prettierignore for exactly that reason. */ var pageErrorHandlerCalls = 0 window.onerror = function (message, url, line) { @@ -158,14 +162,14 @@

RUM legacy build verification

} function showEnvironment() { var mode = - typeof document.documentMode === 'undefined' ? '(none - not a Trident engine)' : document.documentMode + typeof document.documentMode === 'undefined' ? '(none - documentMode needs IE8+, or not Trident at all)' : document.documentMode var pairs = [ ['document.documentMode', String(mode)], ['navigator.userAgent', navigator.userAgent], [ 'has fetch / Promise', - (typeof window.fetch === 'function') + ' / ' + (typeof window.Promise === 'function'), - ], + (typeof window.fetch === 'function') + ' / ' + (typeof window.Promise === 'function') + ] ] var box = document.getElementById('envBox') while (box.firstChild) { @@ -191,8 +195,16 @@

RUM legacy build verification

box.appendChild(table) } + function createXhr() { + if (typeof window.XMLHttpRequest !== 'undefined') { + return new XMLHttpRequest() + } + // IE6 has only the ActiveX flavour. + return new ActiveXObject('Microsoft.XMLHTTP') + } + function request(method, url, onDone) { - var xhr = new XMLHttpRequest() + var xhr = createXhr() xhr.open(method, url, true) xhr.onreadystatechange = function () { if (xhr.readyState === 4) { @@ -271,7 +283,7 @@

RUM legacy build verification

window.FC_RUM.init({ applicationId: '00000000-aaaa-0000-aaaa-000000000000', clientToken: 'verification_token', - proxy: '/rum-intake/', + proxy: '/rum-intake/' }) } catch (error) { initFailed = error && error.message ? error.message : String(error) @@ -337,7 +349,11 @@

RUM legacy build verification

function assertServerView(arrived) { if (arrived.length === 0) { record('The intake received a request', 'fail', 'nothing arrived within 15s') - note('Done, with failures.') + recordCleanliness() + note( + 'Done. Below the support floor this is the expected shape: collection rows red, ' + + 'and "No unexpected uncaught page errors" green means the page was left untouched.' + ) return } var first = arrived[0] @@ -371,6 +387,16 @@

RUM legacy build verification

indexOf(types, 'view') !== -1 && indexOf(types, 'error') !== -1 ? 'pass' : 'fail', summarize(types) + ' (across ' + arrived.length + ' request(s))' ) + recordCleanliness() + note( + 'Done. ' + + arrived.length + + ' request(s) reached the intake. ' + + 'Press "Check page exit" to also verify the synchronous request sent while the page unloads.' + ) + } + + function recordCleanliness() { var foreign = [] for (var e = 0; e < window.__pageErrors.length; e++) { if (window.__pageErrors[e].indexOf('verification uncaught error') === -1) { @@ -382,12 +408,6 @@

RUM legacy build verification

foreign.length === 0 ? 'pass' : 'fail', foreign.length === 0 ? 'none (the one deliberate test error is excluded)' : foreign.join(' | ') ) - note( - 'Done. ' + - arrived.length + - ' request(s) reached the intake. ' + - 'Press "Check page exit" to also verify the synchronous request sent while the page unloads.' - ) } function summarize(types) { From 4fbd24bcb06af9518c9ac74e4b415db2fe84fc20 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 07:02:42 -0700 Subject: [PATCH 20/32] fix(rum-legacy): write the session cookie the modern bundle can read The cookie was written percent-encoded. The modern bundle reads document.cookie without decoding it, and an encoded value fails its validation, so a session started here was discarded rather than shared with a page that loads the standard bundle. Cookies already issued stay readable: the read path keeps decoding. The spec meant to catch this decoded the value before handing it to the modern parser, so it validated a string that never exists in the browser and passed on a cookie the modern bundle rejects. Removing the decode makes it fail against the old implementation. An untracked session is no longer treated as invalid either. The modern bundle only mints an id once a session is tracked, so rum=0 with no id is what a sampled-out session looks like, and renewing on a missing id re-ran the sampling draw on a session that had already been sampled out. Entries this build does not understand now survive a renewal too. --- .../src/domain/sessionStore.spec.ts | 44 +++++++++++++- .../rum-legacy/src/domain/sessionStore.ts | 60 +++++++++++++++---- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index 45290e3e83..afcccce112 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -6,13 +6,17 @@ import { COOKIE_ACCESS_DELAY, SESSION_COOKIE_NAME, createSessionStore, deleteSes * The cookie written here is the same one the modern bundle reads, so its format is not ours to * choose. These specs validate what we write with the modern parser rather than with a * hand-written expectation. + * + * The raw cookie value is passed to that parser untouched. Decoding it first would be testing a + * string that never exists in the browser: the modern bundle reads document.cookie without + * decoding, so anything a decode step repairs here is broken in production. */ describe('session store', () => { const ONE_MINUTE = 60 * 1000 function readRawCookie(): string | undefined { const match = new RegExp(`(?:^|;)\\s*${SESSION_COOKIE_NAME}\\s*=\\s*([^;]+)`).exec(document.cookie) - return match ? decodeURIComponent(match[1]) : undefined + return match ? match[1] : undefined } // Cleared before rather than only after: a spec elsewhere may have left a session cookie behind, @@ -37,6 +41,44 @@ describe('session store', () => { expect(isValidSessionString(readRawCookie())).toBe(true) }) + it('leaves a session written by the modern bundle readable after touching it', () => { + // Written the way the modern bundle writes it: raw, undecorated. + const modernSessionId = '11111111-2222-4333-8444-555555555555' + document.cookie = `${SESSION_COOKIE_NAME}=id=${modernSessionId}&rum=2&created=${Date.now()}&expire=${ + Date.now() + ONE_MINUTE + };path=/` + + const session = createSessionStore(100).getOrCreateSession() + + expect(session.id).toBe(modernSessionId) + const rewritten = readRawCookie() + expect(isValidSessionString(rewritten)).toBe(true) + expect(toSessionState(rewritten).id).toBe(modernSessionId) + }) + + it('keeps a session the modern bundle sampled out, even at a full sample rate', () => { + // What the modern bundle writes for a session that lost the draw: a decision, no id. + document.cookie = `${SESSION_COOKIE_NAME}=rum=0&created=${Date.now()}&expire=${Date.now() + ONE_MINUTE};path=/` + + const session = createSessionStore(100).getOrCreateSession() + + expect(session.isTracked).toBe(false) + expect(toSessionState(readRawCookie()).rum).toBe('0') + }) + + it('carries entries it does not understand across a session renewal', () => { + const anonymousId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + // Expired: created and expire are both in the past. + document.cookie = `${SESSION_COOKIE_NAME}=id=old-session&rum=2&created=1&expire=2&aid=${anonymousId};path=/` + + createSessionStore(100).getOrCreateSession() + + // The modern parser exposes the `aid` cookie entry as anonymousId. + const state = toSessionState(readRawCookie()) + expect(state.anonymousId).toBe(anonymousId) + expect(state.id).not.toBe('old-session') + }) + it('writes the fields the modern bundle expects to find', () => { const session = createSessionStore(100).getOrCreateSession() diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index 637fc7f145..8d20bd7419 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -68,15 +68,23 @@ export function createSessionStore(sessionSampleRate: number) { let state = readSessionCookie() || inMemoryState - if (!state || !state.id || isExpired(state, now)) { - state = { - id: generateUUID(), - created: String(now), - // Decided once, when the session starts, and carried in the cookie from then on. Rolling - // it per event would send a fraction of the events of every session instead of all the - // events of a fraction of the sessions. - rum: Math.random() * 100 < sessionSampleRate ? TRACKED_WITHOUT_SESSION_REPLAY : NOT_TRACKED, - } + // An id-less cookie is not an invalid one. The modern bundle only mints an id once a session + // is tracked, so `rum=0` with no id is what a legitimately sampled-out session looks like. + // Renewing on a missing id would re-run the draw on a session that has already been sampled + // out, which is the one thing sessionSampleRate promises not to do. + if (!state || !state.rum || isExpired(state, now)) { + // Entries this build does not understand — the anonymous user id among them — belong to the + // user rather than to the session, and outlive the session they were found on. + state = extractForeignFields(state) + state.created = String(now) + // Decided once, when the session starts, and carried in the cookie from then on. Rolling + // it per event would send a fraction of the events of every session instead of all the + // events of a fraction of the sessions. + state.rum = Math.random() * 100 < sessionSampleRate ? TRACKED_WITHOUT_SESSION_REPLAY : NOT_TRACKED + } + + if (isTracked(state) && !state.id) { + state.id = generateUUID() } state.expire = String(now + SESSION_EXPIRATION_DELAY) @@ -94,11 +102,32 @@ function toSession(state: SessionState): LegacySession { // jar per domain, and IE enterprise site lists routinely put some urls of a site in compatibility // mode and others not. Reading a session the modern bundle started as untracked would silence // this one for the rest of that session's lifetime. - const trackingType = state.rum return { id: state.id!, - isTracked: trackingType === TRACKED_WITHOUT_SESSION_REPLAY || trackingType === TRACKED_WITH_SESSION_REPLAY, + isTracked: isTracked(state), + } +} + +function isTracked(state: SessionState): boolean { + return state.rum === TRACKED_WITHOUT_SESSION_REPLAY || state.rum === TRACKED_WITH_SESSION_REPLAY +} + +/** + * Keeps the entries of a state that this build does not own, dropping the session's own fields. + * Used when a session is renewed: the new session is a different session, but the user behind it + * is the same one. + */ +function extractForeignFields(state: SessionState | undefined): SessionState { + const kept: SessionState = {} + if (!state) { + return kept + } + for (const key in state) { + if (Object.prototype.hasOwnProperty.call(state, key) && KNOWN_FIELDS.indexOf(key) === -1 && state[key]) { + kept[key] = state[key] + } } + return kept } function isExpired(state: SessionState, now: number): boolean { @@ -145,7 +174,9 @@ function deserialize(value: string): SessionState | undefined { state[match[1]] = match[2] } } - return state.id ? state : undefined + // Returned even without an id: an untracked session has none, and discarding the state here + // would also discard the foreign entries stored alongside it. + return entries.length > 0 && (state.id || state.rum || state.expire) ? state : undefined } function readSessionCookie(): SessionState | undefined { @@ -162,7 +193,10 @@ function readSessionCookie(): SessionState | undefined { function writeSessionCookie(state: SessionState): void { const expires = new Date(dateNow() + SESSION_EXPIRATION_DELAY).toUTCString() - document.cookie = `${SESSION_COOKIE_NAME}=${encodeURIComponent(serialize(state))};expires=${expires};path=/;samesite=strict` + // Written raw, not percent-encoded: the modern bundle reads this cookie without decoding it, and + // an encoded value fails its validation, so the session would be dropped instead of shared. The + // serialized value is already constrained to characters that are legal in a cookie value. + document.cookie = `${SESSION_COOKIE_NAME}=${serialize(state)};expires=${expires};path=/;samesite=strict` } export function deleteSessionCookie(): void { From f96c5c729fb48ce93203f098cdd54a28918e2ac2 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 07:02:57 -0700 Subject: [PATCH 21/32] fix(rum-legacy): stop losing consent, exits and view dates Four behaviours diverged from the standard bundle in ways that lose or misreport data: init overwrote a tracking consent the page had already set. A consent management platform commonly answers before init runs, and the answer is the user's; the configuration only supplies a default for a page that has not answered. The page exit guard was never released, so a cancelled navigation left it set and the real exit that followed did nothing: everything recorded after the cancellation went with the page. It is now released by the next event, which only a page that is still recording produces. View events were dated when the update was assembled rather than when the view started, so the closing update appeared to have happened at the moment the page was dismissed. Navigation Timing is read off window rather than as a bare identifier. Where the property is absent entirely a bare reference throws instead of evaluating to undefined, and this runs inside the first view emitted during init. The degraded environment suite now deletes the global rather than defining it as undefined, which is the only form of the hazard that reproduces it. --- .../src/boot/degradedEnvironment.spec.ts | 17 ++++++ .../rum-legacy/src/boot/publicApi.spec.ts | 45 +++++++++++++++ packages/rum-legacy/src/boot/publicApi.ts | 55 +++++++++++++++---- .../rum-legacy/src/domain/eventAssembly.ts | 8 ++- packages/rum-legacy/src/domain/viewManager.ts | 14 ++++- 5 files changed, 124 insertions(+), 15 deletions(-) diff --git a/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts index b193164450..9f086e2c70 100644 --- a/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts +++ b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts @@ -24,6 +24,9 @@ import { makeRumLegacyPublicApi } from './publicApi' */ const REMOVED_GLOBALS = ['fetch', 'Promise', 'MutationObserver', 'PerformanceObserver', 'TextEncoder', 'URL'] as const +/** Absent entirely, not merely undefined — see `remove` below for why the difference matters. */ +const DELETED_GLOBALS = ['performance'] as const + /* * These globals are shared with every other spec in the suite, which all run in the same browser * context. Restoring them by plain assignment is not enough: `navigator.sendBeacon` lives on @@ -42,9 +45,23 @@ function withIE9Environment(operation: () => T): T { Object.defineProperty(host, name, { value: undefined, configurable: true, writable: true }) } + /* + * Deleted outright rather than defined as undefined. The two are not the same to a bare + * identifier reference: an own property holding undefined resolves quietly, while an absent one + * throws a ReferenceError. Engines that never shipped an API are the second case, so hiding it + * the first way would leave exactly the code this suite exists to catch passing. + */ + function remove(host: any, name: string) { + hidden.push({ host, name, descriptor: Object.getOwnPropertyDescriptor(host, name) }) + delete host[name] + } + for (const name of REMOVED_GLOBALS) { hide(window, name) } + for (const name of DELETED_GLOBALS) { + remove(window, name) + } hide(navigator, 'sendBeacon') try { diff --git a/packages/rum-legacy/src/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts index 2943ca63be..dcc1049bc4 100644 --- a/packages/rum-legacy/src/boot/publicApi.spec.ts +++ b/packages/rum-legacy/src/boot/publicApi.spec.ts @@ -183,6 +183,16 @@ describe('public api', () => { expect(payloads).toEqual([]) }) + it('does not let init overrule a consent decision the page already made', () => { + // The order a consent management platform commonly uses: answer first, init afterwards. + api.setTrackingConsent('not-granted') + api.init(VALID_CONFIGURATION) + api.addError(new Error('boom')) + flush() + + expect(payloads).toEqual([]) + }) + it('collects when consent is granted at init', () => { api.init({ ...VALID_CONFIGURATION, trackingConsent: 'granted' }) flush() @@ -411,6 +421,41 @@ describe('public api', () => { ;(freshApi as unknown as { _stop: () => void })._stop() }) + it('still reports the real exit after the user cancels a navigation', () => { + const handlers = captureExitHandlers() + const freshApi = makeRumLegacyPublicApi() + freshApi.init(VALID_CONFIGURATION) + + // The user starts to leave, then stays. Nothing was dismissed, and the page keeps recording. + handlers.beforeunload[0]() + freshApi.addAction('changed-my-mind') + payloads = [] + + handlers.beforeunload[0]() + + // Without releasing the guard, this second exit is a no-op and everything buffered since the + // cancelled navigation is lost with the page. + expect(eventsOfType('action').length).toBe(1) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + + it('dates every update of a view by when the view started', () => { + const handlers = captureExitHandlers() + const freshApi = makeRumLegacyPublicApi() + const openedAt = Date.now() + freshApi.init(VALID_CONFIGURATION) + const dismissedAt = openedAt + 5000 + jasmine.clock().mockDate(new Date(dismissedAt)) + + handlers.beforeunload[0]() + + const closing = eventsOfType('view').filter((event) => event.view.is_active === false)[0] + // Dated when the view opened, not when the page was dismissed five seconds later. + expect(closing.date).toBeLessThan(dismissedAt) + expect(closing.date).toBeGreaterThanOrEqual(openedAt) + ;(freshApi as unknown as { _stop: () => void })._stop() + }) + it('carries the time spent and the counts collected during the view into that update', () => { const handlers = captureExitHandlers() const freshApi = makeRumLegacyPublicApi() diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index 550d918e15..30800ca2f8 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -45,6 +45,10 @@ export function makeRumLegacyPublicApi() { // Collection only runs while this is exactly 'granted', matching the modern bundle. An // unrecognised value therefore withholds collection rather than silently enabling it. let trackingConsent: string = TRACKING_CONSENT_GRANTED + // A consent management platform commonly calls setTrackingConsent before init, and the answer it + // gives is the user's, not the page's. The configuration only supplies a default for a page that + // has not answered yet. + let trackingConsentSetExplicitly = false let globalContext: Context = {} let userContext: Context = {} let accountContext: Context = {} @@ -67,9 +71,22 @@ export function makeRumLegacyPublicApi() { }) const batch = startBatch(createHttpRequest(buildUrl)) + /* + * Declared here rather than next to the page exit listeners below, which is where they are + * used: the first view update is emitted while startViewManager is still running, so sendEvent + * runs before anything declared after it has been initialised. + */ + let exited = false + let exiting = false + function releaseExitGuard(): void { + if (!exiting) { + exited = false + } + } + // The view is passed in rather than read back from the view manager: the first view update is // emitted while startViewManager is still running, before the binding below exists. - function sendEvent(type: string, properties: Context, view: ViewContext, context?: Context): void { + function sendEvent(type: string, properties: Context, view: ViewContext, context?: Context, date?: number): void { const session = sessionStore.getOrCreateSession() if (!session.isTracked) { // Sampled out. The decision belongs to the session, so this holds for every event in it. @@ -80,14 +97,19 @@ export function makeRumLegacyPublicApi() { configuration: assemblyConfiguration, sessionId: session.id, view, + date, properties: withIdentityContexts(properties), context: context && !isEmptyObject(context) ? shallowMerge(globalContext, context) : globalContext, }) batch.add(event) + releaseExitGuard() } const viewManager = startViewManager((properties, view) => { - sendEvent('view', properties, view) + // A view event is dated by when the view started, not by when this update was assembled. The + // modern bundle does the same, so every update of one view shares a date and the closing + // update does not appear to have happened at the moment the page was dismissed. + sendEvent('view', properties, view, undefined, view.startTime) }) // Both uncaught and manually added errors arrive here, so the count and the event stay in one @@ -103,19 +125,29 @@ export function makeRumLegacyPublicApi() { * before the buffer is sent. A listener inside startBatch would always run first and flush an * empty buffer. * - * beforeunload and unload are the only signals available before IE10. The exit runs once: the - * synchronous request it makes blocks the browser, and doing that twice while a page is closing - * is worse than missing a second closing update on the rare cancelled navigation. + * beforeunload and unload are the only signals available before IE10, and both fire on the same + * dismissal. The guard below keeps that from sending the synchronous request twice while the + * page is closing, which would block the browser for a second time. + * + * It is released again by the next event, because a page that is still recording was not + * closing after all: the user cancelled the navigation. Leaving the guard set there would drop + * everything buffered from that point on, since the real exit would find it already true. + * Events produced by the exit flush itself do not release it — they are part of the sequence + * being guarded. */ - let exited = false function onPageExit(): void { if (exited) { return } exited = true - // Closing the view inside the exit flush keeps the whole sequence on the synchronous - // transport, including a buffer limit the closing update happens to cross. - batch.flushOnExit(() => viewManager.endView()) + exiting = true + try { + // Closing the view inside the exit flush keeps the whole sequence on the synchronous + // transport, including a buffer limit the closing update happens to cross. + batch.flushOnExit(() => viewManager.endView()) + } finally { + exiting = false + } } // Wrapped: the browser calls this one, so an internal failure here would become an uncaught @@ -192,7 +224,9 @@ export function makeRumLegacyPublicApi() { // Copied on the way in: pages commonly keep the object they passed, and this one is what a // later consent grant starts from. initConfiguration = shallowMerge(configuration, {}) as LegacyInitConfiguration - trackingConsent = configuration.trackingConsent ?? TRACKING_CONSENT_GRANTED + if (!trackingConsentSetExplicitly) { + trackingConsent = configuration.trackingConsent ?? TRACKING_CONSENT_GRANTED + } if (trackingConsent === TRACKING_CONSENT_GRANTED) { running = start(configuration) } @@ -290,6 +324,7 @@ export function makeRumLegacyPublicApi() { // Warned about rather than ignored: a typo would otherwise silently stop all collection. displayWarn(`Unknown tracking consent "${String(consent)}", treating it as not granted.`) } + trackingConsentSetExplicitly = true if (consent === trackingConsent) { return } diff --git a/packages/rum-legacy/src/domain/eventAssembly.ts b/packages/rum-legacy/src/domain/eventAssembly.ts index b2a3b3875a..9beb9bf865 100644 --- a/packages/rum-legacy/src/domain/eventAssembly.ts +++ b/packages/rum-legacy/src/domain/eventAssembly.ts @@ -12,6 +12,8 @@ export interface ViewContext { id: string url: string referrer: string + /** When the view started. Every update of a view carries the same value. */ + startTime: number } export interface AssembleOptions { @@ -19,6 +21,8 @@ export interface AssembleOptions { configuration: AssemblyConfiguration sessionId: string view: ViewContext + /** When the event happened. Defaults to now, which is wrong for a view: see below. */ + date?: number properties: { [key: string]: any } context?: { [key: string]: any } } @@ -31,11 +35,11 @@ export interface AssembleOptions { * `view` sub-object is merged rather than replaced. */ export function assembleEvent(options: AssembleOptions): object { - const { type, configuration, sessionId, view, properties, context } = options + const { type, configuration, sessionId, view, date, properties, context } = options const event: { [key: string]: any } = { type, - date: dateNow(), + date: date ?? dateNow(), source: 'browser', application: { id: configuration.applicationId, diff --git a/packages/rum-legacy/src/domain/viewManager.ts b/packages/rum-legacy/src/domain/viewManager.ts index 28f0456a1c..6c96e91f3b 100644 --- a/packages/rum-legacy/src/domain/viewManager.ts +++ b/packages/rum-legacy/src/domain/viewManager.ts @@ -77,7 +77,7 @@ export function startViewManager( view, _dd: { document_version: currentView.documentVersion }, }, - { id: currentView.id, url: currentView.url, referrer: currentView.referrer } + { id: currentView.id, url: currentView.url, referrer: currentView.referrer, startTime: currentView.startTime } ) } @@ -120,7 +120,12 @@ export function startViewManager( return { getCurrentView(): ViewContext { - return { id: currentView.id, url: currentView.url, referrer: currentView.referrer } + return { + id: currentView.id, + url: currentView.url, + referrer: currentView.referrer, + startTime: currentView.startTime, + } }, startView(name?: string): void { @@ -171,7 +176,10 @@ export function startViewManager( * left out rather than reported as 0. */ function addNavigationTimings(view: { [key: string]: any }): void { - const timing = performance && performance.timing + // Read off window rather than as a bare identifier: where the property does not exist at all, a + // bare reference throws a ReferenceError instead of evaluating to undefined, and this runs inside + // the first view emitted during init. + const timing = window.performance && window.performance.timing if (!timing || !timing.navigationStart) { return } From 4fc4154f20e5a0d66e6472ab02e3820bd30a0b58 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 07:02:57 -0700 Subject: [PATCH 22/32] fix(rum-legacy): make the documented loader route and parse correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader chose the standard bundle whenever Promise and fetch were present. Those are the two most commonly polyfilled APIs on the pages this build targets, and a polyfill supplies the API without supplying the syntax, so a polyfilled IE9 was handed a bundle it cannot parse and collected nothing. document.documentMode is checked first: only Trident defines it, it reports the mode the page is actually rendered in, and no polyfill sets it. The snippet also carried trailing commas, which an ES3 parser rejects outright — in a snippet whose whole job is to route browsers that parse that way. A build gate now parses every documented snippet as ES3, since a formatter reinserts the comma given the chance. The page load timings row is marked as uneven rather than supported: on the IE9 device used for verification none were reported, while the same code fills them in on a modern browser. --- packages/rum-legacy/README.md | 39 ++++++++++++++++++++------ scripts/check-es5-compatibility.js | 44 +++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index db0afebfcf..f8a164b45c 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -16,7 +16,7 @@ build exists to avoid. | Capability | Supported | Notes | | -------------------------- | :-------: | ------------------------------------------------ | | Uncaught JavaScript errors | ✅ | No stack; the script url and line are reported | -| Page load timings | ✅ | From `performance.timing` | +| Page load timings | ⚠️ | From `performance.timing`; see the note below | | Views | ✅ | Initial load, plus `hashchange` and manual views | | Manual actions and errors | ✅ | `addAction`, `addError` | | Session and user identity | ✅ | Same session cookie as the standard bundles | @@ -26,6 +26,12 @@ build exists to avoid. | Session replay | ❌ | No `MutationObserver` | | CSP violation reporting | ❌ | No `securitypolicyviolation` event | +Page load timings come from Navigation Timing, which the engines here support unevenly. On the IE9 +device used for verification none were reported: the view events arrived without them, while the +same code fills them in on a modern browser. They are read defensively and their absence costs +nothing else, so a view is still reported — but do not promise them for IE9 without measuring that +browser first. + Everything unsupported is a no-op method rather than a missing one. A page written against the standard bundle runs unchanged; it does not need to branch on the browser. @@ -38,25 +44,37 @@ names, which those engines cannot even parse and no runtime guard could catch. ## Setup Both builds share the `FC_RUM` global and the same call sequence, so the page carries one snippet. -The choice is made on capability, not on the user agent string, which means a browser running in a -compatibility document mode is classified by what it can actually do. +The snippet decides which build to load, and the decision cannot rest on `Promise` and `fetch` +alone. Those two are the most commonly polyfilled APIs on exactly these pages, and a polyfill +supplies the API without supplying the syntax: an engine that cannot parse an arrow function still +cannot parse one after `core-js` has loaded. A polyfilled IE9 would be handed the standard bundle +and collect nothing. + +So `document.documentMode` is checked first. It is defined only by Trident, it reports the mode the +page is actually rendered in rather than what the user agent string claims, and no polyfill sets +it — which also means an IE11 running a page in IE9 document mode is classified by what that mode +can really do. The capability check stays as the fallback for old engines that are not IE. + + ```html ``` +The snippet itself has to parse on every browser it is meant to route, which is why it carries no +trailing comma and no `//` comment inside the object literal: an ES3 parser rejects a trailing comma +outright, and the failure happens before any of the routing runs. A formatter will reinsert one +given the chance — the `prettier-ignore` above keeps ours out. + Calls made before the bundle arrives are queued on `q` and run once it loads. This is the same mechanism the standard bundles already use. `init` is stubbed on the placeholder for the same reason: a page that calls it outside `onReady`, before the script has landed, would otherwise hit diff --git a/scripts/check-es5-compatibility.js b/scripts/check-es5-compatibility.js index 8af6491a18..926be7acb5 100644 --- a/scripts/check-es5-compatibility.js +++ b/scripts/check-es5-compatibility.js @@ -97,6 +97,10 @@ function findEs3ReservedProperties(relativePath) { runMain(() => { const failures = [] + for (const failure of checkDocumentedSnippets()) { + failures.push(failure) + } + for (const relativePath of EXPECTED_ES5) { const found = findForbiddenApis(relativePath) if (found === undefined) { @@ -110,7 +114,9 @@ runMain(() => { const reserved = findEs3ReservedProperties(relativePath) if (reserved && reserved.length > 0) { - failures.push(`${relativePath}: uses ES3 reserved words as property names, which IE6/7 cannot parse: ${reserved.join(', ')}`) + failures.push( + `${relativePath}: uses ES3 reserved words as property names, which IE6/7 cannot parse: ${reserved.join(', ')}` + ) } else if (reserved) { printLog(`✅ ${relativePath} uses no ES3 reserved word as a property name`) } @@ -190,3 +196,39 @@ function findForbiddenApis(relativePath) { return found } + +/** + * The loader snippet in the README is copied into customer pages verbatim, and it has to parse on + * every browser it routes — including the ES3 engines that only ever receive a no-op. A trailing + * comma is the way this breaks: legal ES5, rejected outright by an ES3 parser, and reinserted by + * any formatter given the chance. The failure happens before the routing runs, so nothing inside + * the snippet can defend against it. + */ +function checkDocumentedSnippets() { + const relativePath = 'packages/rum-legacy/README.md' + const absolutePath = path.join(ROOT_DIR, relativePath) + if (!fs.existsSync(absolutePath)) { + return [`${relativePath}: not found`] + } + + const content = fs.readFileSync(absolutePath, 'utf-8') + const snippets = content.match(/