Skip to content

feat(rum-legacy): ES5 build for browsers without ES2015 support - #22

Open
Fiona2016 wants to merge 16 commits into
mainfrom
feat/rum-legacy-es5
Open

feat(rum-legacy): ES5 build for browsers without ES2015 support#22
Fiona2016 wants to merge 16 commits into
mainfrom
feat/rum-legacy-es5

Conversation

@Fiona2016

@Fiona2016 Fiona2016 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

What

Adds packages/rum-legacy, a separate CDN-only 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 inside the SDK can help. This build is compiled to ES5 and sends over XMLHttpRequest.

It is self-contained: it does not import @flashcatcloud/browser-core or browser-rum-core, which are authored against ES2018. Nothing in the existing packages changes.

Capabilities

Uncaught errors, page load timings from performance.timing, views (initial load, hashchange, manual), manual actions and errors, and session/user identity.

Resource timings, automatic user actions, Web Vitals, long tasks, session replay and CSP reporting are not available: the underlying platform APIs do not exist on these browsers. Those methods are present as no-ops rather than absent, because a missing method throws undefined is not a function and takes the host page down, which is the failure this build exists to prevent. A page written against the standard bundle runs unchanged.

Design notes

One reverse proxy rule serves both builds. The intake URL is built to match the standard bundles byte for byte, so no compatibility branch is needed on the intake. Two details carry that and are easy to get wrong: the real intake path travels inside the ddforward query parameter rather than being appended to the proxy path, and a relative proxy value is resolved to an absolute URL first. The specs build a reference URL with the standard implementation and compare against it, so a change on either side fails loudly instead of drifting.

proxy is required here, unlike in the standard bundles. These browsers cannot make a cross-origin XMLHttpRequest carrying the parameters the intake needs, so init reports the problem and collects nothing rather than sending requests that would be blocked.

The content type is declared explicitly. The intake rejects a body that is not text/plain. The standard bundles never declare it because fetch and sendBeacon set it implicitly for a string body, but XMLHttpRequest on these browsers cannot be relied on to do the same, so without it every request would be refused. 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.

Completion is detected through onreadystatechange. onload arrived in IE10, so a transport built on it looks correct in a modern test browser and never completes where this package runs. The spec's fake XMLHttpRequest fires only onreadystatechange to keep that honest. The exit path sends synchronously, as there is no sendBeacon.

sessionSampleRate is decided once per session and carried in the session cookie, so a session is either collected whole or not at all rather than losing a fraction of the events of every session.

trackingConsent is honoured. Collection runs only while consent is exactly granted, matching the standard bundles, where an unrecognised value counts as not granted. Withdrawing consent drops whatever is buffered rather than sending it, and clears the session cookie.

Session identity reuses the existing cookie, serialisation and expiration rules. The existing parser rejects uppercase characters, so the generated uuid stays lowercase or every page load would silently start a new session. A session the standard bundles started is honoured as tracked whether or not replay was sampled: 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.

Durations come from the wall clock, since these browsers have no monotonic performance.now(). A clock correction can move time backwards mid-view, so elapsed time is floored at zero rather than reported negative, and the session throttle treats a backwards jump as an elapsed window rather than freezing.

Payload size is measured with a UTF-8 byte count rather than string length, which would undercount non-latin content threefold and let batches grow past the intake limit.

Timers and listener registration go through the unpatched originals when Zone.js is present, whose patched versions have been observed to cause memory leaks and high CPU usage in host pages.

Session cookie access is throttled to one second, as the standard bundles throttle it. The session is looked up for every event, and reading and writing document.cookie is a full string parse each time.

Both entry points are guarded: public methods and the handlers the browser calls back into. A failure inside a listener would otherwise become an uncaught error on the page, which is the outcome this package exists to avoid.

The page exit is one atomic sequence. The closing view update is produced inside the exit flush, so a buffer limit it happens to cross cannot start an asynchronous request that the closing page would never complete.

Time is read via a local dateNow() rather than Date.now(), which some sites wrongly polyfill to return a Date instance. Pages still running these browsers are the most likely to carry such a dependency.

Guardrails

