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/.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/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/README.md b/packages/rum-legacy/README.md
new file mode 100644
index 0000000000..db0afebfcf
--- /dev/null
+++ b/packages/rum-legacy/README.md
@@ -0,0 +1,209 @@
+# 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.
+
+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.
+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. `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
+
+`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:///;
+}
+```
+
+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
+
+| 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
+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.
+
+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
+
+The specs run in a modern headless browser. `src/boot/degradedEnvironment.spec.ts` removes `fetch`,
+`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
+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. 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/package.json b/packages/rum-legacy/package.json
new file mode 100644
index 0000000000..ff3cae9393
--- /dev/null
+++ b/packages/rum-legacy/package.json
@@ -0,0 +1,27 @@
+{
+ "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 && 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": {
+ "ajv": "8.17.1",
+ "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/scripts/verification-server.js b/packages/rum-legacy/scripts/verification-server.js
new file mode 100644
index 0000000000..efadfe98fa
--- /dev/null
+++ b/packages/rum-legacy/scripts/verification-server.js
@@ -0,0 +1,100 @@
+'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 || '/'
+ 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) => {
+ 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 (pathname === '/' || pathname === '/index.html') {
+ 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/src/boot/degradedEnvironment.spec.ts b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts
new file mode 100644
index 0000000000..b193164450
--- /dev/null
+++ b/packages/rum-legacy/src/boot/degradedEnvironment.spec.ts
@@ -0,0 +1,209 @@
+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 headers: Array<[string, 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 = []
+ headers = []
+ 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(name: string, value: string) {
+ headers.push([name, value])
+ },
+ }
+ 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 with the content type the intake requires', () => {
+ withIE9Environment(() => {
+ api = makeRumLegacyPublicApi()
+ api.init(VALID_CONFIGURATION)
+ api.addError(new Error('boom'))
+ })
+ jasmine.clock().tick(FLUSH_TIMEOUT)
+
+ 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', () => {
+ 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/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/boot/publicApi.spec.ts b/packages/rum-legacy/src/boot/publicApi.spec.ts
new file mode 100644
index 0000000000..2943ca63be
--- /dev/null
+++ b/packages/rum-legacy/src/boot/publicApi.spec.ts
@@ -0,0 +1,683 @@
+import type { BuildEnvWindow } from '../../../core/test'
+import { COOKIE_ACCESS_DELAY, deleteSessionCookie } from '../domain/sessionStore'
+import { BATCH_BYTES_LIMIT, FLUSH_TIMEOUT } from '../transport/batch'
+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'
+ deleteSessionCookie()
+ 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("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' })
+
+ 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)
+
+ 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('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 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()
+
+ expect(payloads).toEqual([])
+ })
+
+ it('does not throw when called with nothing at all', () => {
+ expect(() => anyApi().init()).not.toThrow()
+ })
+ })
+
+ 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)
+ })
+
+ 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("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'
+ 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'))
+ 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([])
+ })
+
+ /*
+ * 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('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()
+ 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 () {
+ 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', () => {
+ /**
+ * 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',
+ '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('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')
+ })
+
+ 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 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 = {}
+ 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..550d918e15
--- /dev/null
+++ b/packages/rum-legacy/src/boot/publicApi.ts
@@ -0,0 +1,374 @@
+import { assembleEvent } from '../domain/eventAssembly'
+import type { AssemblyConfiguration, ViewContext } from '../domain/eventAssembly'
+import { startErrorCollection } from '../domain/errorCollection'
+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'
+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
+ /** '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
+}
+
+type Context = { [key: string]: any }
+
+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 = {}
+
+ function start(configuration: LegacyInitConfiguration) {
+ const assemblyConfiguration: AssemblyConfiguration = {
+ applicationId: configuration.applicationId,
+ sessionSampleRate: configuration.sessionSampleRate ?? 100,
+ service: configuration.service,
+ version: configuration.version,
+ }
+
+ const sessionStore = createSessionStore(assemblyConfiguration.sessionSampleRate)
+ 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()
+ 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,
+ sessionId: session.id,
+ view,
+ properties: withIdentityContexts(properties),
+ context: context && !isEmptyObject(context) ? shallowMerge(globalContext, context) : globalContext,
+ })
+ 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)
+ })
+
+ /*
+ * 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
+ // 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
+ // error in the page rather than being contained.
+ const guardedOnPageExit = monitor(onPageExit)
+ const addEventListener = getZoneJsOriginalValue(window, 'addEventListener')
+ addEventListener.call(window, 'beforeunload', guardedOnPageExit)
+ addEventListener.call(window, 'unload', guardedOnPageExit)
+
+ return {
+ stop(flushPending: boolean) {
+ viewManager.stop()
+ errorCollection.stop()
+ if (flushPending) {
+ batch.flush()
+ }
+ batch.stop()
+ const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener')
+ removeEventListener.call(window, 'beforeunload', guardedOnPageExit)
+ removeEventListener.call(window, 'unload', guardedOnPageExit)
+ },
+ 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 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 (!isEmptyObject(accountContext) && accountContext.id !== undefined) {
+ identity.account = accountContext
+ }
+ return shallowMerge(properties, identity)
+ }
+
+ 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
+ }
+ // 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)
+ }
+ }),
+
+ /*
+ * 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)),
+
+ 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 ? shallowMerge(context, {}) : {}
+ }),
+ getGlobalContext: monitor(() => shallowMerge(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 ? shallowMerge(user, {}) : {}
+ }),
+ getUser: monitor(() => shallowMerge(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 ? shallowMerge(account, {}) : {}
+ }),
+ getAccount: monitor(() => shallowMerge(accountContext, {})),
+ setAccountProperty: monitor((key: string, value: any) => {
+ accountContext[key] = value
+ }),
+ removeAccountProperty: monitor((key: string) => {
+ delete accountContext[key]
+ }),
+ clearAccount: monitor(() => {
+ accountContext = {}
+ }),
+
+ stopSession: monitor(() => {
+ running?.stop(true)
+ 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((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(() => ({})),
+ 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.
+ 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
+}
+
+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')
+ 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
+ }
+ // 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
+ }
+ return true
+}
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..d09cba0e04
--- /dev/null
+++ b/packages/rum-legacy/src/domain/errorCollection.spec.ts
@@ -0,0 +1,151 @@
+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('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()
+
+ 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..b58cfa9954
--- /dev/null
+++ b/packages/rum-legacy/src/domain/errorCollection.ts
@@ -0,0 +1,122 @@
+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, context?: { [key: string]: any }) => 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, context?: { [key: string]: any }): void {
+ onError(computeManualError(value), context)
+ },
+
+ 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 (isError(error)) {
+ fillFromError(collected, error)
+ 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 (isError(value)) {
+ fillFromError(collected, value)
+ } else {
+ collected.message = String(value)
+ }
+
+ 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
+ if (error.name) {
+ collected.type = error.name
+ }
+ if (error.stack) {
+ collected.stack = error.stack
+ }
+}
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..b2a3b3875a
--- /dev/null
+++ b/packages/rum-legacy/src/domain/eventAssembly.ts
@@ -0,0 +1,86 @@
+import { isEmptyObject, shallowMerge } from '../tools/objectUtils'
+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 isPlainObject(value: unknown): value is { [key: string]: any } {
+ return typeof value === 'object' && value !== null && !(value instanceof Array)
+}
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..45290e3e83
--- /dev/null
+++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts
@@ -0,0 +1,259 @@
+import { isValidSessionString } from '../../../core/src/domain/session/sessionStateValidation'
+import { toSessionState } from '../../../core/src/domain/session/sessionState'
+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
+ * 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
+ }
+
+ // 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(100).getOrCreateSession()
+
+ expect(session.id).toMatch(/^[0-9a-f-]{36}$/)
+ })
+
+ it('writes a cookie the modern bundle considers valid', () => {
+ createSessionStore(100).getOrCreateSession()
+
+ expect(isValidSessionString(readRawCookie())).toBe(true)
+ })
+
+ it('writes the fields the modern bundle expects to find', () => {
+ const session = createSessionStore(100).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(100)
+
+ expect(store.getOrCreateSession().id).toBe(store.getOrCreateSession().id)
+ })
+
+ it('reuses a session written by a previous page load', () => {
+ const first = createSessionStore(100).getOrCreateSession()
+
+ expect(createSessionStore(100).getOrCreateSession().id).toBe(first.id)
+ })
+
+ it('pushes the expiration forward on activity', () => {
+ const store = createSessionStore(100)
+ 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(100).getOrCreateSession()
+
+ jasmine.clock().install()
+ jasmine.clock().mockDate(new Date(Date.now() + 16 * ONE_MINUTE))
+ 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(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(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('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)
+
+ 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('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)
+
+ expect(store.getOrCreateSession().id).toBe(store.getOrCreateSession().id)
+ })
+ })
+
+ 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`
+ )};path=/`
+
+ 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(session.isTracked).toBe(true)
+ })
+
+ 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 })
+
+ const session = createSessionStore(100).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..637fc7f145
--- /dev/null
+++ b/packages/rum-legacy/src/domain/sessionStore.ts
@@ -0,0 +1,170 @@
+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
+
+/** 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'
+
+/**
+ * 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 {
+ id?: string
+ 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) {
+ // 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()
+
+ // 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)
+ }
+
+ 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,
+ }
+ }
+
+ state.expire = String(now + SESSION_EXPIRATION_DELAY)
+ inMemoryState = state
+ lastCookieAccess = now
+ writeSessionCookie(state)
+
+ return toSession(state)
+ },
+ }
+}
+
+function toSession(state: SessionState): LegacySession {
+ // 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 {
+ 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.
+ */
+const KNOWN_FIELDS = ['id', 'created', 'expire', 'rum']
+
+function serialize(state: SessionState): string {
+ const entries: string[] = []
+
+ for (let i = 0; i < KNOWN_FIELDS.length; i++) {
+ const value = state[KNOWN_FIELDS[i]]
+ if (value) {
+ entries.push(`${KNOWN_FIELDS[i]}=${value}`)
+ }
+ }
+
+ // 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('&')
+}
+
+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]] = 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/domain/viewManager.spec.ts b/packages/rum-legacy/src/domain/viewManager.spec.ts
new file mode 100644
index 0000000000..7a22411f6f
--- /dev/null
+++ b/packages/rum-legacy/src/domain/viewManager.spec.ts
@@ -0,0 +1,242 @@
+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.endView()
+
+ 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.endView()
+ manager.endView()
+
+ 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.endView()
+ 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('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
+
+ 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 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()
+
+ 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('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
+ 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..28f0456a1c
--- /dev/null
+++ b/packages/rum-legacy/src/domain/viewManager.ts
@@ -0,0 +1,208 @@
+import { monitor } from '../tools/monitor'
+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(
+ // 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')
+
+ let currentView = createView(INITIAL_LOAD)
+ let stopped = false
+
+ function createView(loadingType: string, name?: string, previousViewUrl?: string): CurrentView {
+ return {
+ id: generateUUID(),
+ url: location.href,
+ // 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(),
+ 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(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.
+ 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 },
+ },
+ { id: currentView.id, url: currentView.url, referrer: currentView.referrer }
+ )
+ }
+
+ function endCurrentView(): void {
+ emit(false)
+ }
+
+ function startNewView(loadingType: string, name?: string): void {
+ const previousViewUrl = currentView.url
+ endCurrentView()
+ currentView = createView(loadingType, name, previousViewUrl)
+ 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)
+ }
+ }
+
+ // 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', guardedOnHashChange)
+ if (!isDocumentLoaded()) {
+ addEventListener.call(window, 'load', guardedOnLoad)
+ }
+
+ 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++
+ },
+
+ /**
+ * 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) {
+ endCurrentView()
+ }
+ },
+
+ stop(): void {
+ if (stopped) {
+ return
+ }
+ endCurrentView()
+ stopped = true
+ const removeEventListener = getZoneJsOriginalValue(window, 'removeEventListener')
+ removeEventListener.call(window, 'hashchange', guardedOnHashChange)
+ removeEventListener.call(window, 'load', guardedOnLoad)
+ },
+ }
+}
+
+/*
+ * 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)
+}
+
+/**
+ * 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/entries/main.ts b/packages/rum-legacy/src/entries/main.ts
new file mode 100644
index 0000000000..4243d1005c
--- /dev/null
+++ b/packages/rum-legacy/src/entries/main.ts
@@ -0,0 +1,26 @@
+import { defineGlobal } from '../boot/global'
+import { makeRumLegacyPublicApi } from '../boot/publicApi'
+
+interface BrowserWindow extends Window {
+ FC_RUM?: unknown
+}
+
+/*
+ * 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.
+}
+
+export const flashcatRumLegacy = api
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/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
+ }
+ }
+}
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
+}
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..e62308e319
--- /dev/null
+++ b/packages/rum-legacy/src/transport/batch.spec.ts
@@ -0,0 +1,169 @@
+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()
+ })
+
+ it('uses the exit transport when asked to flush on exit', () => {
+ const batch = startBatch(request)
+
+ batch.add({ type: 'view' })
+ batch.flushOnExit()
+
+ expect(request.exitPayloads).toEqual(['{"type":"view"}'])
+ expect(request.sentPayloads).toEqual([])
+ batch.stop()
+ })
+
+ 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)
+
+ const registered = addEventListenerSpy.calls.allArgs().map(([eventName]) => eventName)
+ expect(registered).not.toContain('beforeunload')
+ expect(registered).not.toContain('unload')
+ batch.stop()
+ })
+
+ it('sends nothing on exit when the buffer is empty', () => {
+ const batch = startBatch(request)
+
+ batch.flushOnExit()
+
+ expect(request.exitPayloads).toEqual([])
+ batch.stop()
+ })
+
+ it('stops buffering and stops flushing once stopped', () => {
+ const batch = startBatch(request)
+ batch.stop()
+
+ batch.add({ type: 'view' })
+ jasmine.clock().tick(FLUSH_TIMEOUT * 2)
+ 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
new file mode 100644
index 0000000000..2f02d755c0
--- /dev/null
+++ b/packages/rum-legacy/src/transport/batch.ts
@@ -0,0 +1,172 @@
+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
+ /**
+ * 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
+}
+
+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 {
+ cancelScheduledFlush()
+
+ if (messages.length === 0) {
+ return
+ }
+
+ const payload = messages.join('\n')
+ messages = []
+ bytesCount = 0
+
+ if (useExitTransport || exiting) {
+ 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
+ }
+ }
+
+ // 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) {
+ 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()
+ }
+ },
+
+ 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
+ }
+ },
+
+ stop() {
+ stopped = true
+ cancelScheduledFlush()
+ },
+ }
+}
+
+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.
+ */
+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..bf7432c36d
--- /dev/null
+++ b/packages/rum-legacy/src/transport/httpRequest.spec.ts
@@ -0,0 +1,182 @@
+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('declares the content type the intake requires', () => {
+ createHttpRequest(buildUrl).send('{}')
+
+ // 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', () => {
+ 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..de364bee2c
--- /dev/null
+++ b/packages/rum-legacy/src/transport/httpRequest.ts
@@ -0,0 +1,58 @@
+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.
+ *
+ * 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 {
+ // 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)
+ xhr.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8')
+
+ 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..9e5f42f979
--- /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.
+ */
+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)
+ })
+}
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/verification/index.html b/packages/rum-legacy/verification/index.html
new file mode 100644
index 0000000000..e2b946e79a
--- /dev/null
+++ b/packages/rum-legacy/verification/index.html
@@ -0,0 +1,485 @@
+
+
+
+
+ RUM legacy build verification
+
+
+
+
RUM legacy build verification
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..8af6491a18
--- /dev/null
+++ b/scripts/check-es5-compatibility.js
@@ -0,0 +1,192 @@
+'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']
+
+/**
+ * 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']
+
+/**
+ * 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 = []
+
+ 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`)
+ }
+
+ 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) {
+ 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
+}
+
+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
+}
diff --git a/scripts/check-legacy-bundle-runtime.js b/scripts/check-legacy-bundle-runtime.js
new file mode 100644
index 0000000000..88f0db47c3
--- /dev/null
+++ b/scripts/check-legacy-bundle-runtime.js
@@ -0,0 +1,210 @@
+'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')
+ }
+ // 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']) {
+ 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, 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)
+ 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
+}
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/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')) {
diff --git a/yarn.lock b/yarn.lock
index 807d1134be..5eb84ca434 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,16 @@ __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:
+ ajv: "npm:8.17.1"
+ 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 +682,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 +2256,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 +2681,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 +3348,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 +3469,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 +3564,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 +4163,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 +4390,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 +4484,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 +4740,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 +5562,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 +5862,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 +6044,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 +6357,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 +6680,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 +8887,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 +9188,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 +11207,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 +12462,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"