diff --git a/CHANGELOG-FRONTIER.md b/CHANGELOG-FRONTIER.md index 4bea864366d..dd6075a0adf 100644 --- a/CHANGELOG-FRONTIER.md +++ b/CHANGELOG-FRONTIER.md @@ -1,3 +1,15 @@ +## 8.17.0 + +- Migrate to the Dynatrace New RUM Experience (RUM on Grail) and simplify agent loading to a single mechanism + - **Default:** combined `_complete.js` from the Dynatrace global CDN, loaded `async`. It is the only mechanism that tracks tenant configuration without a react-scripts release — two of the previous three treatments had been serving a RUM agent artifact dated 2022-11-04 to production + - **Removed the `frontier_snow_dynatraceNewRUM` flag.** It never controlled New RUM: both its branches emitted the same URL and it only toggled `async`. New RUM enablement is the tenant's `enabledOnGrail` setting, toggled per application in the Dynatrace UI with no deploy + - `frontier_snow_dynatraceRUM` reduced to three states: `off` (kill switch), `asyncCS-inline` (versioned OneAgent tag + SRI, loaded `sync`), and a **fail-open default** for everything else. Retired treatment names, a renamed flag, and a Split outage returning `control` all still load RUM — failing closed would create a silent monitoring gap + - The retained SRI arm covers both fallback cases at once: immutably cached for a year (if Dynatrace never adds a cache validator to `_complete.js`) and synchronous (no blind window if `async` proves to lose early interactions) + - **Removed all old-RUM code paths**: `edgeUrls`, the old `cdnUrls`, and the three `_inline_*.ejs` bootstrap files — about 105 KB out of the published package + - `dtWhenReady(which, fn)` helper added for both RUM APIs; `enableManualPageDetection` and `window.dtinfo.appName` in `layout.ejs` no longer depend on the agent having loaded synchronously, which silently disabled them under async loading + - Snow can supply fresh values at runtime via `locals.dynatrace.*`, published to S3 as `dynatrace-rum-config.json`; publish-time fallbacks remain in `dynatrace.ejs` as a last resort + - **Maintainer tooling and docs moved to `packages/react-scripts/tools/dynatrace/`** and excluded from the published package. `scripts/` is now only scripts consumers run. Published package: 119 files / 392 KB → 104 files / 247 KB + ## 8.16.1 - Convert layout.ejs includes from the removed EJS preprocessor syntax (`<% include x %>`) to the function form (`<%- include('x') %>`) ahead of the company-wide EJS 3 upgrade diff --git a/package-lock.json b/package-lock.json index b65ca530607..091c8bbeb28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43779,7 +43779,7 @@ }, "packages/react-scripts": { "name": "@fs/react-scripts", - "version": "8.16.1", + "version": "8.17.0", "license": "MIT", "dependencies": { "@babel/core": "^7.16.0", diff --git a/packages/react-scripts/layout/views/layout.ejs b/packages/react-scripts/layout/views/layout.ejs index 3c5e76ad5f2..ba4c9ef91f5 100644 --- a/packages/react-scripts/layout/views/layout.ejs +++ b/packages/react-scripts/layout/views/layout.ejs @@ -64,10 +64,25 @@ <%- include('partials/experiments') %> <%- include('partials/sentry') %> <%- include('partials/clientAppConfig') %> - if (window.dtrum) { + // window.dtinfo is a plain global read by Dynatrace metadata capture rules, so set it + // unconditionally and as early as possible. Both RUM Classic and the New RUM Experience can + // read it via their own (separately configured) capture rules; gating it on the agent being + // loaded meant the async treatments never set it at all. + if (!window.dtinfo) window.dtinfo = {} + window.dtinfo.appName = SERVER_DATA.appName + + // enableManualPageDetection is a RUM Classic API with no New RUM equivalent — New RUM models + // navigations / page summaries / view summaries instead. Defer it until dtrum exists rather + // than firing once: under async loading the old `if (window.dtrum)` guard was always false, + // so Classic silently fell back to automatic page detection on those treatments. + if (window.dtWhenReady) { + window.dtWhenReady('classic', function () { + if (typeof window.dtrum.enableManualPageDetection === 'function') { + window.dtrum.enableManualPageDetection() + } + }) + } else if (window.dtrum) { window.dtrum.enableManualPageDetection() - if (!window.dtinfo) window.dtinfo = {} - window.dtinfo.appName = SERVER_DATA.appName } diff --git a/packages/react-scripts/layout/views/partials/dynatrace.ejs b/packages/react-scripts/layout/views/partials/dynatrace.ejs index 6805ef274fc..b0b7f1b5668 100644 --- a/packages/react-scripts/layout/views/partials/dynatrace.ejs +++ b/packages/react-scripts/layout/views/partials/dynatrace.ejs @@ -2,27 +2,184 @@ /* SETS up dynatrace for the various environments */ if (typeof getFeatureFlag !== 'undefined') { + /* One flag, three states — see tools/dynatrace/DYNATRACE_RUM_MECHANISMS.md for the full rationale. + * + * 'off' no agent at all (kill switch) + * 'asyncCS-inline' versioned OneAgent JS tag + SRI, loaded SYNCHRONOUSLY + * anything else combined _complete.js from the Dynatrace global CDN, loaded ASYNC + * + * The default is deliberately the fallthrough rather than an explicit 'global-cdn' match: a + * retired treatment name, a renamed flag, or a Split outage returning 'control' must still load + * RUM. Failing closed would produce a silent monitoring gap, which is the one failure mode we + * cannot detect from the data itself. + * + * The SRI arm is the hedge for BOTH documented revisit triggers: it is versioned and immutably + * cached (so it survives Dynatrace never adding an ETag to _complete.js), and it is synchronous + * (so it has no blind window if async proves to lose early interactions or errors). Because its + * URL is cacheable for a year, the sync parse-block is paid only on the first uncached load. + * + * Whether the New RUM Experience is active is NOT a code concern — it is the tenant's + * `enabledOnGrail` setting, toggled per application in the Dynatrace UI with no deploy. + * The old frontier_snow_dynatraceNewRUM flag never controlled it; it only toggled `async`. + */ const dynatraceFlag = getFeatureFlag('frontier_snow_dynatraceRUM', {appName: process.env.APP_NAME}); const env = envType(); + const treatment = dynatraceFlag.treatment; + const rumDisabled = treatment === 'off'; + const useSriSync = treatment === 'asyncCS-inline'; - const cdnUrls = { + // Snow provides fresh values via locals.dynatrace (published to S3 by + // tools/dynatrace/fetch-dynatrace-scripts.js); fallback to values current at publish time. + // fallback to values current at react-scripts publish time. + const cdnUrlsNew = (locals && locals.dynatrace && locals.dynatrace.cdnUrls) || { + int: 'https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789MNPQRTUVXfghqrux_10343260715123811.js', + beta: 'https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789MNPRTUVXfghqrux_10343260715123811.js', + prod: 'https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA7NQVfghqrux_10343260715123811.js' + } + + const cdnIntegrityNew = (locals && locals.dynatrace && locals.dynatrace.cdnIntegrity) || { + int: 'sha256-8SY/pAJr28AjGTRbzyKLcD4kPiJQIJaT2EfcY59FoWE=', + beta: 'sha256-UW/29ParJH4UKavjhjZNnwHYcXoXrf5SVeOfDCypEds=', + prod: 'sha256-euJAVYI5qOoBJ7VKyqZ/OREHvmNG0kK5NB6rlOtDqqQ=' + } + + const cdnConfigNew = (locals && locals.dynatrace && locals.dynatrace.cdnConfig) || { + int: 'app=3faf90e849295814|cors=1|owasp=1|featureHash=ICA15789MNPQRTUVXfghqrux|msl=153600|srsr=10000|nsfnv=1|reportUrl=https://bf99293tkn.bf.dynatrace.com/bf|srvr=%5C%2Fidentity%5C%2Fsettings|rdnt=2|uxrgce=1|cuc=t0rtn87t|srms=2,1,0,0%2Ftextarea%2Cinput%2Cselect%2Coption;0%2Fdatalist;0%2Fform%20button;0%2F%5Bdata-dtrum-input%5D;0%2F.data-dtrum-input;0%2F%5Bcontenteditable%5D%3Anot%28%5Bcontenteditable%3D%22false%22%5D%29;0%2F%5Brole%3D%22textbox%22%5D%2C%5Brole%3D%22checkbox%22%5D%2C%5Brole%3D%22radio%22%5D%2C%5Brole%3D%22option%22%5D%2C%5Brole%3D%22combobox%22%5D%2C%5Brole%3D%22slider%22%5D%2C%5Brole%3D%22searchbox%22%5D%2C%5Brole%3D%22switch%22%5D%2C%5Brole%3D%22spinbutton%22%5D;1%2F%5Edata%28%28%5C-.%2B%24%29%7C%24%29|mel=100000|dpvc=1|md=mdcc1=cfs_anid,mdcc2=bFS.User.profile.cisId,mdcc3=bs.pageName,mdcc4=bFS.attrs.anonId,mdcc5=bFS.attrs.country,mdcc6=bFS.attrs.cisId|lastModification=1786979998466|mdp=mdcc4,mdcc5|tp=500,50,0|srbbv=2|agentUri=https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789MNPQRTUVXfghqrux_10343260715123811.js', + beta: 'app=c4242bb1eb216374|cors=1|owasp=1|featureHash=ICA15789MNPRTUVXfghqrux|srsr=5000|nsfnv=1|reportUrl=https://bf99293tkn.bf.dynatrace.com/bf|srvr=%5C%2Fidentity%5C%2Fsettings|rdnt=2|uxrgce=1|cuc=t0rtn87t|mel=100000|dpvc=1|md=mdcc1=cfs_anid,mdcc2=bFS.attrs.cisId,mdcc3=bFS.User.profile.cisId,mdcc4=fx-ratelimit-remaining,mdcc5=bs.campaign,mdcc6=bs.channel,mdcc7=bs.pageName,mdcc8=bs.pageURL,mdcc9=bs.pageType,mdcc10=bs.visitorID|lastModification=1786979998466|tp=500,50,0|srbbv=2|agentUri=https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789MNPRTUVXfghqrux_10343260715123811.js', + prod: 'app=a8e5edd77f861ace|cors=1|owasp=1|featureHash=ICA7NQVfghqrux|msl=153600|srsr=10000|vcx=1500|nsfnv=1|reportUrl=https://bf99293tkn.bf.dynatrace.com/bf|srvr=%5C%2Fidentity%5C%2Fsettings|rdnt=2|uxrgce=1|vcit=8000|cuc=t0rtn87t|srms=2,2,1,|mdl=mdcc11=20|mel=100000|dpvc=1|md=mdcc1=cfs_anid,mdcc2=bFS.attrs.cisId,mdcc3=bFS.User.profile.cisId,mdcc4=bs.pageName,mdcc5=bs.pageType,mdcc6=bs.pageURL,mdcc7=bs.visitorID,mdcc8=bs.campaign,mdcc9=bs.channel,mdcc10=fx-ratelimit-remaining,mdcc11=bdocument.referrer,mdcc12=dutm_campaign,mdcc13=dutm_source,mdcc14=dutm_medium|lastModification=1786979998466|tp=500,50,0|srbbv=2|agentUri=https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA7NQVfghqrux_10343260715123811.js' + } + + // New RUM JavaScript tag (combined code+config _complete.js) from the Dynatrace global CDN. + // Snow provides fresh values via locals.dynatrace.cdnCompleteUrls; fallback to publish-time values. + const cdnCompleteUrlsNew = (locals && locals.dynatrace && locals.dynatrace.cdnCompleteUrls) || { int: 'https://js-cdn.dynatrace.com/jstag/15c157a40ab/bf99293tkn/3faf90e849295814_complete.js', beta: 'https://js-cdn.dynatrace.com/jstag/15c157a40ab/bf99293tkn/c4242bb1eb216374_complete.js', prod: 'https://js-cdn.dynatrace.com/jstag/15c157a40ab/bf99293tkn/a8e5edd77f861ace_complete.js' } - const edgeUrls = { - int: 'https://edge.fscdn.org/assets/components/hf/assets/js/monitoring/dynatrace-20221104-int.min-9aebd229f83ef7c845ae898f451bb617.js', - beta: 'https://edge.fscdn.org/assets/components/hf/assets/js/monitoring/dynatrace-20221104-beta.min-e859eab722487cc71d4f659447d7ed6d.js', - prod: 'https://edge.fscdn.org/assets/components/hf/assets/js/monitoring/dynatrace-20221104-prod.min-44bb9345beec8a984c2fca40385b4f41.js' - } + // The agent is never inlined: EJS compiles included files as templates, and the minified agent + // contains the EJS open-delimiter sequence (a less-than sign immediately followed by a percent + // sign) inside a character-class string, which EJS misreads as an unterminated scriptlet and + // fails with "Could not find matching close tag". It would also re-ship 300-460 KB per response. + if (!rumDisabled) { + if (useSriSync) { %> + + + <% } else { %> + + + <% } %> + - <% } else if (dynatraceFlag.treatment === 'asyncCS-inline') { %> - <%- include(`./partials/dynatrace/_inline_${env}`) %> - <% } else if (dynatraceFlag.treatment === 'global-cdn') { %> - - <% } %> + poll(); // synchronous treatments have already loaded by now + if (!(fired.classic && fired.grail)) { + var iv = setInterval(function () { + if (poll() || ++attempts > 50) clearInterval(iv); // up to ~10s for an async agent + }, 200); + } + })(); -<% } %> + // Replay whatever the buffer caught before the agent's own handlers existed. + // + // Routed through RUM Classic on purpose: dtrum.reportError is available, dynatrace.reportError + // is NOT (New RUM exposes sendEvent instead, whose error event shape we have not verified). + // Classic runs alongside New RUM and cannot be disabled, so this path always exists. Whether a + // Classic-reported error also surfaces in Grail is unconfirmed — see EARLY_ERROR_BUFFER.md. + // + // Known imprecision, accepted deliberately: the agent installs its handlers slightly before + // window.dtrum becomes visible (measured — errors captured from ~550ms while dtrum appeared at + // ~660ms), so an error thrown in that ~100ms sliver can be reported twice. Duplicating a + // handful of errors is the better failure than dropping them, and the "[dt-early]" prefix makes + // replayed ones identifiable if they ever need excluding. + window.dtWhenReady && window.dtWhenReady('classic', function () { + var buf = window.__dtEarlyErrors || []; + if (window.__dtEarlyErrorsStop) { try { window.__dtEarlyErrorsStop(); } catch (e) {} } + for (var i = 0; i < buf.length; i++) { + try { + var rec = buf[i]; + var ev = rec.e || {}; + // Pass the Error object where we have one so the real stack survives; a bare string makes + // the agent synthesise a stack from the reporting site, which points here instead of at + // the throw. Check exception.is_stack_trace_generated in user.events to tell them apart. + var err = ev.error || ev.reason; + var at = '[dt-early +' + rec.t + 'ms] '; + if (typeof window.dtrum.reportError === 'function') { + window.dtrum.reportError(err instanceof Error ? err : new Error(at + (ev.message || err || 'unknown early error'))); + } + } catch (e) {} + } + buf.length = 0; + }); + + <% } +} %> diff --git a/packages/react-scripts/layout/views/partials/dynatrace/_inline_beta.ejs b/packages/react-scripts/layout/views/partials/dynatrace/_inline_beta.ejs deleted file mode 100644 index 34f8961ae23..00000000000 --- a/packages/react-scripts/layout/views/partials/dynatrace/_inline_beta.ejs +++ /dev/null @@ -1,73 +0,0 @@ - \ No newline at end of file diff --git a/packages/react-scripts/layout/views/partials/dynatrace/_inline_int.ejs b/packages/react-scripts/layout/views/partials/dynatrace/_inline_int.ejs deleted file mode 100644 index 731df536373..00000000000 --- a/packages/react-scripts/layout/views/partials/dynatrace/_inline_int.ejs +++ /dev/null @@ -1,73 +0,0 @@ - \ No newline at end of file diff --git a/packages/react-scripts/layout/views/partials/dynatrace/_inline_prod.ejs b/packages/react-scripts/layout/views/partials/dynatrace/_inline_prod.ejs deleted file mode 100644 index 204607df5bf..00000000000 --- a/packages/react-scripts/layout/views/partials/dynatrace/_inline_prod.ejs +++ /dev/null @@ -1,73 +0,0 @@ - diff --git a/packages/react-scripts/package.json b/packages/react-scripts/package.json index 98f1328c8af..ae596db68e7 100644 --- a/packages/react-scripts/package.json +++ b/packages/react-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@fs/react-scripts", - "version": "8.16.1", + "version": "8.17.0-alpha.17", "upstreamVersion": "5.0.1", "description": "Configuration and scripts for Create React App.", "repository": { diff --git a/packages/react-scripts/tools/dynatrace/DYNATRACE_RUM_MECHANISMS.md b/packages/react-scripts/tools/dynatrace/DYNATRACE_RUM_MECHANISMS.md new file mode 100644 index 00000000000..18f3abbfcc5 --- /dev/null +++ b/packages/react-scripts/tools/dynatrace/DYNATRACE_RUM_MECHANISMS.md @@ -0,0 +1,321 @@ +# Dynatrace RUM: loading mechanisms, trade-offs, and why we chose what we chose + +Reference for how the Dynatrace RUM agent gets onto the page, what the alternatives are, and +the reasoning behind the current choice. Companion to +[DYNATRACE_RUM_UPDATE.md](./DYNATRACE_RUM_UPDATE.md) (how to refresh the agent), +[dynatrace.ejs](../../layout/views/partials/dynatrace.ejs) (the implementation), and +[beacon-harness/](./beacon-harness/) (the beacon-volume harness and its retained raw data). + +**Current decision:** serve the combined `_complete.js` tag from the Dynatrace global CDN, +loaded `async`. Provisional — see [Revisit triggers](#revisit-triggers). + +**Scope:** the New RUM Experience with agent 1.343 as the committed baseline. All old-RUM code +paths were removed in 8.17.0. + +--- + +## What is actually implemented + +`frontier_snow_dynatraceRUM` is the only flag. Three meaningful states: + +| Treatment | Renders | Role | +|---|---|---| +| `off` | nothing | Kill switch. Defined in int, beta and prod | +| `asyncCS-inline` | versioned OneAgent JS tag + SRI, **sync** | Fallback arm — option 4 below | +| anything else | `_complete.js`, **async** | Default — option 1 below | + +Two deliberate properties: + +**The default is a fallthrough, not an equality check.** Retired treatment names, a renamed +flag, and a Split outage returning `control` all still load RUM. Failing closed would produce a +silent monitoring gap, which is the one failure mode invisible in the data itself. + +**The sync SRI arm covers both revisit triggers at once** — it is immutably cached (so it +survives Dynatrace never adding an `ETag`) and synchronous (so it has no blind window if `async` +proves to lose early interactions or errors). That is why one arm suffices rather than two. + +Whether the New RUM Experience is active is **not** a code concern — it is the tenant's +`enabledOnGrail` setting. The former `frontier_snow_dynatraceNewRUM` flag never controlled it; +it only toggled `async`, and was removed in 8.17.0. + +--- + +## Candidates evaluated and rejected + +All three mechanisms below were **real candidates**, deployed behind the feature flag, carrying +live traffic across int, beta and prod, and measured. Each was rejected on final consideration +for the reasons recorded here. They are part of the analysis, not leftovers — and two of them +produced findings that drove the final decision even though they lost. + +### The evaluation + +Before the New RUM migration, `frontier_snow_dynatraceRUM` selected between three **RUM Classic** +loading mechanisms. The intent was to compare page-speed cost against maintenance cost and keep +the winner. + +| Treatment | RUM Classic behaviour | +|---|---| +| `asyncCS-script` | Self-hosted agent from `edge.fscdn.org`, `async` | +| `asyncCS-inline` | Small bootstrap inlined into the HTML response (`_inline_*.ejs`, ~35 KB), `sync` | +| `global-cdn` | `_complete.js` from the Dynatrace CDN, `sync` | +| `off` | No agent | + +That experiment is what produced the measurements in this document. Its conclusion was that +mechanism choice moves FCP by tens of milliseconds while agent payload moves it by hundreds — so +the decision fell to maintenance, not performance. + +### Candidate: inline bootstrap — rejected + +**The case for it.** The old-RUM Inline Code format was a **small synchronous bootstrap**: it +began capture immediately, then async-loaded the full library. On paper the best of both worlds — +no blind window, no third-party request before capture started, and no render-blocking download. +Of the three candidates this had the strongest theoretical position, and it is why the option was +built and tested rather than dismissed. + +**Why it was rejected.** Four independent reasons, any one of which would be sufficient: + +1. **New RUM has no small-bootstrap snippet format.** The available formats are JavaScript Tag + (`_complete.js`), OneAgent JS Tag, OneAgent JS Tag + SRI, and Inline Code — but New RUM's + inline variant inlines the *entire* agent, not a bootstrap. The property that made it + attractive does not exist in New RUM. +2. **EJS cannot include the agent, and this caused a production 500.** `include()` compiles the + included file as a template, and the minified agent contains the EJS open-delimiter sequence + (a `<` immediately followed by a `%`) inside a character-class string. EJS reads it as an + unterminated scriptlet and fails with `Could not find matching close tag for "<%"`. +3. **No caching at all.** Inlining re-ships 300–460 KB in *every* HTML response, versus one + cached download per agent version. For repeat visitors this is strictly worse than any + ` +``` + +Key parts: +- **`src`**: The CDN URL for the external script +- **`data-dtconfig`**: Configuration string (includes app ID, parameters, etc.) +- **`integrity`**: SRI hash for script verification +- **app ID**: The environment-specific application identifier (e.g., `app=3faf90e849295814`) + +## Files Modified During Update + +``` +packages/react-scripts/layout/views/partials/dynatrace.ejs (fallback values auto-updated) +packages/react-scripts/tools/dynatrace/dynatrace-rum-config.json (regenerated; also published to S3) +packages/react-scripts/package.json (version bump) +CHANGELOG-FRONTIER.md (add entry) +``` + +> Note: the script never generates inline agent files. The old-RUM `_inline_*.ejs` bootstrap files were deleted when the old-RUM code paths were removed. + +## Troubleshooting + +**API returns 401 Unauthorized** +- Check that `DYNATRACE_API_TOKEN` environment variable is set +- Verify the token has the correct scopes (RUM manual insertion tags read) + +**API returns 400 Bad Request** +- Ensure entity IDs include the `APPLICATION-` prefix +- Entity IDs must be uppercase + +**API returns 404 Not Found** +- Verify the API endpoint URL is correct: `/api/v2/rum/oneAgentJavaScriptTagWithSri/{entityId}` +- Check that you're using the `.live.dynatrace.com` domain, not `.apps.dynatrace.com` + +## Related Documentation + +- [Dynatrace RUM API Documentation](https://docs.dynatrace.com/docs/dynatrace-api/environment-api/rum/rum-manual-insertion-tags) +- [dynatrace.ejs](../../layout/views/partials/dynatrace.ejs) - Main RUM configuration file +- [fetch-dynatrace-scripts.js](./fetch-dynatrace-scripts.js) - The fetch script itself + +## Version History + +- **8.17.0**: New RUM Experience baseline; single-flag loading + - Removed the `frontier_snow_dynatraceNewRUM` flag — New RUM enablement is the tenant's + `enabledOnGrail` setting, not a code concern + - Removed all old-RUM code paths: `edgeUrls`, the old `cdnUrls`, and the three + `_inline_*.ejs` bootstrap files (~105 KB out of the published package) + - `frontier_snow_dynatraceRUM` reduced to three states: `off`, `asyncCS-inline` + (SRI, sync), and a fail-open default of `_complete.js` loaded `async` + - Maintainer tooling and docs moved to `tools/dynatrace/`, excluded from the published package diff --git a/packages/react-scripts/tools/dynatrace/EARLY_ERROR_BUFFER.md b/packages/react-scripts/tools/dynatrace/EARLY_ERROR_BUFFER.md new file mode 100644 index 00000000000..81ce69200d6 --- /dev/null +++ b/packages/react-scripts/tools/dynatrace/EARLY_ERROR_BUFFER.md @@ -0,0 +1,192 @@ +# Early-error buffer + +A ~20-line inline listener in `
` that catches errors thrown before the RUM agent exists and +replays them once it arrives. Lives in +[dynatrace.ejs](../../layout/views/partials/dynatrace.ejs), directly above `dtWhenReady`. + +## Why it exists + +Loading the agent `async` means its error handlers are not installed at navigation start. Measured +on int with [beacon-harness/blind-window.mjs](https://github.com/fs-webdev/create-react-app/blob/rum-harness-archive-2026-08-17/packages/react-scripts/tools/dynatrace/beacon-harness/blind-window.mjs), n=10: + +| offset | XHR captured | Error captured | +| --- | --- | --- | +| 0 ms | 10/10 | **0/10** | +| 100 ms | 10/10 | **0/10** | +| 250 ms | 10/10 | 2/10 | +| 400 ms | 10/10 | 5/10 | +| 550 ms | 10/10 | 10/10 | +| 700 ms + | 10/10 | 10/10 | + +Requests are never lost — the agent recovers them retroactively from Resource Timing. **Exceptions +are the only thing the async load actually loses**, because there is no buffered API for an error +thrown with no listener attached. Once it is gone, it is gone. + +That is a narrow loss, but a badly-placed one: errors in the first half-second are +page-load errors, which are the ones you most want to see. + +## Sentry does not cover this + +The obvious question is whether Sentry catches what Dynatrace misses. It does not. Measured on int, +median of 4 runs: + +| | Installed at | +| --- | --- | +| `window.dtrum` / `window.dynatrace` | **660 ms** | +| `window.__SENTRY__` | **1016 ms** | + +Sentry initialises from the app bundle, which is larger and later than the 115 KB agent, so it +shares the blind window and extends it by ~356 ms. Anything Dynatrace misses, Sentry misses too. + +Also worth knowing: `SERVER_DATA.sentryDSN` is **empty on int and beta** — set only in prod. The SDK +loads and costs bundle weight in the lower environments but transports nothing, so Sentry error +coverage cannot be validated there at all. + +The gap is a property of installing a handler late, not of any particular vendor. The only fix is a +listener that already exists at t=0. + +## How it works + +1. An inline IIFE registers `error` and `unhandledrejection` listeners in the **capture phase**, so + a `stopPropagation()` elsewhere cannot hide errors from it. Entries are pushed to + `window.__dtEarlyErrors` with a `performance.now()` stamp. Bounded at 50 — a page erroring in a + loop must not grow it without limit. +2. When the agent appears, the existing `dtWhenReady('classic', …)` callback stops the listeners + and replays the buffer through `dtrum.reportError`. + +It must stay **first** in that script block, and the partial must stay early in `` — anything +that throws before it runs is unrecoverable. It currently renders at `layout.ejs` line 42, ahead of +`SERVER_DATA` (52) and Sentry (65). + +The block is inside the `!rumDisabled` branch, so the `off` treatment emits **zero bytes**. If RUM +is kill-switched because it is causing a problem, our listeners should not still be installed. + +## Why it reports through RUM Classic + +Verified against the live agent on int: + +| API | | +| --- | --- | +| `dtrum.reportError` | **function** | +| `dtrum.reportCustomError` | function | +| `dynatrace.reportError` | **undefined** | + +New RUM has no direct error-reporting method — it exposes `sendEvent`, whose error event shape we +have not verified. Classic runs alongside New RUM and cannot be disabled, so `dtrum.reportError` is +always available. + +**Open question:** whether an error reported through Classic also surfaces in Grail (`user.events`). +Classic and Grail are separate ingest channels. `characteristics.is_api_reported` does appear on +Grail events, so API-reported data reaches it in general, but that has not been confirmed for +`reportError` specifically. If it does not cross over, the replay is visible in Classic dashboards +only, and reaching Grail would need `dynatrace.sendEvent` with a hand-built error event. + +## Chunk-load failures are not covered + +The buffer listens for `error` and `unhandledrejection`. A chunk-load failure reaches RUM through +neither: `RetryChunkLoadPlugin` catches the rejection itself and reports the exhausted retry with +`console.error`, which the agent picks up by patching the console. Measured with +[beacon-harness/chunk-failure.mjs](https://github.com/fs-webdev/create-react-app/blob/rum-harness-archive-2026-08-17/packages/react-scripts/tools/dynatrace/beacon-harness/chunk-failure.mjs), the event arrives as +`exception.type: ChunkLoadError` with `error.source: "console"` — see +[RUM_ERROR_PROBE_SPEC.md](./RUM_ERROR_PROBE_SPEC.md#answered-chunk-load). + +So a chunk failure inside the blind window would be lost, and this buffer would not save it. + +The same measurement found a harder limit that no buffer can fix. Application chunks are served +cross-origin from `edge.fscdn.org` and their `