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

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
@@ -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,36 @@ 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, { scriptsOnly: false })
75
+ }
76
+ // No manifest -- fall back to matching the resolved URL of whatever script called register().
77
+ return !!entity.metadata.timings?.asset && cleanURL(entity.metadata.timings.asset) === cleanedUrl
78
+ })
79
+ return dedupeRegisteredEntitiesByAsset(matches).map(entity => entity.metadata.target)
80
+ }
81
+
50
82
  /**
51
83
  * 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
84
  * Multiple registrations that resolve to the same underlying script asset AND represent the same logical MFE (i.e.
@@ -63,7 +95,16 @@ export function getRegisteredTargetsFromId (id, agentRef) {
63
95
  export function getRegisteredTargetsFromFilename (filename, agentRef) {
64
96
  if (!isValid(filename, agentRef)) return []
65
97
  const registeredEntities = agentRef.runtime.registeredEntities
66
- const matches = registeredEntities?.filter(entity => entity.metadata.timings?.asset?.endsWith(filename))
98
+ const matches = registeredEntities?.filter(entity => {
99
+ const manifest = entity.metadata.target?.manifest
100
+ if (manifest) {
101
+ // See the identical case in getRegisteredTargetsFromResourceUrl above: once a manifest exists, it is the
102
+ // sole source of truth for attribution -- the caller-script fallback below never applies.
103
+ return matchManifestAsset(manifest, filename, { scriptsOnly: true })
104
+ }
105
+ // No manifest -- fall back to matching the resolved URL of whatever script called register().
106
+ return !!entity.metadata.timings?.asset?.endsWith(filename)
107
+ })
67
108
  return dedupeRegisteredEntitiesByAsset(matches).map(entity => entity.metadata.target)
68
109
  }
69
110
 
@@ -1,16 +1,17 @@
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
 
10
11
  export const cumulativeLayoutShift = new VitalMetric(VITAL_NAMES.CUMULATIVE_LAYOUT_SHIFT, (x) => x)
11
12
 
12
13
  if (isBrowserScope) {
13
- onCLS(({ value, attribution, id }) => {
14
+ const handleCLS = ({ value, attribution, id }) => {
14
15
  const attrs = {
15
16
  metricId: id,
16
17
  largestShiftTarget: attribution.largestShiftTarget,
@@ -19,5 +20,6 @@ if (isBrowserScope) {
19
20
  loadState: attribution.loadState
20
21
  }
21
22
  cumulativeLayoutShift.update({ value, attrs })
22
- }, { reportAllChanges: true })
23
+ }
24
+ registerVital(() => onCLS(handleCLS, { reportAllChanges: true }))
23
25
  }
@@ -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
 
11
12
  export const firstContentfulPaint = new VitalMetric(VITAL_NAMES.FIRST_CONTENTFUL_PAINT)
12
13
 
@@ -27,7 +28,7 @@ if (isBrowserScope) {
27
28
  // ignore
28
29
  }
29
30
  } else {
30
- onFCP(({ value, attribution }) => {
31
+ const handleFCP = ({ value, attribution }) => {
31
32
  if (initiallyHidden || firstContentfulPaint.isValid) return
32
33
  const attrs = {
33
34
  timeToFirstByte: attribution.timeToFirstByte,
@@ -35,6 +36,7 @@ if (isBrowserScope) {
35
36
  loadState: attribution.loadState
36
37
  }
37
38
  firstContentfulPaint.update({ value, attrs })
38
- })
39
+ }
40
+ registerVital(() => onFCP(handleFCP))
39
41
  }
40
42
  }
@@ -1,17 +1,21 @@
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
 
10
11
  export const interactionToNextPaint = new VitalMetric(VITAL_NAMES.INTERACTION_TO_NEXT_PAINT)
11
12
 
12
13
  if (isBrowserScope) {
13
14
  /* Interaction-to-Next-Paint */
14
- onINP(({ value, attribution, id }) => {
15
+ const handleINP = ({ value, attribution, id, entries }) => {
16
+ /* web-vitals v6 reports a synthetic INP (value 8, no entries, no interaction attribution) after bfcache restores when
17
+ every interaction stayed below the duration threshold; skip those so only measured interactions are reported, as in v4 */
18
+ if (!entries?.length) return
15
19
  const attrs = {
16
20
  metricId: id,
17
21
  eventTarget: attribution.interactionTarget, // event* attrs deprecated in v4, kept for NR backwards compatibility
@@ -26,5 +30,6 @@ if (isBrowserScope) {
26
30
  loadState: attribution.loadState
27
31
  }
28
32
  interactionToNextPaint.update({ value, attrs })
29
- })
33
+ }
34
+ registerVital(() => onINP(handleINP))
30
35
  }
@@ -5,13 +5,14 @@
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
 
11
12
  export const largestContentfulPaint = new VitalMetric(VITAL_NAMES.LARGEST_CONTENTFUL_PAINT)
12
13
 
13
14
  if (isBrowserScope) {
14
- onLCP(({ value, attribution }) => {
15
+ const handleLCP = ({ value, attribution }) => {
15
16
  /* Largest 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. */
16
17
  if (initiallyHidden || largestContentfulPaint.isValid) return
17
18
 
@@ -28,9 +29,10 @@ if (isBrowserScope) {
28
29
  attrs.eid = lcpEntry.id
29
30
  if (lcpEntry.element?.tagName) attrs.elTag = lcpEntry.element.tagName
30
31
  }
31
- if (attribution.element) attrs.element = attribution.element
32
+ if (attribution.target) attrs.element = attribution.target // renamed from `element` in web-vitals v4->v5; NR attr name kept for backwards compatibility
32
33
  if (attribution.url) attrs.elUrl = cleanURL(attribution.url)
33
34
 
34
35
  largestContentfulPaint.update({ value, attrs })
35
- })
36
+ }
37
+ registerVital(() => onLCP(handleLCP))
36
38
  }
@@ -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
 
10
11
  export const timeToFirstByte = new VitalMetric(VITAL_NAMES.TIME_TO_FIRST_BYTE)
@@ -18,10 +19,11 @@ export const timeToFirstByte = new VitalMetric(VITAL_NAMES.TIME_TO_FIRST_BYTE)
18
19
  * - 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)
19
20
  */
20
21
  if (isBrowserScope && getNavigationEntry() && !isiOS && window === window.parent) {
21
- onTTFB(({ value, attribution }) => {
22
+ const handleTTFB = ({ value, attribution }) => {
22
23
  if (timeToFirstByte.isValid) return
23
24
  timeToFirstByte.update({ value, attrs: { navigationEntry: attribution.navigationEntry } })
24
- })
25
+ }
26
+ registerVital(() => onTTFB(handleTTFB))
25
27
  } else {
26
28
  if (!timeToFirstByte.isValid) {
27
29
  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 {
@@ -17,7 +17,8 @@ export class VitalMetric {
17
17
  const state = {
18
18
  value: this.roundingMethod(value),
19
19
  name: this.name,
20
- attrs
20
+ // optional web-vitals attribution fields are omitted rather than being serialized as null attributes
21
+ attrs: Object.fromEntries(Object.entries(attrs).filter(([, attrValue]) => attrValue !== undefined))
21
22
  }
22
23
 
23
24
  this.history.push(state)
@@ -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
 
20
21
  export class Aggregate extends AggregateBase {
21
22
  static featureName = FEATURE_NAME
@@ -229,7 +230,26 @@ export class Aggregate extends AggregateBase {
229
230
  firstParty
230
231
  }
231
232
 
232
- this.addEvent(event)
233
+ const targets = getRegisteredTargetsFromResourceUrl(name, this.agentRef)
234
+ if (targets.length) {
235
+ targets.forEach(target => this.addEvent({ ...event }, target))
236
+ return
237
+ }
238
+
239
+ this.addEvent({ ...event }, undefined)
240
+
241
+ // This resource entry can resolve before a self-registering script has actually executed and called
242
+ // register() on itself (responseEnd fires before script parse/execution). If this URL belongs to a
243
+ // <script> we're tracking that hasn't finished loading yet, retry resolution once it has -- reporting
244
+ // a second (deliberately duplicate) copy under the real target if one shows up by then. One-shot,
245
+ // self-cleans via `once: true`, so there's nothing to leak if the script never registers.
246
+ const correlation = findCorrelation(cleanURL(name))
247
+ if (correlation?.dom.value && !correlation.dom.end) {
248
+ const retryAttribution = () => {
249
+ getRegisteredTargetsFromResourceUrl(name, this.agentRef).forEach(target => this.addEvent({ ...event }, target))
250
+ }
251
+ ;['load', 'error'].forEach(evtType => correlation.dom.value.addEventListener(evtType, retryAttribution, { once: true }))
252
+ }
233
253
  } catch (err) {
234
254
  this.ee.emit('internal-error', [err, 'GenericEvents-Resource'])
235
255
  }
@@ -271,21 +291,11 @@ export class Aggregate extends AggregateBase {
271
291
  }, this.featureName, this.ee)
272
292
  }
273
293
  if (!agentRef.init.feature_flags.includes('no_spv')) {
274
- registerHandler('spv', (evt) => {
294
+ registerHandler('spv', (violation, timestamp) => {
275
295
  this.addEvent({
276
296
  eventType: EVENT_TYPES.SPV,
277
- timestamp: this.#toEpoch(evt.timeStamp),
278
- blockedUri: evt.blockedURI,
279
- documentUri: evt.documentURI,
280
- effectiveDirective: evt.effectiveDirective,
281
- originalPolicy: evt.originalPolicy,
282
- sourceFile: evt.sourceFile,
283
- statusCode: evt.statusCode,
284
- lineNumber: evt.lineNumber,
285
- columnNumber: evt.columnNumber,
286
- disposition: evt.disposition,
287
- sample: evt.sample,
288
- referrer: evt.referrer
297
+ timestamp: this.#toEpoch(timestamp),
298
+ ...violation
289
299
  })
290
300
  }, this.featureName, this.ee)
291
301
  }
@@ -6,6 +6,7 @@
6
6
  import { globalScope, isBrowserScope } from '../../../common/constants/runtime'
7
7
  import { handle } from '../../../common/event-emitter/handle'
8
8
  import { eventListenerOpts, windowAddEventListener } from '../../../common/event-listener/event-listener-opts'
9
+ import { now } from '../../../common/timing/now'
9
10
  import { debounce } from '../../../common/util/invoke'
10
11
  import { setupAddPageActionAPI } from '../../../loaders/api/addPageAction'
11
12
  import { setupFinishedAPI } from '../../../loaders/api/finished'
@@ -61,8 +62,24 @@ export class Instrument extends InstrumentBase {
61
62
  })
62
63
  }
63
64
  if (securityPolicyViolationEnabled) {
65
+ /** ReportingObserver's `csp-violation` type isn't reliably enabled everywhere out of the box:
66
+ * - Safari only delivers it when the page sets a report-to CSP directive -- https://bugs.webkit.org/show_bug.cgi?id=320750;
67
+ * - Firefox ships it behind the `dom.reporting.enabled` pref, which defaults to false;
68
+ * So it's only used for violations that occur before the agent loaded, targeting Chromium browsers. `buffered: true` replays
69
+ * that backlog in the observer's first callback invocation, after which it's disconnected and all future violations are
70
+ * covered by securitypolicyviolation, which is reliable across all browsers. */
71
+ if (typeof globalScope.ReportingObserver === 'function') {
72
+ const cspObserver = new globalScope.ReportingObserver((reports) => {
73
+ reports.forEach(report => {
74
+ handle('spv', [normalizeCspViolation(report), now()], undefined, FEATURE_NAMES.genericEvents, this.ee)
75
+ })
76
+ cspObserver.disconnect()
77
+ }, { types: ['csp-violation'], buffered: true })
78
+ cspObserver.observe()
79
+ }
80
+
64
81
  globalScope.addEventListener('securitypolicyviolation', (evt) => {
65
- handle('spv', [evt], undefined, FEATURE_NAMES.genericEvents, this.ee)
82
+ handle('spv', [normalizeCspViolation(evt), evt.timeStamp], undefined, FEATURE_NAMES.genericEvents, this.ee)
66
83
  }, eventListenerOpts(false, this.removeOnAbort.signal))
67
84
  }
68
85
  if (isBrowserScope) {
@@ -132,5 +149,26 @@ export class Instrument extends InstrumentBase {
132
149
  else this.deregisterDrain()
133
150
  }
134
151
  }
152
+ /** `Report.body` (ReportingObserver) and `SecurityPolicyViolationEvent` (securitypolicyviolation) describe the same
153
+ * violation with differently-named/shaped fields -- collapse both into one shape so downstream code doesn't care
154
+ * which mechanism produced it. `source` is either of those: a Report (fields under `.body`, URLs as blockedURL/
155
+ * documentURL) or the event (fields at the top level, URLs as blockedURI/documentURI). Also normalizes disposition's
156
+ * "reporting" (Report body) to "report" (event) so the value is consistent regardless of source. */
157
+ function normalizeCspViolation (source) {
158
+ const body = source.body ?? source
159
+ return {
160
+ blockedUrl: body.blockedURL ?? source.blockedURI,
161
+ documentUrl: body.documentURL ?? source.documentURI,
162
+ effectiveDirective: body.effectiveDirective,
163
+ originalPolicy: body.originalPolicy,
164
+ sourceFile: body.sourceFile,
165
+ statusCode: body.statusCode,
166
+ lineNumber: body.lineNumber,
167
+ columnNumber: body.columnNumber,
168
+ disposition: body.disposition === 'reporting' ? 'report' : body.disposition,
169
+ sample: body.sample,
170
+ referrer: body.referrer
171
+ }
172
+ }
135
173
 
136
174
  export const GenericEvents = Instrument
@@ -7,6 +7,7 @@
7
7
  import { onCLS, onFCP, onINP, onLCP } from 'web-vitals'
8
8
  // internal
9
9
  import { globalScope, isBrowserScope } from '../common/constants/runtime'
10
+ import { registerVital } from '../common/vitals/register-vital'
10
11
  import { isIFrameWindow } from '../common/dom/iframe'
11
12
  import { now } from '../common/timing/now'
12
13
  import { warn } from '../common/util/console'
@@ -179,12 +180,16 @@ export class RegisteredIframeEntity {
179
180
  */
180
181
  #setupVitalsListeners () {
181
182
  VITALS.forEach(([vitalFn, property]) => {
182
- vitalFn(({ value }) => {
183
+ registerVital(() => vitalFn(({ value, entries: vitalEntries }) => {
184
+ /* web-vitals v6 reports a synthetic INP (value 8, no entries) after bfcache restores when
185
+ every interaction stayed below the duration threshold; skip those so only measured
186
+ interactions are reported, matching the guard in src/common/vitals/interaction-to-next-paint.js */
187
+ if (property === 'inp' && !vitalEntries?.length) return
183
188
  this.metadata.vitals[property].value = value
184
189
  this.#postMessageToParent(IFRAME_VITALS_UPDATE, {
185
190
  entries: [{ property, value }]
186
191
  })
187
- }, { reportAllChanges: property === 'cls' || property === 'inp' })
192
+ }, { reportAllChanges: property === 'cls' || property === 'inp' }))
188
193
  })
189
194
  }
190
195
 
@@ -17,6 +17,10 @@
17
17
  * @property {RegisterAPIMetadata} metadata - The metadata object containing the custom attributes and target information for the registered entity.
18
18
  */
19
19
 
20
+ /**
21
+ * @typedef {import('../../common/v2/manifest').AssetFile} AssetFile
22
+ */
23
+
20
24
  /**
21
25
  * @typedef {Object} RegisterAPIConstructor
22
26
  * @property {string} id - The unique id for the registered entity. This will be assigned to any synthesized entities.
@@ -24,6 +28,8 @@
24
28
  * @property {{[key: string]: any}} [tags] - The tags for the registered entity as key-value pairs. This will be assigned to any synthesized entities. Tags are converted to source.* attributes (e.g., {environment: 'production'} becomes source.environment: 'production').
25
29
  * @property {RegisterAPITarget} [parent] - The parent target for the registered entity. If none was supplied, it will assume the entity guid from the main agent.
26
30
  * @property {string} [parentId] - The parentId for the registered entity. If none was supplied, it will assume the entity guid from the main agent.
31
+ * @property {{assets?: Array<AssetFile>}} [manifest] - An optional manifest describing the MFE's known assets (scripts, stylesheets, images, fonts, etc.) as `AssetFile` entries, used to improve the accuracy of event attribution (errors, logs, ajax, websockets), attribute `BrowserPerformance` resource events (any asset type, not just scripts) and, depending on `timingMethod`, MicroFrontEndTiming values. Each `AssetFile`'s script-capability is inferred from a `.js` suffix on a string `matcher` (a RegExp `matcher` is never inferred as a script), unless overridden via `type: 'script'` or explicitly disabled via `type: 'asset'`. An `AssetFile` with an invalid `matcher` or an unrecognized `type` is discarded/ignored (with a console warning) rather than applied. If omitted, the agent falls back to its existing behavior of only evaluating the script that called `register()` (resource attribution still applies to that script).
32
+ * @property {'entry'|'scripts'|'all'} [timingMethod] - Controls which manifest assets are used to calculate MicroFrontEndTiming values: 'entry' (the default) leaves timing based entirely on the script that called `register()`, unaffected by the manifest; 'scripts' widens the timing window across all script assets; 'all' widens it across every asset.
27
33
  */
28
34
 
29
35
  /**
@@ -39,18 +45,22 @@
39
45
  * @property {string} name - The name returned for the registered entity.
40
46
  * @property {{[key: string]: any}} [tags] - The tags for the registered entity as key-value pairs.
41
47
  * @property {string} [parentId] - The parentId for the registered entity. If none was supplied, it will assume the entity guid from the main agent.
48
+ * @property {import('../../common/v2/manifest').ParsedManifest} [manifest] - The parsed manifest for the registered entity, if one was supplied.
49
+ * @property {'entry'|'scripts'|'all'} [timingMethod] - The timing method supplied for the registered entity, if any.
42
50
  */
43
51
 
44
52
  /**
45
53
  * @typedef {Object} RegisterAPITimings
46
54
  * @property {number} registeredAt - The timestamp when the registered entity was created.
47
55
  * @property {number} [reportedAt] - The timestamp when the registered entity was deregistered.
48
- * @property {number} fetchStart - The timestamp when the registered entity began fetching (performance.start).
49
- * @property {number} fetchEnd - The timestamp when the registered entity finished fetching (performance.end).
50
- * @property {number} scriptStart - The timestamp when script initialization began (max of dom.start or performance.end, or performance.end if no dom.start).
51
- * @property {number} scriptEnd - The timestamp when script loading completed (dom.end or registeredAt if no dom.end).
56
+ * @property {number} fetchStart - The timestamp when the registered entity began fetching (performance.start). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the earliest fetchStart across all matched manifest assets.
57
+ * @property {number} fetchEnd - The timestamp when the registered entity finished fetching (performance.end). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the latest fetchEnd across all matched manifest assets.
58
+ * @property {number} scriptStart - The timestamp when script initialization began (max of dom.start or performance.end, or performance.end if no dom.start). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the earliest scriptStart across all matched manifest script assets (non-script assets are excluded).
59
+ * @property {number} scriptEnd - The timestamp when script loading completed (dom.end or registeredAt if no dom.end). When a manifest with timingMethod 'scripts'|'all' is supplied, this widens to the latest scriptEnd across all matched manifest script assets (non-script assets are excluded).
52
60
  * @property {Object} [asset] - The asset path (if found) for the registered entity.
53
61
  * @property {string} type - The type of timing associated with the registered entity, 'script' or 'link' if found with the performance resource API, 'fetch' for dynamic imports, 'inline' if found to be associated with the root document URL, or 'unknown' if no associated resource could be found.
62
+ * @property {number} totalWeight - The sum of `transferSize` (bytes) across every asset detected for the registered entity -- the entry script, plus, when a manifest is supplied, every matched manifest asset (script or non-script). Unlike fetchStart/fetchEnd/scriptStart/scriptEnd above, this is never gated behind `timingMethod` -- manifest assets contribute their bytes even at the 'entry' default/unset. Cross-origin assets without a Timing-Allow-Origin header report 0 bytes per the Resource Timing spec.
63
+ * @property {boolean} [renderBlocking] - True if any detected asset (entry script or matched manifest asset) has a `renderBlockingStatus` of 'blocking'; false if none were 'blocking' but at least one reported 'non-blocking'; left `undefined` if no detected asset reported the attribute at all (e.g. unsupported browser). A single 'blocking' asset always wins over any number of 'non-blocking' ones, regardless of resolution order.
54
64
  */
55
65
 
56
66
  /**
@@ -17,11 +17,14 @@ import { single } from '../../common/util/invoke'
17
17
  import { measure } from './measure'
18
18
  import { recordCustomEvent } from './recordCustomEvent'
19
19
  import { subscribeToPageUnload } from '../../common/window/page-visibility'
20
- import { findScriptTimings } from '../../common/v2/script-tracker'
20
+ import { findScriptTimings, applyManifestTimings } from '../../common/v2/script-tracker'
21
21
  import { trackMFEVitals } from '../../common/v2/mfe-vitals'
22
22
  import { generateRandomHexString } from '../../common/ids/unique-id'
23
+ import { parseManifest } from '../../common/v2/manifest'
23
24
  import { cleanURL } from '../../common/url/clean-url'
24
25
 
26
+ const TIMING_METHODS = ['entry', 'scripts', 'all']
27
+
25
28
  /**
26
29
  * @typedef {import('./register-api-types').RegisterAPI} RegisterAPI
27
30
  */
@@ -48,7 +51,10 @@ export const warnings = {
48
51
  experimental: single(() => warn(54, 'newrelic.register')),
49
52
  disabled: single(() => warn(55)),
50
53
  invalidTarget: single((target) => warn(48, target)),
51
- deregistered: single(() => warn(68))
54
+ deregistered: single(() => warn(68)),
55
+ invalidTimingMethod: single((value) => warn(80, value)),
56
+ duplicateName: single((target) => warn(81, target)),
57
+ duplicateId: single((target) => warn(82, target))
52
58
  }
53
59
 
54
60
  /**
@@ -82,9 +88,17 @@ function register (agentRef, target) {
82
88
  get id () { return agentRef.runtime.appMetadata.agents?.[0].entityGuid }, // getter because this is asynchronously set
83
89
  type: V2_TYPES.BA
84
90
  }
91
+ target.manifest = parseManifest(target.manifest)
92
+ if (target.timingMethod !== undefined && !TIMING_METHODS.includes(target.timingMethod)) {
93
+ warnings.invalidTimingMethod(target.timingMethod)
94
+ target.timingMethod = undefined
95
+ }
85
96
 
86
97
  // The script timings for this entity, which will be used to populate part of the MicroFrontEndTiming custom event.
87
98
  const timings = findScriptTimings(target)
99
+ // Always applied when a manifest is present -- totalWeight/renderBlocking accumulate from manifest assets
100
+ // regardless of timingMethod; actual timing widening inside stays gated to 'scripts'/'all' (see applyManifestTimings).
101
+ if (target.manifest) applyManifestTimings(timings, target)
88
102
 
89
103
  // Track MFE vitals for this entity, which will be used to populate part of the MicroFrontEndTiming custom event.
90
104
  const vitals = trackMFEVitals(target, timings)
@@ -138,6 +152,16 @@ function register (agentRef, target) {
138
152
  /** primary cases that can block the register API from working at init time */
139
153
  if (!agentRef.init.api.register.enabled) block(warnings.disabled)
140
154
  if (!hasValidValue(target.id) || !hasValidValue(target.name)) block(() => warnings.invalidTarget(target))
155
+ /** warn if we see obviously unstable things with MFE targets */
156
+ registeredEntities.forEach((entity) => {
157
+ try {
158
+ const { name, id } = entity.metadata.target
159
+ if (name === target.name && id !== target.id) warnings.duplicateName(target)
160
+ if (id === target.id && name !== target.name) warnings.duplicateId(target)
161
+ } catch (e) {
162
+ // something unexpected went wrong...
163
+ }
164
+ })
141
165
 
142
166
  /** @type {RegisterAPI} */
143
167
  const api = {
@@ -215,6 +239,9 @@ function register (agentRef, target) {
215
239
  timeToFetch, // fetchStart to fetchEnd
216
240
  timeToLoad: timeToFetch + timeToExecute, // fetch time and script time together
217
241
  timeToRegister: timings.registeredAt, // timestamp when register() was called
242
+ totalWeight: timings.totalWeight, // sum of transferSize bytes across every detected asset (entry script + matched manifest assets)
243
+ // renderBlocking: true if any detected asset was 'blocking', false if only 'non-blocking' assets were seen, omitted entirely if no detected asset reported the attribute at all
244
+ ...(timings.renderBlocking !== undefined && { renderBlocking: timings.renderBlocking }),
218
245
  // leave room to extend these with more data keys as needed
219
246
  ...(vitals.fcp.value >= 0 && { 'vitals.fcp.value': vitals.fcp.value }), // FCP vital object with value and metadata
220
247
  ...(vitals.lcp.value >= 0 && { 'vitals.lcp.value': vitals.lcp.value }), // LCP vital object with value and metadata