Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cf49e1b
feat(rum-legacy): add ES5 build target and compatibility gate
Fiona2016 Aug 15, 2026
4d3a115
feat(rum-legacy): send batched events over XMLHttpRequest
Fiona2016 Aug 15, 2026
e5f37ee
feat(rum-legacy): collect errors, page load timings and views
Fiona2016 Aug 15, 2026
8949a52
feat(rum-legacy): expose the full public API surface
Fiona2016 Aug 15, 2026
a7933ac
test(rum-legacy): verify behaviour without the modern browser APIs
Fiona2016 Aug 15, 2026
33c455e
refactor(rum-legacy): remove duplicated object and error helpers
Fiona2016 Aug 16, 2026
5999eee
fix(rum-legacy): send a closing view update when the page unloads
Fiona2016 Aug 16, 2026
e30b7af
fix(rum-legacy): apply sessionSampleRate and honour trackingConsent
Fiona2016 Aug 17, 2026
11ffdfe
fix(rum-legacy): guard browser callbacks and fix in-page referrer
Fiona2016 Aug 17, 2026
4fd8414
fix(rum-legacy): correct wall-clock durations, exit ordering and sess…
Fiona2016 Aug 17, 2026
721024c
fix(rum-legacy): recognise errors from other frames, stop leaking state
Fiona2016 Aug 17, 2026
e7b8786
fix(rum-legacy): copy context and configuration on the way in as well
Fiona2016 Aug 17, 2026
b7c78c0
fix(rum-legacy): reject a non-numeric sample rate, keep foreign cooki…
Fiona2016 Aug 17, 2026
8738d26
fix(rum-legacy): declare the content type the intake requires
Fiona2016 Aug 17, 2026
87a46da
feat(rum-legacy): add a real-browser verification harness
Fiona2016 Aug 18, 2026
2963991
fix(rum-legacy): make the verification run fit a metered device session
Fiona2016 Aug 18, 2026
31a73b8
feat(rum-legacy): guarantee silence on engines below the support floor
Fiona2016 Aug 18, 2026
2eaced7
fix(rum-legacy): harden the verification harness on real Trident engines
Fiona2016 Aug 18, 2026
ce49713
fix(rum-legacy): survive the formatter and the pre-XHR engines in the…
Fiona2016 Aug 18, 2026
4fbd24b
fix(rum-legacy): write the session cookie the modern bundle can read
Fiona2016 Aug 20, 2026
f96c5c7
fix(rum-legacy): stop losing consent, exits and view dates
Fiona2016 Aug 20, 2026
4fc4154
fix(rum-legacy): make the documented loader route and parse correctly
Fiona2016 Aug 20, 2026
4f4d58e
chore: keep the lockfile change to the dependency this adds
Fiona2016 Aug 20, 2026
e0c2b98
docs(rum-legacy): name the remaining divergences from the standard bu…
Fiona2016 Aug 20, 2026
bcdad97
test(rum-legacy): make the harness answer the page load timing question
Fiona2016 Aug 20, 2026
e4e5562
docs(rum-legacy): confirm page load timings on IE9
Fiona2016 Aug 20, 2026
4e82a20
docs(rum-legacy): state what the page exit guard actually promises
Fiona2016 Aug 21, 2026
5fd673a
fix(rum-legacy): keep stopSession and init to their public contracts
Fiona2016 Aug 21, 2026
d3f39d5
fix(rum-legacy): rename the current view instead of starting a new one
Fiona2016 Aug 21, 2026
03cde8c
fix(rum-legacy): hold the session cookie contract across both builds
Fiona2016 Aug 21, 2026
41d377b
fix(rum-legacy): keep the session cookie as long as the modern bundle…
Fiona2016 Aug 21, 2026
218c3fe
chore(rum-legacy): satisfy the no-unsafe-return rule in the cookie spec
Fiona2016 Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy-auto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/deploy-manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions eslint-local-rules/disallowSideEffects.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
239 changes: 239 additions & 0 deletions packages/rum-legacy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# 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 |

