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 + //