feat(rum-legacy): ES5 build for browsers without ES2015 support - #22
Open
Fiona2016 wants to merge 16 commits into
Open
feat(rum-legacy): ES5 build for browsers without ES2015 support#22Fiona2016 wants to merge 16 commits into
Fiona2016 wants to merge 16 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 overXMLHttpRequest.It is self-contained: it does not import
@flashcatcloud/browser-coreorbrowser-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 functionand 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
ddforwardquery 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.proxyis required here, unlike in the standard bundles. These browsers cannot make a cross-originXMLHttpRequestcarrying the parameters the intake needs, soinitreports 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 becausefetchandsendBeaconset it implicitly for a string body, butXMLHttpRequeston 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, andtext/plainis a safelisted value that does not trigger a preflight even when it is not.Completion is detected through
onreadystatechange.onloadarrived 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 fakeXMLHttpRequestfires onlyonreadystatechangeto keep that honest. The exit path sends synchronously, as there is nosendBeacon.sessionSampleRateis 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.trackingConsentis honoured. Collection runs only while consent is exactlygranted, 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.cookieis 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 thanDate.now(), which some sites wrongly polyfill to return aDateinstance. Pages still running these browsers are the most likely to carry such a dependency.Guardrails
packages/rum-legacy/tsconfig.jsondeliberately does not extend the base config.libis restricted toES5+DOM, which turns using an unavailable API into a compile error rather than a runtime crash, andpathsis emptied so@flashcatcloud/*imports do not resolve.scripts/check-es5-compatibility.jsruns 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.tsremovesfetch,Promise, the observers,TextEncoder,URLandsendBeacon, then drives the package end to end. Event shape is validated against the sharedrum-events-formatschemas 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, noPromise, nosendBeaconand anXMLHttpRequestthat only firesonreadystatechange, 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 bareinstanceof 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.