Page load timings come from Navigation Timing, which IE9 does provide — measured on a real IE9,
where all five arrive. It is read defensively all the same, since the engines below it do not have
it and reading an absent global as a bare identifier throws rather than yielding undefined. A view
is still reported where it is missing.

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 snippet decides which build to load, and the decision cannot rest on `Promise` and `fetch`
alone. Those two are the most commonly polyfilled APIs on exactly these pages, and a polyfill
supplies the API without supplying the syntax: an engine that cannot parse an arrow function still
cannot parse one after `core-js` has loaded. A polyfilled IE9 would be handed the standard bundle
and collect nothing.

So `document.documentMode` is checked first. It is defined only by Trident, it reports the mode the
page is actually rendered in rather than what the user agent string claims, and no polyfill sets
it — which also means an IE11 running a page in IE9 document mode is classified by what that mode
can really do. The capability check stays as the fallback for old engines that are not IE.

<!-- prettier-ignore -->
```html
<script>
;(function (w, d) {
var legacy = d.documentMode !== undefined ||
typeof w.Promise !== 'function' ||
typeof w.fetch !== 'function'
w.FC_RUM = w.FC_RUM || {
q: [],
onReady: function (c) {
this.q.push(c)
},
/* Stub so that calling init before the script has landed queues the call instead of throwing
"undefined is not a function" and taking the page down with it. */
init: function (o) {
this.q.push(function () {
w.FC_RUM.init(o)
})
}
}
var s = d.createElement('script')
s.async = true
s.src = legacy ? 'https://<static host>/fc-rum-legacy.js' : 'https://<static host>/flashcat-rum.js'
d.getElementsByTagName('head')[0].appendChild(s)
})(window, document)
</script>
<script>
window.FC_RUM.onReady(function () {
window.FC_RUM.init({
applicationId: '<application id>',
clientToken: '<client token>',
proxy: '/rum-intake/'
})
})
</script>
```

The snippet itself has to parse on every browser it is meant to route, which is why it carries no
trailing comma and no `//` comment inside the object literal: an ES3 parser rejects a trailing comma
outright, and the failure happens before any of the routing runs. A formatter will reinsert one
given the chance — the `prettier-ignore` above keeps ours out.

Calls made before the bundle arrives are queued on `q` and run once it loads. This is the same
mechanism the standard bundles already use. `init` is stubbed on the placeholder for the same
reason: a page that calls it outside `onReady`, before the script has landed, would otherwise hit
`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://<page origin>/rum-intake/?ddforward=<url-encoded /api/v2/rum?...>
```

An nginx rule forwarding it, for example:

```nginx
location /rum-intake/ {
proxy_pass https://<intake host>/;
}
```

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, a few details differ and are worth knowing before porting a
page:

- Calls made before `init`, or while consent is withheld, are dropped rather than replayed later.
The standard bundles keep them and send them once collection starts, so an error reported during
a consent dialog survives there and is lost here.
- A view outlives the session it started in. When a session ends — `stopSession()`, or the fifteen
minute idle expiry — the current view carries on under the new session id, where the standard
bundles start a new view. The view's closing update therefore lands only in the newer session.
- `startView()` takes the view name and ignores the rest. The standard bundles let a view carry its
own `service`, `version` and context; here those stay as they were configured for the page.
- A relative `proxy` is resolved against the document base url, which a `<base href>` tag changes.
The standard bundles resolve it against the page url instead, so a page that carries that tag has
to give `proxy` as an absolute url for both builds to reach the same place.

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. The behaviour of the actual engines was
verified separately, on real browsers through a cloud device farm (BrowserStack): IE 9, 10 and 11
pass every check in the verification page below, including the two that only mean anything on a
real Trident engine, and IE 6 and IE 8 were confirmed to degrade to a silent no-op that leaves the
hosting page untouched.

## 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.
27 changes: 27 additions & 0 deletions packages/rum-legacy/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading