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..669a98011dd 100644
--- a/packages/react-scripts/layout/views/partials/dynatrace.ejs
+++ b/packages/react-scripts/layout/views/partials/dynatrace.ejs
@@ -2,27 +2,121 @@
/* 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_ICA15789NPRTUVXfghqrux_10343260715123811.js',
+ prod: 'https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA7NQVfghqrux_10341260622154106.js'
+ }
+
+ const cdnIntegrityNew = (locals && locals.dynatrace && locals.dynatrace.cdnIntegrity) || {
+ int: 'sha256-8SY/pAJr28AjGTRbzyKLcD4kPiJQIJaT2EfcY59FoWE=',
+ beta: 'sha256-PeyllVOccLjCaea3jYyf0w5YCMFa54GWyc4B3eRQn4g=',
+ prod: 'sha256-eGB6PLc8ndZISmjwbbi4xZhE9nbarKf48bRKxVdha+8='
+ }
+
+ 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=1786000244457|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=ICA15789NPRTUVXfghqrux|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=1786000244457|tp=500,50,0|srbbv=2|agentUri=https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789NPRTUVXfghqrux_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=1786000244457|tp=500,50,0|srbbv=2|agentUri=https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA7NQVfghqrux_10341260622154106.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') { %>
-
- <% } %>
+ function poll() {
+ if (window.dtrum) flush('classic');
+ if (window.dynatrace && typeof window.dynatrace.sendSessionPropertyEvent === 'function') flush('grail');
+ return fired.classic && fired.grail;
+ }
+ 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);
+ }
+ })();
+
<% } %>
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/beacon-harness/README.md b/packages/react-scripts/tools/dynatrace/beacon-harness/README.md
new file mode 100644
index 00000000000..c9664fff090
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/README.md
@@ -0,0 +1,88 @@
+# Dynatrace RUM beacon-volume harness
+
+Measures how many bytes the Dynatrace RUM agent **uploads** per session, split by beacon
+channel, against deployed environments. Complements the payload/download measurements, which
+are just `curl` against the agent URLs and need no tooling.
+
+Standalone — it hits deployed URLs and needs only Playwright. It is not part of the
+`react-scripts` build, and its `package.json` is not picked up by the yarn workspace (the
+workspace glob is `packages/*`, direct children only).
+
+Full analysis and the numbers this produced:
+**Dynatrace RUM: Loading Mechanisms & Agent Management (In-Depth Analysis)** (Confluence, FRDOCS).
+Decision context: [../DYNATRACE_RUM_MECHANISMS.md](../DYNATRACE_RUM_MECHANISMS.md).
+
+## Why this exists
+
+The New RUM Experience runs *alongside* RUM Classic — Classic cannot be disabled — so enabling
+New RUM **adds** a beacon channel rather than replacing one. This harness quantifies that, and
+was what established:
+
+- Enabling New RUM is roughly a **2.3× increase in per-session upload**
+- The Grail channel appears to be **unsampled**, while Classic honours `costAndTrafficControl`
+
+## Usage
+
+```bash
+npm install
+
+# Capture sessions. Defaults: 10 runs, 20s dwell.
+node measure-beacons.mjs int 40 20000
+node measure-beacons.mjs beta 15 20000
+node measure-beacons.mjs prod 15 20000
+
+# Summarise (reads data/v2-.jsonl)
+node analyze.mjs
+```
+
+Each run takes `dwell + ~3s`, so 40 runs at 20s is about 16 minutes. Run it in the background.
+
+`probe-channels.mjs` is a diagnostic: it dumps each beacon's query parameters and the first
+180 bytes of its body. Use it if the wire format changes and the channel classification in
+`measure-beacons.mjs` stops matching.
+
+## What it measures
+
+The agent multiplexes two channels onto `bf99293tkn.bf.dynatrace.com/bf`:
+
+| Channel | Query signature | Encoding |
+| --- | --- | --- |
+| `classic` | `type=js3` | form-encoded (`$a=1%7C2%7C_event_%7C…`) |
+| `grail` | `ty=js&cy=event` | JSON (`{"data_version":2,…}`), compressed |
+
+Per session it records bytes and request counts per channel, plus `_sr_`/`_nosr_` marker counts.
+`analyze.mjs` reports mean/median/min/max per channel, splits sessions by whether Classic sent a
+full payload or a ~1KB stub, and prints sorted totals so bimodality is visible directly.
+
+## Interpreting results
+
+- **Compare within an environment, not across.** int/beta/prod run different app deployments with
+ different DOM weight, so cross-environment magnitude comparisons are confounded. Presence/absence
+ results (e.g. `prod grail == 0`) are not.
+- **Sampling is not loss.** `costAndTrafficControl: 33` means about one session in three is
+ monitored by design. Most Classic sessions legitimately send only a ~1KB stub.
+- **These are lower bounds.** 20-second scripted sessions understate real patron sessions, which
+ are longer and more interactive.
+- Counts request **body** bytes only — headers and TLS overhead excluded.
+
+## Retained data
+
+`data/` holds the raw JSONL from the 2026-08-08 run (int n=40, beta n=15, prod n=15) that produced
+the published figures. Tenant configuration at capture time:
+
+| | int | beta | prod |
+| --- | --- | --- | --- |
+| New RUM (`enabledOnGrail`) | on | on | off |
+| Session Replay | on | off (temporary) | on |
+| Agent | 1.343 | 1.343 | 1.341 |
+
+Re-running after prod enables New RUM is the natural next use — it would verify the predicted
+~2.3× upload increase against reality.
+
+## Not included
+
+The earlier FCP/loading-mechanism harnesses were deliberately not kept: they were tied to
+`fs_anid` cookie pins that are meaningless now the flag is 100% `global-cdn`, two of their three
+approaches had already been superseded, and the one question they addressed — does `async` lose
+early events — is better answered by `@dynatrace/rum-javascript-sdk-playwright`'s
+`dynatraceTesting.expectToHaveSentEvent(...)`, which turns it into a deterministic assertion.
diff --git a/packages/react-scripts/tools/dynatrace/beacon-harness/analyze.mjs b/packages/react-scripts/tools/dynatrace/beacon-harness/analyze.mjs
new file mode 100644
index 00000000000..5165e75be73
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/analyze.mjs
@@ -0,0 +1,75 @@
+import fs from 'fs'
+
+const load = env => {
+ try {
+ return fs
+ .readFileSync(new URL(`./data/v2-${env}.jsonl`, import.meta.url), 'utf8')
+ .trim()
+ .split('\n')
+ .filter(Boolean)
+ .map(JSON.parse)
+ .filter(r => r.ok)
+ } catch {
+ return []
+ }
+}
+
+const q = (xs, p) => {
+ if (!xs.length) return 0
+ const s = [...xs].sort((a, b) => a - b)
+ const i = (s.length - 1) * p
+ const lo = Math.floor(i)
+ const hi = Math.ceil(i)
+ return Math.round(s[lo] + (s[hi] - s[lo]) * (i - lo))
+}
+const stat = xs => ({ n: xs.length, p25: q(xs, 0.25), p50: q(xs, 0.5), p75: q(xs, 0.75), max: Math.max(0, ...xs) })
+const fmt = s => `${String(s.p50).padStart(7)} [${s.p25}–${s.p75}] max=${s.max}`
+
+const envs = ['int', 'beta', 'prod']
+const data = Object.fromEntries(envs.map(e => [e, load(e)]))
+
+console.log('\n===== per-session Dynatrace upload bytes (median [p25–p75]) =====\n')
+console.log('env n channel bytes')
+for (const e of envs) {
+ const d = data[e]
+ if (!d.length) { console.log(`${e.padEnd(6)} 0 (no data)`); continue }
+ for (const k of ['total', 'classic', 'grail']) {
+ console.log(`${e.padEnd(6)} ${String(d.length).padEnd(3)} ${k.padEnd(11)} ${fmt(stat(d.map(r => r[k])))}`)
+ }
+ const sr = d.filter(r => r.srMarkers > 0).length
+ console.log(` ${' '.repeat(3)} sessions w/ _sr_ marker: ${sr}/${d.length}`)
+ console.log('')
+}
+
+// Bimodality: if Session Replay records in a sampled subset, those sessions should
+// sit well above the rest. Print the sorted totals so any gap is visible directly.
+for (const e of envs) {
+ const d = data[e]
+ if (d.length < 5) continue
+ const totals = d.map(r => r.total).sort((a, b) => a - b)
+ console.log(`${e} sorted totals: ${totals.join(' ')}`)
+ const gaps = totals.slice(1).map((v, i) => ({ at: i + 1, gap: v - totals[i], v }))
+ const biggest = gaps.sort((a, b) => b.gap - a.gap)[0]
+ if (biggest) {
+ const above = totals.length - biggest.at
+ console.log(
+ ` largest gap: ${biggest.gap} bytes at rank ${biggest.at}/${totals.length} ` +
+ `(${above} session(s) above, ${((above / totals.length) * 100).toFixed(0)}%)`
+ )
+ }
+ console.log('')
+}
+
+// New RUM channel cost: prod has enabledOnGrail=false, so it is the natural control.
+const p = data.prod, i = data.int
+if (p.length && i.length) {
+ const pc = stat(p.map(r => r.classic)).p50
+ const pg = stat(p.map(r => r.grail)).p50
+ const ic = stat(i.map(r => r.classic)).p50
+ const ig = stat(i.map(r => r.grail)).p50
+ console.log('===== New RUM (Grail) channel overhead =====')
+ console.log(` prod classic=${pc} grail=${pg} (New RUM off)`)
+ console.log(` int classic=${ic} grail=${ig} (New RUM on)`)
+ console.log(` Grail channel adds ~${ig} bytes/session on int`)
+ if (pg === 0) console.log(' prod grail == 0 confirms the channel is New-RUM-gated')
+}
diff --git a/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-beta.jsonl b/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-beta.jsonl
new file mode 100644
index 00000000000..245e92bee7d
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-beta.jsonl
@@ -0,0 +1,15 @@
+{"env":"beta","run":0,"ok":true,"durationMs":23313,"total":24615,"classic":5876,"grail":18739,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":1,"ok":true,"durationMs":22670,"total":49436,"classic":20603,"grail":28833,"other":0,"nClassic":2,"nGrail":5,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":2,"ok":true,"durationMs":23063,"total":20509,"classic":3823,"grail":16686,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":3,"ok":true,"durationMs":22786,"total":37264,"classic":10242,"grail":27022,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":4,"ok":true,"durationMs":22911,"total":37209,"classic":10265,"grail":26944,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":5,"ok":true,"durationMs":23260,"total":50730,"classic":22217,"grail":28513,"other":0,"nClassic":2,"nGrail":4,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":6,"ok":true,"durationMs":22870,"total":25521,"classic":3831,"grail":21690,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":7,"ok":true,"durationMs":22870,"total":38396,"classic":10533,"grail":27863,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":8,"ok":true,"durationMs":23351,"total":24782,"classic":6300,"grail":18482,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":9,"ok":true,"durationMs":22805,"total":24759,"classic":6027,"grail":18732,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":10,"ok":true,"durationMs":23338,"total":17407,"classic":3832,"grail":13575,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":11,"ok":true,"durationMs":23396,"total":14885,"classic":1106,"grail":13779,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":12,"ok":true,"durationMs":23323,"total":37888,"classic":10507,"grail":27381,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":13,"ok":true,"durationMs":22846,"total":37733,"classic":10238,"grail":27495,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
+{"env":"beta","run":14,"ok":true,"durationMs":23360,"total":37606,"classic":10508,"grail":27098,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":1,"srMarkers":0,"nosrMarkers":0}
diff --git a/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-int.jsonl b/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-int.jsonl
new file mode 100644
index 00000000000..d8c6d3f62d9
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-int.jsonl
@@ -0,0 +1,40 @@
+{"env":"int","run":0,"ok":true,"durationMs":23482,"total":13896,"classic":1803,"grail":12093,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":1,"ok":true,"durationMs":23244,"total":40916,"classic":17751,"grail":23165,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":2,"ok":true,"durationMs":23442,"total":20533,"classic":935,"grail":19598,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":3,"ok":true,"durationMs":23360,"total":20755,"classic":935,"grail":19820,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":4,"ok":true,"durationMs":23394,"total":17780,"classic":2177,"grail":15603,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":5,"ok":true,"durationMs":23330,"total":20219,"classic":935,"grail":19284,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":6,"ok":true,"durationMs":23399,"total":20346,"classic":936,"grail":19410,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":7,"ok":true,"durationMs":23310,"total":21740,"classic":936,"grail":20804,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":8,"ok":true,"durationMs":23692,"total":10044,"classic":935,"grail":9109,"other":0,"nClassic":1,"nGrail":1,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":9,"ok":true,"durationMs":23811,"total":41396,"classic":17810,"grail":23586,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":10,"ok":true,"durationMs":23572,"total":26092,"classic":936,"grail":25156,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":11,"ok":true,"durationMs":23538,"total":22425,"classic":935,"grail":21490,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":12,"ok":true,"durationMs":23579,"total":14349,"classic":2178,"grail":12171,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":13,"ok":true,"durationMs":23321,"total":16408,"classic":1802,"grail":14606,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":14,"ok":true,"durationMs":23358,"total":41718,"classic":17799,"grail":23919,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":15,"ok":true,"durationMs":23471,"total":21734,"classic":935,"grail":20799,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":16,"ok":true,"durationMs":23510,"total":22130,"classic":935,"grail":21195,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":17,"ok":true,"durationMs":23516,"total":40952,"classic":17807,"grail":23145,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":18,"ok":true,"durationMs":23296,"total":41030,"classic":17748,"grail":23282,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":19,"ok":true,"durationMs":23355,"total":41000,"classic":17781,"grail":23219,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":20,"ok":true,"durationMs":22885,"total":16903,"classic":1803,"grail":15100,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":21,"ok":true,"durationMs":22695,"total":21243,"classic":936,"grail":20307,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":22,"ok":true,"durationMs":23326,"total":42798,"classic":18794,"grail":24004,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":23,"ok":true,"durationMs":23526,"total":41682,"classic":17799,"grail":23883,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":24,"ok":true,"durationMs":23287,"total":41256,"classic":17773,"grail":23483,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":25,"ok":true,"durationMs":23381,"total":21017,"classic":936,"grail":20081,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":26,"ok":true,"durationMs":23314,"total":12999,"classic":1803,"grail":11196,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":27,"ok":true,"durationMs":23310,"total":16763,"classic":1804,"grail":14959,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":28,"ok":true,"durationMs":23433,"total":18477,"classic":1577,"grail":16900,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":29,"ok":true,"durationMs":23187,"total":21264,"classic":935,"grail":20329,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":30,"ok":true,"durationMs":23259,"total":21027,"classic":935,"grail":20092,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":31,"ok":true,"durationMs":23267,"total":41317,"classic":17759,"grail":23558,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":32,"ok":true,"durationMs":23348,"total":22355,"classic":935,"grail":21420,"other":0,"nClassic":1,"nGrail":4,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":33,"ok":true,"durationMs":23289,"total":20859,"classic":935,"grail":19924,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":34,"ok":true,"durationMs":23325,"total":16313,"classic":1803,"grail":14510,"other":0,"nClassic":1,"nGrail":2,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":35,"ok":true,"durationMs":23377,"total":20798,"classic":936,"grail":19862,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":36,"ok":true,"durationMs":23561,"total":20707,"classic":935,"grail":19772,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":37,"ok":true,"durationMs":23256,"total":41448,"classic":17767,"grail":23681,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"int","run":38,"ok":true,"durationMs":23356,"total":20998,"classic":935,"grail":20063,"other":0,"nClassic":1,"nGrail":3,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"int","run":39,"ok":true,"durationMs":23400,"total":41190,"classic":17790,"grail":23400,"other":0,"nClassic":3,"nGrail":5,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
diff --git a/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-prod.jsonl b/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-prod.jsonl
new file mode 100644
index 00000000000..453b3391909
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/data/v2-prod.jsonl
@@ -0,0 +1,15 @@
+{"env":"prod","run":0,"ok":true,"durationMs":22775,"total":15192,"classic":15192,"grail":0,"other":0,"nClassic":4,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"prod","run":1,"ok":true,"durationMs":23454,"total":27836,"classic":27836,"grail":0,"other":0,"nClassic":4,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"prod","run":2,"ok":true,"durationMs":22680,"total":927,"classic":927,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":3,"ok":true,"durationMs":22791,"total":927,"classic":927,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":4,"ok":true,"durationMs":22841,"total":927,"classic":927,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":5,"ok":true,"durationMs":22932,"total":927,"classic":927,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":6,"ok":true,"durationMs":22756,"total":887,"classic":887,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":7,"ok":true,"durationMs":22883,"total":26318,"classic":26318,"grail":0,"other":0,"nClassic":4,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"prod","run":8,"ok":true,"durationMs":23037,"total":927,"classic":927,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":9,"ok":true,"durationMs":22764,"total":28086,"classic":28086,"grail":0,"other":0,"nClassic":6,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"prod","run":10,"ok":true,"durationMs":23112,"total":66051,"classic":66051,"grail":0,"other":0,"nClassic":25,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":11,"ok":true,"durationMs":22841,"total":26297,"classic":26297,"grail":0,"other":0,"nClassic":4,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"prod","run":12,"ok":true,"durationMs":23344,"total":928,"classic":928,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
+{"env":"prod","run":13,"ok":true,"durationMs":22903,"total":25828,"classic":25828,"grail":0,"other":0,"nClassic":4,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":3,"nosrMarkers":3}
+{"env":"prod","run":14,"ok":true,"durationMs":22759,"total":927,"classic":927,"grail":0,"other":0,"nClassic":1,"nGrail":0,"nOther":0,"agentGet":2,"srMarkers":2,"nosrMarkers":2}
diff --git a/packages/react-scripts/tools/dynatrace/beacon-harness/measure-beacons.mjs b/packages/react-scripts/tools/dynatrace/beacon-harness/measure-beacons.mjs
new file mode 100644
index 00000000000..95b71fc2613
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/measure-beacons.mjs
@@ -0,0 +1,101 @@
+import { chromium } from 'playwright'
+import fs from 'fs'
+
+// Per-session Dynatrace upload volume, split by beacon channel.
+//
+// Session Replay is sampled (int: 10%), so rather than decoding an undocumented wire
+// format we run many sessions and let replay ones separate by volume. Two independent
+// signals per run: total upload bytes, and the _sr_/_nosr_ markers the Classic beacon
+// carries inline.
+//
+// Channels:
+// classic (type=js3) form-encoded RUM Classic beacon
+// grail (ty=js&cy=event) JSON New RUM beacon
+// other anything else on the beacon host
+
+const ENVS = {
+ int: 'https://integration.familysearch.org/en/frontier/app-react/',
+ beta: 'https://beta.familysearch.org/en/frontier/app-react/',
+ prod: 'https://www.familysearch.org/en/frontier/app-react/',
+}
+
+const env = process.argv[2] || 'int'
+const RUNS = Number(process.argv[3] || 10)
+const DWELL_MS = Number(process.argv[4] || 20000)
+const OUT = new URL(`./data/v2-${env}.jsonl`, import.meta.url)
+const UA =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
+
+const browser = await chromium.launch()
+
+for (let i = 0; i < RUNS; i++) {
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, userAgent: UA })
+ const page = await ctx.newPage()
+
+ const ch = { classic: 0, grail: 0, other: 0, agentGet: 0 }
+ const n = { classic: 0, grail: 0, other: 0 }
+ let srMarkers = 0
+ let nosrMarkers = 0
+
+ page.on('request', req => {
+ const u = req.url()
+ if (!/dynatrace\.com/.test(u)) return
+ const parsed = new URL(u)
+ let bytes = 0
+ let body = ''
+ try {
+ const b = req.postDataBuffer()
+ if (b) {
+ bytes = b.byteLength
+ body = b.toString('latin1')
+ }
+ } catch {}
+ if (req.method() === 'GET') {
+ ch.agentGet += 1
+ return
+ }
+ const q = parsed.searchParams
+ let kind = 'other'
+ if (q.get('type') === 'js3') kind = 'classic'
+ else if (q.get('cy') === 'event' || q.get('ty') === 'js') kind = 'grail'
+ ch[kind] += bytes
+ n[kind] += 1
+ // _sr_ / _nosr_ appear URL-encoded as %7C_sr_%7C in the classic payload
+ srMarkers += (body.match(/_sr_/g) || []).length
+ nosrMarkers += (body.match(/_nosr_/g) || []).length
+ })
+
+ const t0 = Date.now()
+ let ok = true
+ try {
+ await page.goto(ENVS[env], { waitUntil: 'domcontentloaded', timeout: 45000 })
+ const deadline = Date.now() + DWELL_MS
+ let tick = 0
+ while (Date.now() < deadline) {
+ tick++
+ await page.mouse.move(200 + (tick % 400), 200 + ((tick * 7) % 300))
+ await page.evaluate(y => window.scrollTo(0, y), (tick % 6) * 250).catch(() => {})
+ await page.waitForTimeout(600)
+ }
+ await page.waitForTimeout(2000) // let the agent flush its final batch
+ } catch {
+ ok = false
+ }
+ await ctx.close()
+
+ const rec = {
+ env, run: i, ok,
+ durationMs: Date.now() - t0,
+ total: ch.classic + ch.grail + ch.other,
+ classic: ch.classic, grail: ch.grail, other: ch.other,
+ nClassic: n.classic, nGrail: n.grail, nOther: n.other,
+ agentGet: ch.agentGet,
+ srMarkers, nosrMarkers,
+ }
+ fs.appendFileSync(OUT, JSON.stringify(rec) + '\n')
+ console.log(
+ `${env} ${i + 1}/${RUNS} total=${rec.total} classic=${rec.classic} grail=${rec.grail} sr=${srMarkers} nosr=${nosrMarkers}`
+ )
+}
+
+await browser.close()
diff --git a/packages/react-scripts/tools/dynatrace/beacon-harness/package.json b/packages/react-scripts/tools/dynatrace/beacon-harness/package.json
new file mode 100644
index 00000000000..567fcba69a1
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "dynatrace-beacon-harness",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Measures Dynatrace RUM beacon upload volume per session against deployed environments. Standalone: not part of the react-scripts build or the yarn workspace (workspaces glob is packages/*, direct children only).",
+ "type": "module",
+ "scripts": {
+ "measure": "node measure-beacons.mjs",
+ "analyze": "node analyze.mjs",
+ "probe": "node probe-channels.mjs"
+ },
+ "devDependencies": {
+ "playwright": "1.62.0"
+ }
+}
diff --git a/packages/react-scripts/tools/dynatrace/beacon-harness/probe-channels.mjs b/packages/react-scripts/tools/dynatrace/beacon-harness/probe-channels.mjs
new file mode 100644
index 00000000000..5b00c07ede9
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/beacon-harness/probe-channels.mjs
@@ -0,0 +1,59 @@
+import { chromium } from 'playwright'
+
+const ENVS = {
+ int: 'https://integration.familysearch.org/en/frontier/app-react/',
+ beta: 'https://beta.familysearch.org/en/frontier/app-react/',
+}
+const env = process.argv[2] || 'int'
+const DWELL_MS = Number(process.argv[3] || 15000)
+
+const browser = await chromium.launch()
+const ctx = await browser.newContext({
+ viewport: { width: 1280, height: 900 },
+ userAgent:
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
+})
+const page = await ctx.newPage()
+
+const rows = []
+page.on('request', req => {
+ const u = req.url()
+ if (!/dynatrace\.com/.test(u)) return
+ const parsed = new URL(u)
+ if (!/\/bf\b/.test(parsed.pathname)) return
+ let body = ''
+ let bytes = 0
+ try {
+ const b = req.postDataBuffer()
+ if (b) {
+ bytes = b.byteLength
+ body = b.toString('utf8')
+ }
+ } catch {}
+ rows.push({
+ params: Object.fromEntries(parsed.searchParams.entries()),
+ bytes,
+ head: body.slice(0, 180).replace(/\s+/g, ' '),
+ })
+})
+
+await page.goto(ENVS[env], { waitUntil: 'domcontentloaded', timeout: 45000 })
+const deadline = Date.now() + DWELL_MS
+let tick = 0
+while (Date.now() < deadline) {
+ tick++
+ await page.mouse.move(200 + (tick % 400), 200 + ((tick * 7) % 300))
+ await page.evaluate(y => window.scrollTo(0, y), (tick % 6) * 250).catch(() => {})
+ await page.waitForTimeout(600)
+}
+await page.waitForTimeout(1500)
+await browser.close()
+
+console.log(`\n===== ${env}: ${rows.length} beacon POSTs =====`)
+for (const [i, r] of rows.entries()) {
+ const keys = Object.keys(r.params).sort().join(',')
+ console.log(`\n[${i}] bytes=${r.bytes}`)
+ console.log(` paramKeys: ${keys}`)
+ console.log(` type=${r.params.type} ty=${r.params.ty} cy=${r.params.cy} sc=${r.params.sc}`)
+ console.log(` body: ${r.head}`)
+}
diff --git a/packages/react-scripts/tools/dynatrace/dynatrace-rum-config.json b/packages/react-scripts/tools/dynatrace/dynatrace-rum-config.json
new file mode 100644
index 00000000000..9033ca3fc16
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/dynatrace-rum-config.json
@@ -0,0 +1,23 @@
+{
+ "generated": "2026-08-06T16:22:47.552Z",
+ "cdnUrls": {
+ "int": "https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789MNPQRTUVXfghqrux_10343260715123811.js",
+ "beta": "https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789NPRTUVXfghqrux_10343260715123811.js",
+ "prod": "https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA7NQVfghqrux_10341260622154106.js"
+ },
+ "cdnIntegrity": {
+ "int": "sha256-8SY/pAJr28AjGTRbzyKLcD4kPiJQIJaT2EfcY59FoWE=",
+ "beta": "sha256-PeyllVOccLjCaea3jYyf0w5YCMFa54GWyc4B3eRQn4g=",
+ "prod": "sha256-eGB6PLc8ndZISmjwbbi4xZhE9nbarKf48bRKxVdha+8="
+ },
+ "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=1786000244457|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=ICA15789NPRTUVXfghqrux|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=1786000244457|tp=500,50,0|srbbv=2|agentUri=https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA15789NPRTUVXfghqrux_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=1786000244457|tp=500,50,0|srbbv=2|agentUri=https://js-cdn.dynatrace.com/jstag/15c157a40ab/sri/ruxitagent_ICA7NQVfghqrux_10341260622154106.js"
+ },
+ "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"
+ }
+}
\ No newline at end of file
diff --git a/packages/react-scripts/tools/dynatrace/fetch-dynatrace-scripts.js b/packages/react-scripts/tools/dynatrace/fetch-dynatrace-scripts.js
new file mode 100755
index 00000000000..71453c77959
--- /dev/null
+++ b/packages/react-scripts/tools/dynatrace/fetch-dynatrace-scripts.js
@@ -0,0 +1,414 @@
+#!/usr/bin/env node
+/**
+ * Dynatrace RUM Script Fetcher
+ *
+ * Fetches the latest RUM inline scripts and tags from Dynatrace using the API.
+ * Automatically updates inline script files, dynatrace.ejs fallbacks, and publishes
+ * dynatrace-rum-config.json to S3 for Snow to consume.
+ *
+ * Requires:
+ * - Dynatrace API token with "Read RUM manual insertion tags" scope
+ * - AWS CLI configured with S3 write access to the CDN bucket
+ *
+ * IMPORTANT: Dynatrace API may not be available on all instances.
+ * If unavailable, fetch scripts manually from:
+ * Dynatrace UI → Settings → Real User Monitoring → Managed JavaScript
+ *
+ * Usage:
+ * node fetch-dynatrace-scripts.js # reads token from keychain
+ * DYNATRACE_API_TOKEN="xxx" node fetch-dynatrace-scripts.js # override for one run
+ *
+ * Setup (one-time):
+ * 1. Store Dynatrace API token in keychain:
+ * security add-generic-password -a dynatrace-rum-fetch -s dynatrace-rum-fetch -w
+ *
+ * 2. Configure AWS CLI (uses standard AWS credential chain):
+ * aws configure # or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY env vars
+ * aws s3 ls # verify you have S3 access
+ *
+ * Configuration:
+ * - Dynatrace token: auto-read from keychain; override with DYNATRACE_API_TOKEN
+ * - AWS credentials: read via AWS CLI (no keychain needed; uses ~/.aws/credentials or env vars)
+ * - S3 bucket/region: set via S3_PUBLISH_BUCKET and S3_PUBLISH_REGION env vars
+ * - Dynatrace/Entity IDs: set via DYNATRACE_ENV_URL and *_ENTITY_ID env vars
+ *
+ * API Documentation:
+ * https://docs.dynatrace.com/docs/dynatrace-api/environment-api/rum/rum-manual-insertion-tags
+ */
+
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const KEYCHAIN_ACCOUNT = 'dynatrace-rum-fetch';
+const KEYCHAIN_SERVICE_DYNATRACE = 'dynatrace-rum-fetch';
+const KEYCHAIN_SERVICE_S3 = 's3-publish-key';
+
+function getKeychainSecret(service) {
+ try {
+ const secret = execSync(
+ `security find-generic-password -a "${KEYCHAIN_ACCOUNT}" -s "${service}" -w`,
+ { stdio: ['pipe', 'pipe', 'pipe'] }
+ ).toString().trim();
+ return secret || null;
+ } catch (error) {
+ return null;
+ }
+}
+
+function printKeychainSetupInstructions() {
+ console.error(`
+ERROR: No Dynatrace API token found in keychain.
+
+To store your Dynatrace token (one-time setup):
+
+ security add-generic-password \\
+ -a "${KEYCHAIN_ACCOUNT}" \\
+ -s "${KEYCHAIN_SERVICE_DYNATRACE}" \\
+ -w
+
+You'll be prompted to enter the token (won't appear in shell history).
+
+To get a Dynatrace token:
+ 1. Log into https://bjm35087.live.dynatrace.com
+ 2. Go to Account → Access Tokens → Generate new token
+ 3. Add scope: "Read RUM manual insertion tags"
+ 4. Paste the token when prompted above
+
+To update an existing token:
+ security delete-generic-password -a "${KEYCHAIN_ACCOUNT}" -s "${KEYCHAIN_SERVICE_DYNATRACE}"
+ security add-generic-password -a "${KEYCHAIN_ACCOUNT}" -s "${KEYCHAIN_SERVICE_DYNATRACE}" -w
+
+For AWS S3 access (used for publishing config):
+ Ensure AWS CLI is configured with credentials:
+ aws configure
+ Or set environment variables:
+ export AWS_ACCESS_KEY_ID="your-key"
+ export AWS_SECRET_ACCESS_KEY="your-secret"
+
+Or override Dynatrace token for a single run:
+ DYNATRACE_API_TOKEN="dt0c01.xxx" node fetch-dynatrace-scripts.js
+`);
+}
+
+// Configuration
+const DYNATRACE_ENVIRONMENT_URL = process.env.DYNATRACE_ENV_URL || "https://bjm35087.live.dynatrace.com";
+const DYNATRACE_API_TOKEN = process.env.DYNATRACE_API_TOKEN || getKeychainSecret(KEYCHAIN_SERVICE_DYNATRACE);
+
+// S3 CDN configuration (for publishing dynatrace-rum-config.json via AWS CLI)
+const S3_PUBLISH_BUCKET = process.env.S3_PUBLISH_BUCKET || "fs-cdn2-origin/assets/dynatrace";
+const S3_PUBLISH_REGION = process.env.S3_PUBLISH_REGION || "us-east-1";
+// AWS auth uses the standard credential chain. The `aws` child processes below inherit this
+// process's env, so select a profile the normal AWS way — no script-specific env var needed:
+// aws sso login --profile frontier-admin
+// export AWS_PROFILE=frontier-admin (or run inline: AWS_PROFILE=frontier-admin node ...)
+
+// Entity IDs for your RUM applications in each environment
+// Get from Dynatrace UI: Applications → select app → Settings → note the entity ID
+const ENTITY_IDS = {
+ int: process.env.INT_ENTITY_ID || "APPLICATION-3FAF90E849295814",
+ beta: process.env.BETA_ENTITY_ID || "APPLICATION-C4242BB1EB216374",
+ prod: process.env.PROD_ENTITY_ID || "APPLICATION-A8E5EDD77F861ACE",
+};
+
+function parseUrl(urlString) {
+ const url = new URL(urlString);
+ return {
+ hostname: url.hostname,
+ path: url.pathname.replace(/\/$/, ''),
+ };
+}
+
+async function makeRequest(hostname, path) {
+ return new Promise((resolve, reject) => {
+ const options = {
+ hostname,
+ path,
+ method: 'GET',
+ headers: {
+ 'Authorization': `Api-Token ${DYNATRACE_API_TOKEN}`,
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ https.request(options, (res) => {
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', () => {
+ if (res.statusCode >= 200 && res.statusCode < 300) {
+ resolve(data);
+ } else {
+ reject(`HTTP ${res.statusCode}: ${data}`);
+ }
+ });
+ }).on('error', reject).end();
+ });
+}
+
+function extractEnvValues(results) {
+ const envs = Object.keys(results);
+ return {
+ cdnUrls: Object.fromEntries(envs.map(env => [
+ env, results[env].completeTag.match(/src="([^"]+)"/)?.[1] || ''
+ ])),
+ cdnIntegrity: Object.fromEntries(envs.map(env => [
+ env, results[env].completeTag.match(/integrity="([^"]+)"/)?.[1] || ''
+ ])),
+ cdnConfig: Object.fromEntries(envs.map(env => [
+ env, results[env].completeTag.match(/data-dtconfig="([^"]+)"/)?.[1] || ''
+ ])),
+ // Combined code+config file (the "JavaScript tag" _complete.js) used by the global-cdn treatment.
+ cdnCompleteUrls: Object.fromEntries(envs.map(env => [
+ env, results[env].simpleTag.match(/src="([^"]+)"/)?.[1] || ''
+ ])),
+ };
+}
+
+// Parse the Dynatrace agent version — the trailing number in ruxitagent__.js —
+// from each environment's tag, e.g. ...ruxitagent_ICA7NQVfghqrux_10341260622154106.js → 10341260622154106.
+function extractAgentVersions(results) {
+ return Object.fromEntries(Object.keys(results).map(env => {
+ const src = results[env].completeTag.match(/src="([^"]+)"/)?.[1] || '';
+ const version = src.match(/ruxitagent_[A-Za-z0-9]+_(\d+)\.js/)?.[1] || 'unknown';
+ return [env, version];
+ }));
+}
+
+function updateDynatraceEjs(results) {
+ const ejsPath = path.join(__dirname, '../../layout/views/partials/dynatrace.ejs');
+ let content = fs.readFileSync(ejsPath, 'utf8');
+ const values = extractEnvValues(results);
+
+ // Rewrite the hardcoded fallback object inside a
+ // const = (locals && locals.dynatrace && locals.dynatrace.) || { ... }
+ // block, preserving the locals-first progressive-enhancement guard.
+ const replaceFallback = (name, localsKey, map) => {
+ const lines = Object.entries(map).map(([env, v]) => ` ${env}: '${v}'`).join(',\n');
+ const re = new RegExp(
+ `const ${name} = \\(locals && locals\\.dynatrace && locals\\.dynatrace\\.${localsKey}\\) \\|\\| \\{[^}]+\\}`,
+ 's'
+ );
+ if (!re.test(content)) {
+ console.warn(` ⚠️ Could not find ${name} fallback block in dynatrace.ejs — skipped`);
+ return;
+ }
+ const replacement = `const ${name} = (locals && locals.dynatrace && locals.dynatrace.${localsKey}) || {\n${lines}\n }`;
+ // Use a function replacement so '$' in values is not treated as a substitution token.
+ content = content.replace(re, () => replacement);
+ };
+
+ replaceFallback('cdnUrlsNew', 'cdnUrls', values.cdnUrls);
+ replaceFallback('cdnIntegrityNew', 'cdnIntegrity', values.cdnIntegrity);
+ replaceFallback('cdnConfigNew', 'cdnConfig', values.cdnConfig);
+ replaceFallback('cdnCompleteUrlsNew', 'cdnCompleteUrls', values.cdnCompleteUrls);
+
+ fs.writeFileSync(ejsPath, content, 'utf8');
+ console.log(" ✅ Updated dynatrace.ejs fallback values");
+}
+
+function writeCdnConfigJson(results) {
+ const values = extractEnvValues(results);
+ const config = {
+ generated: new Date().toISOString(),
+ ...values,
+ };
+ const configPath = path.join(__dirname, 'dynatrace-rum-config.json');
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
+ console.log(" ✅ Wrote dynatrace-rum-config.json");
+ return config;
+}
+
+function printAwsSetupInstructions() {
+ const active = process.env.AWS_PROFILE || "";
+ console.error(`
+AWS credentials are missing or expired${active ? ` for profile "${active}"` : " (no profile set — using the default credential chain)"}.
+
+This script publishes dynatrace-rum-config.json to s3://${S3_PUBLISH_BUCKET} and needs S3 write access.
+
+If your org uses AWS SSO (FamilySearch does), run:
+ export AWS_PROFILE=frontier-admin # so this script's aws calls use that profile
+ aws sso login # refresh the SSO session (opens a browser)
+ # ...then re-run this script
+
+Or pin the profile for a single run without exporting it:
+ AWS_PROFILE=frontier-admin node packages/react-scripts/tools/dynatrace/fetch-dynatrace-scripts.js
+
+Verify your identity any time:
+ aws sts get-caller-identity${active ? ` --profile ${active}` : " --profile frontier-admin"}
+
+(For static keys instead of SSO: run 'aws configure', or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.)
+`);
+}
+
+// Preflight: confirm AWS credentials resolve before doing any Dynatrace work, so we fail
+// fast with actionable guidance instead of after fetching everything (publish is the last step).
+function checkAwsCredentials() {
+ if (!process.env.AWS_PROFILE) {
+ console.warn(
+ " ⚠️ AWS_PROFILE is not set — falling back to the default credential chain.\n" +
+ " In this setup the default profile has no credentials, so the S3 publish will\n" +
+ " likely fail. Set it first: export AWS_PROFILE=frontier-admin\n" +
+ " (after: aws sso login --profile frontier-admin)"
+ );
+ }
+ try {
+ const out = execSync(`aws sts get-caller-identity --output json`, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ }).toString();
+ const id = JSON.parse(out);
+ const profileNote = process.env.AWS_PROFILE || "";
+ console.log(` ✅ AWS credentials OK — account ${id.Account}${profileNote ? `, profile ${profileNote}` : ""}`);
+ return true;
+ } catch (error) {
+ printAwsSetupInstructions();
+ return false;
+ }
+}
+
+async function publishToS3(config) {
+ const tempFile = path.join(__dirname, '.dynatrace-rum-config-temp.json');
+ try {
+ fs.writeFileSync(tempFile, JSON.stringify(config), 'utf8');
+
+ // Use AWS CLI for S3 upload (inherits this process's env → honors AWS_PROFILE / default chain)
+ const awsCmd = `aws s3 cp "${tempFile}" s3://${S3_PUBLISH_BUCKET}/dynatrace-rum-config.json --region ${S3_PUBLISH_REGION} --metadata "generated=$(date +%s)" --cache-control "max-age=300" --acl public-read`;
+
+ execSync(awsCmd, { stdio: 'inherit' });
+
+ console.log(` ✅ Published dynatrace-rum-config.json to S3 (s3://${S3_PUBLISH_BUCKET}/dynatrace-rum-config.json)`);
+ } catch (error) {
+ console.error(` ❌ Failed to publish to S3: ${error.message}`);
+ printAwsSetupInstructions();
+ throw error;
+ } finally {
+ // Always remove the temp file, even if the upload failed.
+ try { fs.unlinkSync(tempFile); } catch (_) { /* already gone */ }
+ }
+}
+
+async function fetchScripts() {
+ if (!DYNATRACE_API_TOKEN) {
+ printKeychainSetupInstructions();
+ process.exit(1);
+ }
+
+ const missingIds = Object.entries(ENTITY_IDS)
+ .filter(([, id]) => !id)
+ .map(([env]) => env);
+
+ if (missingIds.length > 0) {
+ console.error(`ERROR: Missing entity IDs for: ${missingIds.join(", ")}`);
+ console.error("Set environment variables: INT_ENTITY_ID, BETA_ENTITY_ID, PROD_ENTITY_ID");
+ process.exit(1);
+ }
+
+ // Preflight AWS creds up front — publishing to S3 is the last step, and the Dynatrace
+ // fetch is wasted work if we can't publish. Fail fast with setup guidance instead.
+ console.log("Checking AWS credentials...");
+ if (!checkAwsCredentials()) {
+ process.exit(1);
+ }
+
+ try {
+ const { hostname, path: basePath } = parseUrl(DYNATRACE_ENVIRONMENT_URL);
+ console.log("Fetching Dynatrace RUM scripts...\n");
+
+ const results = {};
+
+ for (const [env, entityId] of Object.entries(ENTITY_IDS)) {
+ console.log(`📥 Fetching ${env.toUpperCase()} environment (entity: ${entityId})...`);
+
+ try {
+ // Fetch simple JavaScript tag (basic, no SRI)
+ const simplePath = `${basePath}/api/v2/rum/javaScriptTag/${entityId}`;
+ const simpleResponse = await makeRequest(hostname, simplePath);
+
+ // Fetch OneAgent JavaScript tag with SRI (for CDN URL, config, integrity hash)
+ const tagPath = `${basePath}/api/v2/rum/oneAgentJavaScriptTagWithSri/${entityId}`;
+ const tagResponse = await makeRequest(hostname, tagPath);
+
+ // NOTE: we intentionally no longer fetch /inlineCode. The new RUM agent is loaded via
+ //