packages/rum-legacy/tsconfig.json deliberately does not extend the base config. lib is restricted to ES5 + DOM, which turns using an unavailable API into a compile error rather than a runtime crash, and paths is emptied so @flashcatcloud/* imports do not resolve.

scripts/check-es5-compatibility.js runs as part of the bundle build and in the deploy workflows. It parses the output as ES5, scans it for runtime APIs these browsers lack, and asserts that the standard bundles are rejected — if a misconfiguration made the parser accept everything, the positive assertion alone would still pass and the gate would silently stop protecting anything.

Testing

165 specs. src/boot/degradedEnvironment.spec.ts removes fetch, Promise, the observers, TextEncoder, URL and sendBeacon, then drives the package end to end. Event shape is validated against the shared rum-events-format schemas rather than hand-written expectations.

The full unit suite is unchanged from main — the same set of failing specs before and after, all pre-existing.

A second check executes the emitted bundle in an environment with no fetch, no Promise, no sendBeacon and an XMLHttpRequest that only fires onreadystatechange, then asserts what lands on the wire. The specs run against TypeScript compiled by the test runner; Terser and the webpack runtime sit between that and the shipped file. It caught a real defect on its first run: errors were recognised with a bare instanceof Error, so an error created in another frame was stringified and lost its message, type and stack — and frameset-heavy applications are the norm on these browsers.

Guarantees that are easy to assert vacuously were 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, the listener guards, and the intake path carried inside ddforward.

Not covered

This has not been verified on real hardware. The specs cover missing runtime APIs and unsupported syntax; they do not cover the behaviour of an old browser engine. That verification is a separate step before any support commitment.

The package is not published to npm, and the public compatibility documentation is unchanged.

Introduce packages/rum-legacy, a CDN-only bundle for browsers without
ES2015 support. This commit sets up the toolchain only; collection and
transport follow.

The package does not extend tsconfig.base.json on purpose. Restricting
"lib" to ES5 + DOM turns a missing runtime API into a compile error
rather than a crash on the target browsers, and an empty "paths" map
keeps @flashcatcloud/* imports unresolvable, since those packages are
authored against ES2018.

check-es5-compatibility.js parses the emitted bundle with acorn at
ecmaVersion 5. It also asserts that the modern bundles are rejected: if
a misconfiguration made the parser accept everything, the positive
assertion alone would still pass and the gate would silently stop
protecting anything.

Console access is looked up lazily instead of captured at module
evaluation, because in IE9 window.console does not exist until the
developer tools are opened, and its methods are host objects without
bind().
Transport for browsers that have neither fetch nor sendBeacon.

The intake url is built to match the modern bundle byte for byte, so a
single reverse proxy rule on the customer domain serves both builds and
the intake needs no compatibility branch. Two details carry that
property and are easy to get wrong: the real intake path travels inside
the ddforward query parameter rather than being appended to the proxy
path, and a relative proxy value is resolved to an absolute url first.
The specs build a reference url with the modern implementation and
compare against it, so a change on either side fails loudly instead of
drifting.

Completion is detected through onreadystatechange. onload arrived in
IE10, so a transport built on it would look correct in a modern test
browser and never complete on the browsers this package exists for. The
spec's fake XMLHttpRequest fires only onreadystatechange to keep that
honest. The exit path sends synchronously because there is no
sendBeacon to hand the payload to.

Batch limits match the modern bundle. Payload size is measured with a
UTF-8 byte count rather than string length, which would undercount
non-latin content threefold and let batches grow past the intake limit.

Session identity reuses the modern cookie name, serialisation and
expiration rules. The modern parser rejects uppercase characters, so the
generated uuid has to stay lowercase or every page load would silently
start a new session.

Timers and listener registration go through the unpatched originals when
Zone.js is present, whose patched versions have been observed to cause
memory leaks and high CPU usage in host pages.

Time is read via a local dateNow() rather than Date.now(), which some
sites wrongly polyfill to return a Date instance. Pages still running
these browsers are the most likely to carry such a dependency.
Adds the event assembly and the collection this build can actually
support: uncaught errors, page load timings, view lifecycle and manual
actions.

Event shape is validated in the specs against the shared
rum-events-format schemas rather than against hand-written
expectations, since the intake owns that format. Durations are
nanoseconds, so page load timings derived from performance.timing are
converted rather than passed through as milliseconds.

The zero-valued resource and long task counts are emitted rather than
omitted. Those signals cannot be observed on these browsers, and
leaving the fields out would read downstream as missing data instead of
a real zero. Timings the browser has not reached are the opposite case:
performance.timing reports them as 0, which would be a false
measurement, so they are left out.

window.onerror preserves and still calls whatever handler the page had
installed, and passes its return value back so the page can keep
suppressing the browser's default logging. Replacing it outright would
silently disable the customer's own error reporting. Without an error
object there is no stack, so the script url and line are folded into a
single synthetic frame, which is what makes the error locatable at all.

Route changes are tracked through hashchange only, as there is no
History API to hook into here.
Wires collection, assembly, batching and transport behind the same
FC_RUM surface the modern bundle exposes.

Methods that cannot be supported here are no-ops rather than absent.
There is no PerformanceObserver for vitals, no MutationObserver for
session replay and no way to observe resource timings, but a missing
method throws "undefined is not a function" and takes the host page
down, which is the failure this package exists to prevent. A page
written against the modern bundle therefore runs unchanged.

Every public method is wrapped so an internal failure cannot surface as
an exception in the page. onReady is deliberately left unwrapped: it
invokes the caller's own callback, and swallowing there would hide the
customer's exceptions rather than ours.

The view context is passed to the view update callback rather than read
back from the manager. The first update is emitted while the manager is
still being constructed, so reading it back threw and, being caught by
the safety net, silently produced no events at all.

Uncaught and manually added errors share one path, so an error is
counted and reported exactly once.

The bundle size grows from 505 bytes to 39 KiB of sources, still
parsing as ES5.
Adds a fixture that removes fetch, Promise, sendBeacon, the observers,
TextEncoder and the URL constructor, then drives the package end to end
through an XMLHttpRequest offering only onreadystatechange. Without it
every spec runs in a browser that has all of those, so a dependency on
one would pass the suite and fail only where this package is meant to
run.

The ES2015 collections are deliberately left in place. lib: ES5 already
makes using them a compile error, a stronger guarantee than a runtime
spec, and the bundle scan covers the emitted output. Removing them here
broke the suite's own instrumentation instead: the shared leak detector
wraps addEventListener in a function that constructs a Map, so the first
listener this package registered failed inside the harness rather than
inside the code under test.

Globals are restored by putting back the captured property descriptor,
and a shadow over an inherited property is deleted rather than
overwritten. Restoring navigator.sendBeacon by assignment left it as an
own property of the instance rather than a method on Navigator.prototype,
which changed its shape for every later spec in the same browser context
and failed 41 of them across other packages.

check-es5-compatibility.js now also scans the bundle for runtime APIs
the target browsers lack. Parsing as ES5 says nothing about those: a
bundle full of Promise and fetch parses perfectly well and then fails on
the first line that runs.

Adds a package README covering setup, the required same-origin proxy,
the capability matrix, and an explicit statement that this has not been
verified on real hardware.
The merge and empty-check loops existed twice, byte for byte, in event
assembly and in the public api, because Object.assign and the spread
operator both need ES2015 and lib: ES5 rejects them. They move to
tools/objectUtils.ts.

The block reading a message, name and stack off an Error instance also
existed twice in error collection, once for uncaught errors and once
for manually added ones.

No behaviour change.
The view event carrying the time spent and the error and action counts
was only sent when the session was stopped explicitly. On a normal page
close nothing closed the view, so every view reached the intake with the
counts and duration it had at page load, which are zero. Error events
themselves were unaffected; the view level aggregates were not.

Emitting it was not enough on its own. The batch registered its own exit
listener when it was created, before the view manager existed, so it
always ran first and flushed an empty buffer before the closing update
could be added to it. Page exit is now owned in one place, which closes
the view and then flushes, and the batch no longer listens for it.

The exit path closes the view without shutting collection down.
beforeunload can fire for a navigation the user then cancels, and
tearing down there would leave the page with a dead SDK. It also runs
once per page: the request it makes is synchronous, and blocking a
closing browser twice is worse than missing a second closing update on
a cancelled navigation.

viewManager.flush() is replaced by endView(). It had no caller outside
its own specs.
Both options were accepted, validated and then ignored.

sessionSampleRate only reached _dd.configuration.session_sample_rate.
Every session was collected in full while each event claimed to have
been sampled at the configured rate, so the volume was wrong and the
reported rate described something that never happened. The decision is
now made once when a session starts and carried in the session cookie's
rum field, using the same values as the standard bundles, so a session
is either collected whole or not at all rather than losing a fraction
of each one.

trackingConsent was a no-op, which is worse for a consent control than
not offering it: a page could set 'not-granted' and still be collected
from. Collection now runs only while consent is exactly 'granted',
matching the standard bundles, where an unrecognised value counts as not
granted. Withdrawing consent drops whatever is buffered instead of
sending it and clears the session cookie.

Session cookie access is throttled to one second, as the standard
bundles throttle it. The session is looked up for every event, and
reading and writing document.cookie is a full string parse each time,
which is a cost worth avoiding on the browsers this package targets.
Public methods were wrapped so an internal failure could not surface in
the host page, but the handlers the browser calls back into were not.
A failure inside the hashchange, load or page exit listener became an
uncaught error on the page, which is the outcome this package exists to
avoid. The wrapper moves to tools/monitor.ts and now covers both entry
points.

Removing the wrapper failed no test before this change, so the guard was
untested rather than merely missing; the specs added here fail without
it. Making them fail for the right reason also required advancing past
the new session cookie throttling window, since a page that exits within
a second of init never touches the cookie and never reaches the
injected failure.

Views started in-page reported document.referrer, which describes how
the document was reached rather than how the view was, attributing every
in-page navigation to whatever site linked to the page. They now report
the previous view's url, as the standard bundles do, and only the first
view of a document falls back to document.referrer.

The loader snippet stubs init so that calling it outside onReady, before
the script has landed, queues the call instead of throwing "undefined is
not a function". The README also records where this build's stopSession
and setViewName deliberately differ from the standard bundles.

Session cookies are cleared before each spec as well as after: a spec
elsewhere may leave one behind, and a stale session would be reused
instead of a fresh one being created.
…ion reuse

Five more review passes, over clock behaviour, ordering, hostile input,
release plumbing and drift between the docs and the code.

Durations come from the wall clock, because these browsers have no
monotonic performance.now(). A backwards clock correction made
time_spent negative, which is not a measurement but a broken one, and
made the session throttle read the negative elapsed time as "still
inside the window", freezing the session until the clock caught up.
Elapsed time is now floored at zero and the throttle treats a backwards
jump as an elapsed window.

The page exit produced the closing view update and then flushed. If that
update crossed a buffer limit it started an asynchronous request, which a
closing page never completes. The update is now produced inside the exit
flush, so the whole sequence stays on the synchronous transport.

A session started by the standard bundles with session replay sampled is
stored as '1' rather than '2'. Reading only '2' as tracked meant such a
session was treated as sampled out and silenced for its whole lifetime.
Both builds share one cookie jar per domain, and IE enterprise site
lists routinely put some urls of a site in compatibility mode and others
not, so this is reachable rather than theoretical.

Hostile input was probed rather than assumed: a crafted session cookie,
a polluted Object prototype and a malformed cookie value are all
contained already, and now have specs saying so.

The bundle whose whole purpose is being small was missing from the size
report. It is 4 KiB gzipped.

The README claimed the degraded environment specs remove Map, Set and
Symbol. They deliberately do not, and overstating the coverage is worse
than describing it narrowly.
Four more review passes, over the emitted artifact, the module surface,
the changes outside this package, and the public API semantics.

The suite never executed the file customers actually load. Every spec
runs against TypeScript compiled by the test runner, and between that
and the shipped bundle sit Terser and the webpack runtime. A new check
executes the emitted file in an environment with no fetch, no Promise,
no sendBeacon and an XMLHttpRequest that only fires onreadystatechange,
then asserts what lands on the wire, including the intake path and
parameters carried inside ddforward.

It found a real defect on its first run. Errors were recognised with a
bare `instanceof Error`, which compares against the current frame's
constructor, so an error created in another frame was treated as a plain
value and stringified, losing its message, type and stack. Frameset and
iframe heavy applications are the norm on these browsers. The standard
bundles allow for this and now so does this one, verified with a real
iframe rather than a simulation.

The getters handed out the objects the SDK keeps rather than copies. The
stored configuration is what a later consent grant starts from, and the
contexts are attached to every event, so a caller could change SDK
behaviour by mutating what it read.

computeBytesCount and normalizeUrl were exported without a consumer,
which reads as part of the module's contract when they are internal.

The root build, the deploy path's package list and the workflow's ES5
step were run end to end rather than assumed.
The previous pass stopped the getters handing out the objects the SDK
keeps, but left the other half: setGlobalContext, setUser, setAccount
and init all stored the caller's object by reference.

Pages commonly keep the object they passed. An unrelated later mutation
of it silently changed what every subsequent event carried, and for the
configuration it changed what a later consent grant would start from.
Fixing only the read side left the same defect reachable from the write
side, which is worse than not having noticed it, because the specs
looked like the problem was covered.

Data is now copied at both boundaries.
…e fields

The sample rate range was checked by negating it. NaN fails every
comparison, so a rate computed from a string and landing on NaN passed
validation, and then failed the sampling comparison too: the SDK looked
configured and silently reported nothing, which is the worst way for a
monitoring build to go wrong. The range is now checked positively, as
the standard bundles check it.

The session cookie is shared with the standard bundles, which keep their
own entries in it. The anonymous user id is one of them and is tracked
by default. Rewriting the cookie with only the four fields this build
understands destroyed it, so a visit through a page served in
compatibility mode reset anonymous user continuity for every other page
of the same site. Entries this build does not understand are now written
back untouched; they still cannot reach the session identity or the
tracking decision, which are read from named fields only.
Verified the "no backend change" claim against the intake itself rather
than against the standard bundles' url shape, and found the transport
would have been refused outright.

The intake rejects any body whose content type is not text/plain. This
build deliberately set no request header at all, on the reasoning that
it kept the request simple and avoided a preflight. Both halves of that
were wrong: a same-origin request never preflights, and text/plain is a
safelisted value that does not trigger one even cross-origin. The
standard bundles get away with declaring nothing because fetch and
sendBeacon set it implicitly for a string body; XMLHttpRequest on these
browsers cannot be relied on to do the same.

Nothing client-side could have caught this. The specs and the artifact
check both asserted the absence of headers, so the mistaken belief was
encoded three times over: in the transport, in its spec, and in the fake
XMLHttpRequest of the degraded environment specs, which threw if a
header was set.

Both levels now assert the header, and both fail without it.
Everything this package is checked with so far runs on a modern engine:
the unit suite, the degraded-environment specs and the artifact smoke
test all approximate the target browsers rather than being one. This
adds the missing step, a harness for running the shipped bundle on a
real browser and seeing the result on the device itself.

The page is plain ES5 and renders every check into the DOM, because the
browsers it targets often have no usable developer tools. The server
doubles as a same-origin intake that records what actually arrived, so
the checks assert the wire rather than the SDK's own claims: the bundle
loads, the collection APIs do not throw into the page, an uncaught error
still reaches the page's own handler, the session cookie is written, and
the intake received a text/plain POST whose real path travels inside
ddforward.

One check only has teeth on an old engine: any fetch-era browser adds
the content type to a string body implicitly, so the header assertion
cannot fail there regardless of the SDK. That is exactly why it lives in
this harness and not only in the unit suite.

JSON is parsed with JSON.parse, native since IE8. An eval-based parse
would also break under any Content Security Policy, which the rest of
the package promises not to require.
Cloud device farms meter free sessions by the minute, and the harness
spent over thirty seconds waiting out the SDK's flush timer. The run now
fills the batch to its limit so it flushes over the asynchronous path
immediately, starts itself when opened with ?autorun=1, and keeps its
results across the exit-check reload in sessionStorage. A full pass
takes under a second plus one reload.

The root path did not resolve when a query string was attached, which
made ?autorun=1 a 404: routing now matches on the pathname.

The page-exit check reports SKIP rather than FAIL on modern engines,
which block synchronous XHR during page dismissal by design. Like the
content-type check, it can only genuinely pass or fail on Trident, which
is why it is in this page at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant