@newrelic/browser-agent 1.321.0 → 1.322.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cjs/common/constants/env.cdn.js +1 -1
  3. package/dist/cjs/common/constants/env.npm.js +1 -1
  4. package/dist/cjs/common/util/console.js +3 -0
  5. package/dist/cjs/common/v2/manifest.js +142 -0
  6. package/dist/cjs/common/v2/script-tracker-types.js +27 -0
  7. package/dist/cjs/common/v2/script-tracker.js +223 -25
  8. package/dist/cjs/common/v2/utils.js +47 -1
  9. package/dist/cjs/common/vitals/cumulative-layout-shift.js +6 -4
  10. package/dist/cjs/common/vitals/first-contentful-paint.js +5 -3
  11. package/dist/cjs/common/vitals/interaction-to-next-paint.js +10 -4
  12. package/dist/cjs/common/vitals/largest-contentful-paint.js +5 -3
  13. package/dist/cjs/common/vitals/register-vital.js +25 -0
  14. package/dist/cjs/common/vitals/time-to-first-byte.js +4 -2
  15. package/dist/cjs/common/vitals/vital-metric.js +3 -2
  16. package/dist/cjs/features/generic_events/aggregate/index.js +31 -14
  17. package/dist/cjs/features/generic_events/instrument/index.js +41 -1
  18. package/dist/cjs/interfaces/registered-iframe-entity.js +9 -3
  19. package/dist/cjs/loaders/api/register-api-types.js +13 -4
  20. package/dist/cjs/loaders/api/register.js +34 -1
  21. package/dist/esm/common/constants/env.cdn.js +1 -1
  22. package/dist/esm/common/constants/env.npm.js +1 -1
  23. package/dist/esm/common/util/console.js +3 -0
  24. package/dist/esm/common/v2/manifest.js +136 -0
  25. package/dist/esm/common/v2/script-tracker-types.js +24 -0
  26. package/dist/esm/common/v2/script-tracker.js +222 -26
  27. package/dist/esm/common/v2/utils.js +46 -1
  28. package/dist/esm/common/vitals/cumulative-layout-shift.js +6 -4
  29. package/dist/esm/common/vitals/first-contentful-paint.js +5 -3
  30. package/dist/esm/common/vitals/interaction-to-next-paint.js +10 -4
  31. package/dist/esm/common/vitals/largest-contentful-paint.js +5 -3
  32. package/dist/esm/common/vitals/register-vital.js +19 -0
  33. package/dist/esm/common/vitals/time-to-first-byte.js +4 -2
  34. package/dist/esm/common/vitals/vital-metric.js +3 -2
  35. package/dist/esm/features/generic_events/aggregate/index.js +32 -15
  36. package/dist/esm/features/generic_events/instrument/index.js +41 -1
  37. package/dist/esm/interfaces/registered-iframe-entity.js +9 -3
  38. package/dist/esm/loaders/api/register-api-types.js +14 -4
  39. package/dist/esm/loaders/api/register.js +34 -2
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/dist/types/common/config/init-types.d.ts +1 -1
  42. package/dist/types/common/util/console.d.ts +3 -0
  43. package/dist/types/common/util/console.d.ts.map +1 -1
  44. package/dist/types/common/v2/manifest.d.ts +66 -0
  45. package/dist/types/common/v2/manifest.d.ts.map +1 -0
  46. package/dist/types/common/v2/script-tracker-types.d.ts +25 -0
  47. package/dist/types/common/v2/script-tracker-types.d.ts.map +1 -0
  48. package/dist/types/common/v2/script-tracker.d.ts +25 -4
  49. package/dist/types/common/v2/script-tracker.d.ts.map +1 -1
  50. package/dist/types/common/v2/utils.d.ts +12 -0
  51. package/dist/types/common/v2/utils.d.ts.map +1 -1
  52. package/dist/types/common/vitals/cumulative-layout-shift.d.ts.map +1 -1
  53. package/dist/types/common/vitals/first-contentful-paint.d.ts.map +1 -1
  54. package/dist/types/common/vitals/interaction-to-next-paint.d.ts.map +1 -1
  55. package/dist/types/common/vitals/largest-contentful-paint.d.ts.map +1 -1
  56. package/dist/types/common/vitals/register-vital.d.ts +13 -0
  57. package/dist/types/common/vitals/register-vital.d.ts.map +1 -0
  58. package/dist/types/common/vitals/time-to-first-byte.d.ts.map +1 -1
  59. package/dist/types/common/vitals/vital-metric.d.ts +1 -1
  60. package/dist/types/common/vitals/vital-metric.d.ts.map +1 -1
  61. package/dist/types/features/generic_events/aggregate/index.d.ts.map +1 -1
  62. package/dist/types/features/generic_events/instrument/index.d.ts.map +1 -1
  63. package/dist/types/interfaces/registered-iframe-entity.d.ts.map +1 -1
  64. package/dist/types/loaders/api/register-api-types.d.ts +31 -4
  65. package/dist/types/loaders/api/register-api-types.d.ts.map +1 -1
  66. package/dist/types/loaders/api/register.d.ts +3 -0
  67. package/dist/types/loaders/api/register.d.ts.map +1 -1
  68. package/package.json +3 -3
  69. package/src/common/util/console.js +3 -0
  70. package/src/common/v2/manifest.js +126 -0
  71. package/src/common/v2/script-tracker-types.js +24 -0
  72. package/src/common/v2/script-tracker.js +218 -26
  73. package/src/common/v2/utils.js +42 -1
  74. package/src/common/vitals/cumulative-layout-shift.js +5 -3
  75. package/src/common/vitals/first-contentful-paint.js +5 -3
  76. package/src/common/vitals/interaction-to-next-paint.js +8 -3
  77. package/src/common/vitals/largest-contentful-paint.js +5 -3
  78. package/src/common/vitals/register-vital.js +19 -0
  79. package/src/common/vitals/time-to-first-byte.js +4 -2
  80. package/src/common/vitals/vital-metric.js +3 -2
  81. package/src/features/generic_events/aggregate/index.js +25 -15
  82. package/src/features/generic_events/instrument/index.js +39 -1
  83. package/src/interfaces/registered-iframe-entity.js +7 -2
  84. package/src/loaders/api/register-api-types.js +14 -4
  85. package/src/loaders/api/register.js +29 -2
@@ -12,8 +12,10 @@ import { CORRELATION_STALE_THRESHOLD_MS } from './script-tracker-constants';
12
12
  import { timingFactory } from './timing-factory';
13
13
 
14
14
  /**
15
- * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
15
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITimings} RegisterAPITimings
16
16
  * @typedef {import('../../loaders/api/register-api-types').RegisterAPITarget} RegisterAPITarget
17
+ * @typedef {import('./script-tracker-types').RecordManifestScriptWindowFn} RecordManifestScriptWindowFn
18
+ * @typedef {import('./script-tracker-types').TimingsInternals} TimingsInternals
17
19
  */
18
20
 
19
21
  /** export for testing purposes */
@@ -33,11 +35,43 @@ export const scriptCorrelations = new Map();
33
35
  let poSubscribers = [];
34
36
 
35
37
  /**
36
- * Retrieves a script correlation by URL using exact matching
38
+ * Bookkeeping keyed by a `timings` object, kept off the object itself since it's exposed directly to customers via
39
+ * `register().metadata.timings`.
40
+ * @type {WeakMap<RegisterAPITimings, TimingsInternals>}
41
+ */
42
+ const timingsInternals = new WeakMap();
43
+
44
+ /**
45
+ * Gets (or lazily creates) the bookkeeping record for a `timings` object. A fresh record's `recordManifestScriptWindow`
46
+ * defaults to widening `timings.scriptStart`/`scriptEnd` directly -- correct for a plain `timings` object never
47
+ * produced by `findScriptTimings`. `findScriptTimings` overrides that default with one that folds into its live
48
+ * getters instead.
49
+ * @param {RegisterAPITimings} timings
50
+ * @returns {TimingsInternals}
51
+ */
52
+ function getOrCreateInternals(timings) {
53
+ let internals = timingsInternals.get(timings);
54
+ if (!internals) {
55
+ internals = {
56
+ weighedAssetUrls: new Set(),
57
+ recordManifestScriptWindow: (start, end) => {
58
+ if (start) timings.scriptStart = timings.scriptStart > 0 ? Math.min(timings.scriptStart, start) : start;
59
+ if (end) timings.scriptEnd = timings.scriptEnd > 0 ? Math.max(timings.scriptEnd, end) : end;
60
+ }
61
+ };
62
+ timingsInternals.set(timings, internals);
63
+ }
64
+ return internals;
65
+ }
66
+
67
+ /**
68
+ * Retrieves a script correlation by URL using exact matching. Exported so other features (e.g. generic_events'
69
+ * resource attribution) can key off the same DOM node/load-timing tracking this module already does for every
70
+ * `<script>` element, rather than setting up a second, redundant observer.
37
71
  * @param {string} targetUrl - The URL to find
38
72
  * @returns {ScriptCorrelation | undefined} - The correlation object if found
39
73
  */
40
- function findCorrelation(targetUrl) {
74
+ export function findCorrelation(targetUrl) {
41
75
  return scriptCorrelations.get(targetUrl);
42
76
  }
43
77
 
@@ -86,20 +120,23 @@ if (globalScope.MutationObserver && globalScope.document) {
86
120
  });
87
121
  }
88
122
  if (globalScope.PerformanceObserver?.supportedEntryTypes.includes('resource')) {
89
- /** We must track the script assets this way, because the performance buffer can fill up and when it does that
90
- * it stops accepting new entries (instead of dropping old entries), which means if the register API is called
91
- * after the buffer fills up we won't be able to get the script timing information from the resource timing API
92
- */
123
+ // Tracked via an observer (not a later buffer read) because the performance buffer stops accepting new entries
124
+ // once full, instead of dropping old ones -- a late register() call could otherwise miss timing entirely.
93
125
  const scriptObserver = new PerformanceObserver(list => {
94
- list.getEntries().filter(validEntryCriteria).forEach(entry => {
95
- // Update correlation with performance data (creates entry if needed)
96
- const entryUrl = cleanURL(entry.name);
97
- const correlation = getOrCreateCorrelation(entryUrl);
98
- correlation.performance.start = Math.floor(entry.startTime);
99
- correlation.performance.end = Math.floor(entry.responseEnd);
100
- correlation.performance.value = entry;
101
-
102
- // Clear resolved or expired subscribers
126
+ list.getEntries().forEach(entry => {
127
+ // Correlation bookkeeping only makes sense for script-like entries -- gated on validEntryCriteria so
128
+ // scriptCorrelations doesn't grow for every image/css/font load on the page.
129
+ if (validEntryCriteria(entry)) {
130
+ const entryUrl = cleanURL(entry.name);
131
+ const correlation = getOrCreateCorrelation(entryUrl);
132
+ correlation.performance.start = Math.floor(entry.startTime);
133
+ correlation.performance.end = Math.floor(entry.responseEnd);
134
+ correlation.performance.value = entry;
135
+ }
136
+
137
+ // Late-resolution subscribers can be for any asset type (not just scripts), so every entry is checked here,
138
+ // unfiltered. Skipped when nothing is pending, the common case.
139
+ if (!poSubscribers.length) return;
103
140
  const canClear = [];
104
141
  poSubscribers.forEach(({
105
142
  test,
@@ -198,6 +235,33 @@ function applyPerformanceEntry(timings, entry) {
198
235
  timings.fetchEnd = Math.floor(entry.responseEnd);
199
236
  timings.asset = entry.name;
200
237
  timings.type = entry.initiatorType;
238
+ applyResourceWeight(timings, entry);
239
+ }
240
+
241
+ /**
242
+ * Accumulates the byte weight and render-blocking status of a single detected asset (the entry script or a resolved
243
+ * manifest asset) into a timings object. Shared by both the entry-script path (`applyPerformanceEntry`) and the
244
+ * manifest path (`applyManifestEntry`) so `totalWeight`/`renderBlocking` reflect every asset actually detected,
245
+ * regardless of which path found it.
246
+ * @param {RegisterAPITimings} timings
247
+ * @param {PerformanceResourceTiming} entry
248
+ */
249
+ function applyResourceWeight(timings, entry) {
250
+ // De-dupe by cleaned URL: a manifest can list the .register calling script itself as one of its own assets,
251
+ // which would otherwise weigh the same resource twice (once via findScriptTimings, once via applyManifestTimings).
252
+ const url = cleanURL(entry.name);
253
+ const {
254
+ weighedAssetUrls
255
+ } = getOrCreateInternals(timings);
256
+ if (weighedAssetUrls.has(url)) return;
257
+ weighedAssetUrls.add(url);
258
+
259
+ // transferSize is 0 for cross-origin responses without Timing-Allow-Origin (a privacy restriction, not a
260
+ // zero-byte asset) -- adding 0 is correct either way.
261
+ timings.totalWeight = (timings.totalWeight || 0) + (entry.transferSize || 0);
262
+ // 'blocking' always wins and never gets downgraded; 'non-blocking' only applies if nothing already resolved
263
+ // true; no value at all (unsupported browser) leaves renderBlocking untouched (stays `undefined`).
264
+ if (entry.renderBlockingStatus === 'blocking') timings.renderBlocking = true;else if (entry.renderBlockingStatus === 'non-blocking' && timings.renderBlocking !== true) timings.renderBlocking = false;
201
265
  }
202
266
 
203
267
  /**
@@ -220,8 +284,114 @@ function subscribeToLatePerformanceEntry(timings, mfeScriptUrl) {
220
284
  }
221
285
 
222
286
  /**
223
- * Uses the stack of the initiator function, returns script timing information if a script can be found with the resource timing API matching the URL found in the stack.
224
- * @param {RegisterAPITarget} [target] - The MFE target being registered. Its id is used to scope stale-correlation detection per-MFE rather than per-script-URL, so one script registering multiple distinct MFEs doesn't misclassify a later MFE's registration as a stale reuse of an earlier one's.
287
+ * Applies one manifest asset's performance entry to a timings object: weight/renderBlocking always accumulate;
288
+ * fetchStart/fetchEnd and scriptStart/scriptEnd widen (never shrink) only when `timingMethod` calls for it; asset/
289
+ * type get anchored to the first script asset seen to resolve.
290
+ * @param {RegisterAPITimings} timings
291
+ * @param {PerformanceResourceTiming} entry
292
+ * @param {import('./manifest').ParsedManifestAsset} asset - the manifest asset this entry resolved
293
+ * @param {{ resolved: boolean }} entryState - shared "first script asset wins" guard for a single `applyManifestTimings` call
294
+ * @param {'entry'|'scripts'|'all'} [timingMethod] - the registered MFE's timing method; `undefined`/'entry' means weight/render-blocking still accumulate, but no timing widening happens at all
295
+ */
296
+ function applyManifestEntry(timings, entry, asset, entryState, timingMethod) {
297
+ // Weight isn't a timing concern, so it accumulates for every matched asset regardless of timingMethod.
298
+ applyResourceWeight(timings, entry);
299
+ if (timingMethod !== 'scripts' && timingMethod !== 'all') return; // no timing-widening effect at the 'entry' default/unset
300
+
301
+ const widensAllAssets = timingMethod === 'all';
302
+ // Under 'scripts', only script assets widen the fetch window; under 'all', every matched asset does.
303
+ if (widensAllAssets || asset.isScript) {
304
+ const start = Math.floor(entry.startTime);
305
+ const end = Math.floor(entry.responseEnd);
306
+ // fetchStart/fetchEnd default to 0 ("not yet found") -- only fold into the min/max once they're positive,
307
+ // or 0 would permanently win Math.min.
308
+ timings.fetchStart = timings.fetchStart > 0 ? Math.min(timings.fetchStart, start) : start;
309
+ timings.fetchEnd = timings.fetchEnd > 0 ? Math.max(timings.fetchEnd, end) : end;
310
+ }
311
+
312
+ // Non-script assets never execute, so only script assets widen the execution window or anchor asset/type.
313
+ if (asset.isScript) {
314
+ const correlation = findCorrelation(cleanURL(entry.name));
315
+ if (correlation) {
316
+ // Widens the aggregate scriptStart/scriptEnd window with this asset's current correlation timing. Re-called
317
+ // as a 'load'/'error' listener below if its DOM completion hasn't fired yet, so a later, larger end still counts.
318
+ const widenScriptWindowForAsset = () => {
319
+ const {
320
+ start: scriptStart,
321
+ end: scriptEnd
322
+ } = correlation.script;
323
+ getOrCreateInternals(timings).recordManifestScriptWindow(scriptStart, scriptEnd);
324
+ };
325
+ widenScriptWindowForAsset();
326
+ if (!correlation.dom.end && correlation.dom.value) {
327
+ ;
328
+ ['load', 'error'].forEach(eventType => correlation.dom.value.addEventListener(eventType, widenScriptWindowForAsset, {
329
+ once: true
330
+ }));
331
+ }
332
+ }
333
+ if (!entryState.resolved) {
334
+ timings.asset = entry.name;
335
+ timings.type = entry.initiatorType;
336
+ entryState.resolved = true;
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Subscribes to late resource timing emissions for manifest assets not yet resolved against the buffered entries.
343
+ * Reuses the shared page-wide scriptObserver/poSubscribers mechanism (one PerformanceObserver for all MFEs, not
344
+ * one per MFE) and, unlike that observer's own correlation bookkeeping, checks every resource entry -- not just
345
+ * script-like ones -- so lazy-loaded images/fonts/stylesheets resolve too.
346
+ * @param {RegisterAPITimings} timings
347
+ * @param {Set<import('./manifest').ParsedManifestAsset>} pending - manifest assets still unresolved
348
+ * @param {{ resolved: boolean }} entryState - shared "first script asset wins" guard for a single `applyManifestTimings` call
349
+ * @param {'entry'|'scripts'|'all'} [timingMethod] - forwarded to `applyManifestEntry` for each late-resolving asset
350
+ */
351
+ function subscribeToLateManifestEntries(timings, pending, entryState, timingMethod) {
352
+ if (!globalScope.PerformanceObserver?.supportedEntryTypes?.includes('resource')) return;
353
+ poSubscribers.push({
354
+ addedAt: now(),
355
+ test: entry => {
356
+ const matched = [...pending].find(asset => asset.test(entry.name));
357
+ if (matched) {
358
+ applyManifestEntry(timings, entry, matched, entryState, timingMethod);
359
+ pending.delete(matched);
360
+ }
361
+ return pending.size === 0;
362
+ }
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Applies a registered MFE's manifest to a timings object (already populated by `findScriptTimings`). No-op if no
368
+ * manifest is present. Weight/renderBlocking always accumulate from every detected manifest asset; timing widening
369
+ * (fetchStart/fetchEnd/scriptStart/scriptEnd/asset anchor) is opt-in via `timingMethod` -- see `applyManifestEntry`.
370
+ * @param {RegisterAPITimings} timings - the timings object to widen in place
371
+ * @param {RegisterAPITarget} target - the registered MFE target, which may carry a parsed `manifest`
372
+ */
373
+ export function applyManifestTimings(timings, target) {
374
+ const parsedManifest = target?.manifest;
375
+ if (!parsedManifest || !parsedManifest.assets.length) return;
376
+ const entryState = {
377
+ resolved: false
378
+ };
379
+ const pending = new Set(parsedManifest.assets);
380
+ const resourceEntries = globalScope.performance?.getEntriesByType('resource') || [];
381
+ resourceEntries.forEach(entry => {
382
+ const matched = [...pending].find(asset => asset.test(entry.name));
383
+ if (matched) {
384
+ applyManifestEntry(timings, entry, matched, entryState, target.timingMethod);
385
+ pending.delete(matched);
386
+ }
387
+ });
388
+ if (pending.size) subscribeToLateManifestEntries(timings, pending, entryState, target.timingMethod);
389
+ }
390
+
391
+ /**
392
+ * Uses the initiator function's stack to find script timing information via the resource timing API.
393
+ * @param {RegisterAPITarget} [target] - the MFE target being registered; its id scopes stale-correlation
394
+ * detection per-MFE rather than per-script-URL (see isCorrelationStale below)
225
395
  * @returns {RegisterAPITimings} Object containing script fetch start and end times, and the asset URL if found
226
396
  */
227
397
  export function findScriptTimings(target) {
@@ -234,7 +404,9 @@ export function findScriptTimings(target) {
234
404
  scriptStart: 0,
235
405
  scriptEnd: 0,
236
406
  asset: undefined,
237
- type: 'unknown'
407
+ type: 'unknown',
408
+ totalWeight: 0,
409
+ renderBlocking: undefined
238
410
  };
239
411
  const stack = getDeepStackTrace();
240
412
  if (!stack) return timings;
@@ -271,9 +443,8 @@ export function findScriptTimings(target) {
271
443
  }
272
444
 
273
445
  // A correlation can be reused across multiple `register()` calls for the same script URL (e.g. an SPA
274
- // remounting the same MFE without the script actually reloading). When that happens, its dom/performance
275
- // timings still describe the *original* load, not this one. Detect that case so scriptStart/scriptEnd
276
- // below can ignore the stale data instead of reporting it as if it were fresh.
446
+ // remounting the same MFE without the script reloading) -- its dom/performance timings would then describe
447
+ // the *original* load. Detect that so scriptStart/scriptEnd below can ignore the stale data.
277
448
  const correlation = timings.correlation;
278
449
  const alreadyClaimedByThisMFE = !!mfeId && !!correlation?.claimedBy.has(mfeId);
279
450
  if (correlation && mfeId) correlation.claimedBy.add(mfeId);
@@ -284,10 +455,35 @@ export function findScriptTimings(target) {
284
455
  return staleness > CORRELATION_STALE_THRESHOLD_MS;
285
456
  };
286
457
 
287
- // Use getters here because the correlation data may arrive after this function returns the timing object, and we want to provide the most up-to-date timing information possible when the getters are accessed at harvest time.
288
- // Non-stale: fall back to fetchEnd if correlation data isn't available yet (our best approximation for script execution start). Stale: fall back straight to registeredAt — fetchEnd would be derived from the same stale correlation, so it can't be trusted either.
289
- Object.defineProperty(timings, 'scriptStart', timingFactory(() => isCorrelationStale() ? timings.registeredAt : correlation?.script.start ?? timings.fetchEnd));
290
- Object.defineProperty(timings, 'scriptEnd', timingFactory(() => isCorrelationStale() ? timings.registeredAt : correlation?.script.end ?? timings.registeredAt));
458
+ // Only reached for a real (non-inline), stack-attributable script -- scriptStart/scriptEnd become live getters
459
+ // below, so manifest widening needs a hook that composes with them instead of overriding them (see
460
+ // recordManifestScriptWindow's doc comment). Every other path keeps getOrCreateInternals' plain-value-widening
461
+ // default, which is already correct there.
462
+ let manifestScriptStart = 0;
463
+ let manifestScriptEnd = 0;
464
+ /**
465
+ * Widens the running manifestScriptStart/manifestScriptEnd accumulators with one asset's correlation timing.
466
+ * Never shrinks either bound; a falsy (unresolved) start/end is ignored.
467
+ * @type {RecordManifestScriptWindowFn}
468
+ */
469
+ getOrCreateInternals(timings).recordManifestScriptWindow = (start, end) => {
470
+ if (start) manifestScriptStart = manifestScriptStart > 0 ? Math.min(manifestScriptStart, start) : start;
471
+ if (end) manifestScriptEnd = manifestScriptEnd > 0 ? Math.max(manifestScriptEnd, end) : end;
472
+ };
473
+
474
+ // Getters, since correlation data may still arrive after this function returns -- we want the freshest value
475
+ // at harvest time. Non-stale: fall back to fetchEnd (best approximation) if correlation isn't available yet.
476
+ // Stale: fall back to registeredAt, since fetchEnd would derive from the same stale correlation. Manifest
477
+ // widening is re-read on every access rather than baked in once, so it composes with a correlation that
478
+ // resolves later.
479
+ Object.defineProperty(timings, 'scriptStart', timingFactory(() => {
480
+ const ownStart = isCorrelationStale() ? timings.registeredAt : correlation?.script.start ?? timings.fetchEnd;
481
+ return manifestScriptStart > 0 ? Math.min(ownStart, manifestScriptStart) : ownStart;
482
+ }));
483
+ Object.defineProperty(timings, 'scriptEnd', timingFactory(() => {
484
+ const ownEnd = isCorrelationStale() ? timings.registeredAt : correlation?.script.end ?? timings.registeredAt;
485
+ return manifestScriptEnd > 0 ? Math.max(ownEnd, manifestScriptEnd) : ownEnd;
486
+ }));
291
487
  } catch (error) {
292
488
  // Don't let stack parsing errors break anything
293
489
  }
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import { extractUrlsFromStack, getDeepStackTrace } from './script-tracker';
7
+ import { matchManifestAsset } from './manifest';
8
+ import { cleanURL } from '../url/clean-url';
7
9
  import { V2_TYPES } from './constants';
8
10
 
9
11
  /**
@@ -47,6 +49,38 @@ export function getRegisteredTargetsFromId(id, agentRef) {
47
49
  return registeredEntities?.filter(entity => String(entity.metadata.target.id) === String(id)).map(entity => entity.metadata.target) || [];
48
50
  }
49
51
 
52
+ /**
53
+ * Returns the registered target(s) whose resource matches a given resource URL -- used to attribute `BrowserPerformance`
54
+ * (PerformanceResourceTiming) events, which never have a JS call stack to walk (they're fired for declarative
55
+ * `<script src>`/`<link>`/`<img>` tags, not JS execution), so stack-trace attribution (see {@link findTargetsFromStackTrace})
56
+ * doesn't apply. Unlike {@link getRegisteredTargetsFromFilename}, this matches manifest assets of ANY type (scripts,
57
+ * images, fonts, css, etc.) -- non-script assets can't produce a JS stack frame to match against, but they can and do
58
+ * produce their own resource timing entries. Returns an empty array if no target is found.
59
+ * @param {string} url - the resource's URL, as reported by the Performance API
60
+ * @param {*} agentRef
61
+ * @returns {import("../../interfaces/registered-entity").RegisterAPIMetadataTarget[]}
62
+ */
63
+ export function getRegisteredTargetsFromResourceUrl(url, agentRef) {
64
+ if (!isValid(url, agentRef)) return [];
65
+ const registeredEntities = agentRef.runtime.registeredEntities;
66
+ const cleanedUrl = cleanURL(url);
67
+ const matches = registeredEntities?.filter(entity => {
68
+ const manifest = entity.metadata.target?.manifest;
69
+ if (manifest) {
70
+ // A manifest was supplied -- it is the sole source of truth for attribution. The caller-script fallback
71
+ // below never applies here, so e.g. a registrar script that calls register() on behalf of many MFEs (each
72
+ // with its own manifest naming only that MFE's own files) never has ITS OWN activity attributed just because
73
+ // it happened to be the one that called register().
74
+ return matchManifestAsset(manifest, url, {
75
+ scriptsOnly: false
76
+ });
77
+ }
78
+ // No manifest -- fall back to matching the resolved URL of whatever script called register().
79
+ return !!entity.metadata.timings?.asset && cleanURL(entity.metadata.timings.asset) === cleanedUrl;
80
+ });
81
+ return dedupeRegisteredEntitiesByAsset(matches).map(entity => entity.metadata.target);
82
+ }
83
+
50
84
  /**
51
85
  * Returns the registered target(s) associated with a given filename if found in the resource timing API during registration. Returns an empty array if no target is found.
52
86
  * Multiple registrations that resolve to the same underlying script asset AND represent the same logical MFE (i.e.
@@ -63,7 +97,18 @@ export function getRegisteredTargetsFromId(id, agentRef) {
63
97
  export function getRegisteredTargetsFromFilename(filename, agentRef) {
64
98
  if (!isValid(filename, agentRef)) return [];
65
99
  const registeredEntities = agentRef.runtime.registeredEntities;
66
- const matches = registeredEntities?.filter(entity => entity.metadata.timings?.asset?.endsWith(filename));
100
+ const matches = registeredEntities?.filter(entity => {
101
+ const manifest = entity.metadata.target?.manifest;
102
+ if (manifest) {
103
+ // See the identical case in getRegisteredTargetsFromResourceUrl above: once a manifest exists, it is the
104
+ // sole source of truth for attribution -- the caller-script fallback below never applies.
105
+ return matchManifestAsset(manifest, filename, {
106
+ scriptsOnly: true
107
+ });
108
+ }
109
+ // No manifest -- fall back to matching the resolved URL of whatever script called register().
110
+ return !!entity.metadata.timings?.asset?.endsWith(filename);
111
+ });
67
112
  return dedupeRegisteredEntitiesByAsset(matches).map(entity => entity.metadata.target);
68
113
  }
69
114
 
@@ -1,14 +1,15 @@
1
1
  /**
2
- * Copyright 2020-2025 New Relic, Inc. All rights reserved.
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
  import { onCLS } from 'web-vitals/attribution';
6
6
  import { VITAL_NAMES } from './constants';
7
7
  import { VitalMetric } from './vital-metric';
8
+ import { registerVital } from './register-vital';
8
9
  import { isBrowserScope } from '../constants/runtime';
9
10
  export const cumulativeLayoutShift = new VitalMetric(VITAL_NAMES.CUMULATIVE_LAYOUT_SHIFT, x => x);
10
11
  if (isBrowserScope) {
11
- onCLS(({
12
+ const handleCLS = ({
12
13
  value,
13
14
  attribution,
14
15
  id
@@ -24,7 +25,8 @@ if (isBrowserScope) {
24
25
  value,
25
26
  attrs
26
27
  });
27
- }, {
28
+ };
29
+ registerVital(() => onCLS(handleCLS, {
28
30
  reportAllChanges: true
29
- });
31
+ }));
30
32
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Copyright 2020-2025 New Relic, Inc. All rights reserved.
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
  import { onFCP } from 'web-vitals/attribution';
@@ -7,6 +7,7 @@ import { onFCP } from 'web-vitals/attribution';
7
7
  import { iOSBelow16, initiallyHidden, isBrowserScope } from '../constants/runtime';
8
8
  import { VITAL_NAMES } from './constants';
9
9
  import { VitalMetric } from './vital-metric';
10
+ import { registerVital } from './register-vital';
10
11
  export const firstContentfulPaint = new VitalMetric(VITAL_NAMES.FIRST_CONTENTFUL_PAINT);
11
12
 
12
13
  /* First Contentful Paint - As of WV v3, it still imperfectly tries to detect document vis state asap and isn't supposed to report if page starts hidden. */
@@ -29,7 +30,7 @@ if (isBrowserScope) {
29
30
  // ignore
30
31
  }
31
32
  } else {
32
- onFCP(({
33
+ const handleFCP = ({
33
34
  value,
34
35
  attribution
35
36
  }) => {
@@ -43,6 +44,7 @@ if (isBrowserScope) {
43
44
  value,
44
45
  attrs
45
46
  });
46
- });
47
+ };
48
+ registerVital(() => onFCP(handleFCP));
47
49
  }
48
50
  }
@@ -1,19 +1,24 @@
1
1
  /**
2
- * Copyright 2020-2025 New Relic, Inc. All rights reserved.
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
  import { onINP } from 'web-vitals/attribution';
6
6
  import { VitalMetric } from './vital-metric';
7
7
  import { VITAL_NAMES } from './constants';
8
+ import { registerVital } from './register-vital';
8
9
  import { isBrowserScope } from '../constants/runtime';
9
10
  export const interactionToNextPaint = new VitalMetric(VITAL_NAMES.INTERACTION_TO_NEXT_PAINT);
10
11
  if (isBrowserScope) {
11
12
  /* Interaction-to-Next-Paint */
12
- onINP(({
13
+ const handleINP = ({
13
14
  value,
14
15
  attribution,
15
- id
16
+ id,
17
+ entries
16
18
  }) => {
19
+ /* web-vitals v6 reports a synthetic INP (value 8, no entries, no interaction attribution) after bfcache restores when
20
+ every interaction stayed below the duration threshold; skip those so only measured interactions are reported, as in v4 */
21
+ if (!entries?.length) return;
17
22
  const attrs = {
18
23
  metricId: id,
19
24
  eventTarget: attribution.interactionTarget,
@@ -33,5 +38,6 @@ if (isBrowserScope) {
33
38
  value,
34
39
  attrs
35
40
  });
36
- });
41
+ };
42
+ registerVital(() => onINP(handleINP));
37
43
  }
@@ -5,11 +5,12 @@
5
5
  import { onLCP } from 'web-vitals/attribution';
6
6
  import { VitalMetric } from './vital-metric';
7
7
  import { VITAL_NAMES } from './constants';
8
+ import { registerVital } from './register-vital';
8
9
  import { initiallyHidden, isBrowserScope } from '../constants/runtime';
9
10
  import { cleanURL } from '../url/clean-url';
10
11
  export const largestContentfulPaint = new VitalMetric(VITAL_NAMES.LARGEST_CONTENTFUL_PAINT);
11
12
  if (isBrowserScope) {
12
- onLCP(({
13
+ const handleLCP = ({
13
14
  value,
14
15
  attribution
15
16
  }) => {
@@ -29,11 +30,12 @@ if (isBrowserScope) {
29
30
  attrs.eid = lcpEntry.id;
30
31
  if (lcpEntry.element?.tagName) attrs.elTag = lcpEntry.element.tagName;
31
32
  }
32
- if (attribution.element) attrs.element = attribution.element;
33
+ if (attribution.target) attrs.element = attribution.target; // renamed from `element` in web-vitals v4->v5; NR attr name kept for backwards compatibility
33
34
  if (attribution.url) attrs.elUrl = cleanURL(attribution.url);
34
35
  largestContentfulPaint.update({
35
36
  value,
36
37
  attrs
37
38
  });
38
- });
39
+ };
40
+ registerVital(() => onLCP(handleLCP));
39
41
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Invokes a web-vitals registration (e.g. `onLCP(callback)`), tolerating environments where the registration itself
8
+ * throws. web-vitals >= 5 reads Performance Timeline APIs such as `performance.getEntriesByType` unguarded when a
9
+ * metric is registered; where those are missing the metric is simply never reported, instead of the throw failing
10
+ * the feature that imported it.
11
+ * @param {() => void} register
12
+ */
13
+ export function registerVital(register) {
14
+ try {
15
+ register();
16
+ } catch (e) {
17
+ // metric unavailable in this environment
18
+ }
19
+ }
@@ -5,6 +5,7 @@
5
5
  import { globalScope, isBrowserScope, isiOS, originTime, getNavigationEntry } from '../constants/runtime';
6
6
  import { VITAL_NAMES } from './constants';
7
7
  import { VitalMetric } from './vital-metric';
8
+ import { registerVital } from './register-vital';
8
9
  import { onTTFB } from 'web-vitals/attribution';
9
10
  export const timeToFirstByte = new VitalMetric(VITAL_NAMES.TIME_TO_FIRST_BYTE);
10
11
 
@@ -17,7 +18,7 @@ export const timeToFirstByte = new VitalMetric(VITAL_NAMES.TIME_TO_FIRST_BYTE);
17
18
  * - onTTFB relies on a truthy `responseStart` value, should ensure that exists before relying on it (seen to be falsy in certain Electron.js cases for instance)
18
19
  */
19
20
  if (isBrowserScope && getNavigationEntry() && !isiOS && window === window.parent) {
20
- onTTFB(({
21
+ const handleTTFB = ({
21
22
  value,
22
23
  attribution
23
24
  }) => {
@@ -28,7 +29,8 @@ if (isBrowserScope && getNavigationEntry() && !isiOS && window === window.parent
28
29
  navigationEntry: attribution.navigationEntry
29
30
  }
30
31
  });
31
- });
32
+ };
33
+ registerVital(() => onTTFB(handleTTFB));
32
34
  } else {
33
35
  if (!timeToFirstByte.isValid) {
34
36
  const entry = {};
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Copyright 2020-2025 New Relic, Inc. All rights reserved.
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
  export class VitalMetric {
@@ -18,7 +18,8 @@ export class VitalMetric {
18
18
  const state = {
19
19
  value: this.roundingMethod(value),
20
20
  name: this.name,
21
- attrs
21
+ // optional web-vitals attribution fields are omitted rather than being serialized as null attributes
22
+ attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined))
22
23
  };
23
24
  this.history.push(state);
24
25
  this.#subscribers.forEach(cb => {
@@ -15,7 +15,8 @@ import { UserActionsAggregator } from './user-actions/user-actions-aggregator';
15
15
  import { isIFrameWindow } from '../../../common/dom/iframe';
16
16
  import { isPureObject } from '../../../common/util/type-check';
17
17
  import { EVENT_TYPES } from '../../../common/constants/events';
18
- import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/v2/utils';
18
+ import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate, getRegisteredTargetsFromResourceUrl } from '../../../common/v2/utils';
19
+ import { findCorrelation } from '../../../common/v2/script-tracker';
19
20
  export class Aggregate extends AggregateBase {
20
21
  static featureName = FEATURE_NAME;
21
22
  #userActionAggregator;
@@ -232,7 +233,33 @@ export class Aggregate extends AggregateBase {
232
233
  entryDuration: duration,
233
234
  firstParty
234
235
  };
235
- this.addEvent(event);
236
+ const targets = getRegisteredTargetsFromResourceUrl(name, this.agentRef);
237
+ if (targets.length) {
238
+ targets.forEach(target => this.addEvent({
239
+ ...event
240
+ }, target));
241
+ return;
242
+ }
243
+ this.addEvent({
244
+ ...event
245
+ }, undefined);
246
+
247
+ // This resource entry can resolve before a self-registering script has actually executed and called
248
+ // register() on itself (responseEnd fires before script parse/execution). If this URL belongs to a
249
+ // <script> we're tracking that hasn't finished loading yet, retry resolution once it has -- reporting
250
+ // a second (deliberately duplicate) copy under the real target if one shows up by then. One-shot,
251
+ // self-cleans via `once: true`, so there's nothing to leak if the script never registers.
252
+ const correlation = findCorrelation(cleanURL(name));
253
+ if (correlation?.dom.value && !correlation.dom.end) {
254
+ const retryAttribution = () => {
255
+ getRegisteredTargetsFromResourceUrl(name, this.agentRef).forEach(target => this.addEvent({
256
+ ...event
257
+ }, target));
258
+ };
259
+ ['load', 'error'].forEach(evtType => correlation.dom.value.addEventListener(evtType, retryAttribution, {
260
+ once: true
261
+ }));
262
+ }
236
263
  } catch (err) {
237
264
  this.ee.emit('internal-error', [err, 'GenericEvents-Resource']);
238
265
  }
@@ -273,21 +300,11 @@ export class Aggregate extends AggregateBase {
273
300
  }, this.featureName, this.ee);
274
301
  }
275
302
  if (!agentRef.init.feature_flags.includes('no_spv')) {
276
- registerHandler('spv', evt => {
303
+ registerHandler('spv', (violation, timestamp) => {
277
304
  this.addEvent({
278
305
  eventType: EVENT_TYPES.SPV,
279
- timestamp: this.#toEpoch(evt.timeStamp),
280
- blockedUri: evt.blockedURI,
281
- documentUri: evt.documentURI,
282
- effectiveDirective: evt.effectiveDirective,
283
- originalPolicy: evt.originalPolicy,
284
- sourceFile: evt.sourceFile,
285
- statusCode: evt.statusCode,
286
- lineNumber: evt.lineNumber,
287
- columnNumber: evt.columnNumber,
288
- disposition: evt.disposition,
289
- sample: evt.sample,
290
- referrer: evt.referrer
306
+ timestamp: this.#toEpoch(timestamp),
307
+ ...violation
291
308
  });
292
309
  }, this.featureName, this.ee);
293
310
  }