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"