@newrelic/browser-agent 1.318.0-rc.9 → 1.319.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [1.319.0](https://github.com/newrelic/newrelic-browser-agent/compare/v1.318.0...v1.319.0) (2026-07-28)
7
+
8
+
9
+ ### Features
10
+
11
+ * Automatically Detect MFE Web Sockets ([#1726](https://github.com/newrelic/newrelic-browser-agent/issues/1726)) ([746d055](https://github.com/newrelic/newrelic-browser-agent/commit/746d05504c451b954cd0d2b86b92415c22293f2a))
12
+ * Do not obfuscate attribute keys ([#1810](https://github.com/newrelic/newrelic-browser-agent/issues/1810)) ([c5d9a77](https://github.com/newrelic/newrelic-browser-agent/commit/c5d9a773470d670cb27b63777dd035934f1dbfd9))
13
+ * MFE vitals improvements ([#1813](https://github.com/newrelic/newrelic-browser-agent/issues/1813)) ([21f38a9](https://github.com/newrelic/newrelic-browser-agent/commit/21f38a9af35f5204a47be02bfa2a73d41d8ba1f6))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * Avoid capturing agent AJAX payloads ([#1812](https://github.com/newrelic/newrelic-browser-agent/issues/1812)) ([ba069fc](https://github.com/newrelic/newrelic-browser-agent/commit/ba069fcea5f381db698044b84064bbba97676cec))
19
+ * Keep Ajax payload out of unrelated events ([#1809](https://github.com/newrelic/newrelic-browser-agent/issues/1809)) ([b0cb098](https://github.com/newrelic/newrelic-browser-agent/commit/b0cb098d0ea36d1d35212355bc51051f3c02c1ae))
20
+
6
21
  ## [1.318.0](https://github.com/newrelic/newrelic-browser-agent/compare/v1.317.0...v1.318.0) (2026-07-08)
7
22
 
8
23
 
@@ -17,7 +17,7 @@ exports.VERSION = exports.RRWEB_VERSION = exports.RRWEB_PACKAGE_NAME = exports.D
17
17
  /**
18
18
  * Exposes the version of the agent
19
19
  */
20
- const VERSION = exports.VERSION = "1.318.0-rc.9";
20
+ const VERSION = exports.VERSION = "1.319.0-rc.0";
21
21
 
22
22
  /**
23
23
  * Exposes the build type of the agent
@@ -17,7 +17,7 @@ exports.VERSION = exports.RRWEB_VERSION = exports.RRWEB_PACKAGE_NAME = exports.D
17
17
  /**
18
18
  * Exposes the version of the agent
19
19
  */
20
- const VERSION = exports.VERSION = "1.318.0-rc.9";
20
+ const VERSION = exports.VERSION = "1.319.0-rc.0";
21
21
 
22
22
  /**
23
23
  * Exposes the build type of the agent
@@ -12,7 +12,9 @@ var _now = require("../timing/now");
12
12
  */
13
13
 
14
14
  /**
15
- * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
15
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITimings} RegisterAPITimings
16
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITarget} RegisterAPITarget
17
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPIVitals} RegisterAPIVitals
16
18
  */
17
19
 
18
20
  const isObservable = node => {
@@ -22,6 +24,15 @@ const isObservable = node => {
22
24
  return false;
23
25
  }
24
26
  };
27
+ const isMatch = (dataset, target) => dataset?.nrMfeId === target.id && (!dataset?.nrMfeObserved || dataset?.nrMfeObserved === target.instance);
28
+ const escapeSelectorValue = value => {
29
+ try {
30
+ return _runtime.globalScope.CSS.escape(value);
31
+ } catch (e) {
32
+ // give up
33
+ return value;
34
+ }
35
+ };
25
36
 
26
37
  /**
27
38
  * Check if node is within a specific MFE
@@ -29,12 +40,17 @@ const isObservable = node => {
29
40
  * @param {string} id - MFE ID to match
30
41
  * @returns {boolean}
31
42
  */
32
- const isInMFE = (node, id) => {
33
- if (!node || !id) return false;
43
+ const isInMFE = (node, target = {}) => {
44
+ const id = target.id;
45
+ const instance = target.instance;
46
+ if (!node || !id || !instance) return false;
34
47
  try {
35
48
  let curr = node.nodeType === 1 ? node : node.parentElement;
36
49
  while (curr?.tagName) {
37
- if (curr.dataset?.nrMfeId === id) return true;
50
+ if (isMatch(curr.dataset, target)) {
51
+ curr.dataset.nrMfeObserved = instance; // mark that this MFE has been observed for vitals
52
+ return true;
53
+ }
38
54
  curr = curr.parentNode;
39
55
  }
40
56
  } catch (e) {}
@@ -47,16 +63,19 @@ const isInMFE = (node, id) => {
47
63
  * @param {Function} onMatch - Callback when matching node is *added*
48
64
  * @returns {MutationObserver}
49
65
  */
50
- const observeMutations = (id, onMatch) => {
66
+ const observeMutations = (target, onMatch) => {
51
67
  // Try to find existing MFE root
52
- const mfeRoot = _runtime.globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"));
68
+ const potentialMatches = _runtime.globalScope.document?.querySelectorAll("[data-nr-mfe-id=\"".concat(escapeSelectorValue(target.id), "\"]"));
69
+ const mfeRoot = (Array.from(potentialMatches) || []).find(x => isMatch(x.dataset, target));
53
70
  let observingRoot = !!mfeRoot;
71
+ if (observingRoot) mfeRoot.dataset.nrMfeObserved ??= target.instance; // mark that this MFE has been observed for vitals
72
+
54
73
  const obs = new _runtime.globalScope.MutationObserver(mutations => {
55
74
  mutations.forEach(m => {
56
75
  m.addedNodes.forEach(node => {
57
76
  // Check if this is the MFE root being added
58
77
  const elem = node.nodeType === 1 ? node : null;
59
- if (elem?.dataset?.nrMfeId === id) {
78
+ if (!observingRoot && isMatch(elem?.dataset, target)) {
60
79
  // Found the root! Lets switch to observing just this subtree for performance reasons
61
80
  obs.disconnect();
62
81
  obs.observe(elem, {
@@ -64,10 +83,11 @@ const observeMutations = (id, onMatch) => {
64
83
  subtree: true
65
84
  });
66
85
  observingRoot = true;
86
+ elem.dataset.nrMfeObserved = target.instance; // mark that this MFE has been observed for vitals
67
87
  }
68
88
 
69
89
  // Only check isInMFE if we're observing the whole document; skip expensive ancestor walk when observing root
70
- if (isObservable(node) && (observingRoot || isInMFE(node, id))) {
90
+ if (isObservable(node) && (observingRoot || isInMFE(node, target))) {
71
91
  onMatch(node, obs);
72
92
  }
73
93
  });
@@ -104,14 +124,15 @@ const observePerformance = (observers, config, onEntry) => {
104
124
 
105
125
  /**
106
126
  * Tracks all Core Web Vitals for a specific MFE.
107
- * @param {string} id - The MFE ID to track
108
- * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
127
+ * @param {RegisterAPITarget} target - The MFE target to track vitals for
128
+ * @param {RegisterAPITimings} timings - The timings object to use for relative time calculations
129
+ * @returns {RegisterAPIVitals} An object containing the vitals values and a disconnect method to stop tracking
109
130
  */
110
- function trackMFEVitals(id, timings) {
111
- let fcpObservedAt = null;
112
- let lcpObservedAt = null;
131
+ function trackMFEVitals(target, timings) {
132
+ let fcpObservedAt;
133
+ let lcpObservedAt;
113
134
  const getTimeRelativeToScriptStart = capturedAt => {
114
- if (capturedAt === null) return null;
135
+ if (capturedAt == null) return capturedAt;
115
136
  return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0);
116
137
  };
117
138
  const vitals = {
@@ -126,15 +147,21 @@ function trackMFEVitals(id, timings) {
126
147
  }
127
148
  },
128
149
  cls: {
129
- value: null
150
+ value: undefined
130
151
  },
131
152
  inp: {
132
- value: null
153
+ value: undefined
133
154
  },
134
155
  disconnect: () => {}
135
156
  };
136
- if (!id || !_runtime.isBrowserScope || !_runtime.globalScope.MutationObserver || !_runtime.globalScope.PerformanceObserver) return vitals;
157
+ if (!target || !_runtime.isBrowserScope || !_runtime.globalScope.MutationObserver || !_runtime.globalScope.PerformanceObserver) return vitals;
137
158
  const observers = [];
159
+
160
+ // If FCP hasn't been observed within 10 seconds, give up and shut down all observers.
161
+ // Once FCP is observed, the other vitals are left to record until their natural lifespan ends.
162
+ setTimeout(() => {
163
+ if (!fcpObservedAt) vitals.disconnect();
164
+ }, 10000);
138
165
  const populateVitalMinimums = () => {
139
166
  fcpObservedAt ??= (0, _now.now)();
140
167
  lcpObservedAt ??= (0, _now.now)();
@@ -142,14 +169,16 @@ function trackMFEVitals(id, timings) {
142
169
  };
143
170
 
144
171
  // if the MFE has already rendered something on the page before we could set up listeners, just populate vital minimums immediately
145
- if (_runtime.globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"))) populateVitalMinimums();
172
+ const existingRoots = _runtime.globalScope.document?.querySelectorAll("[data-nr-mfe-id=\"".concat(escapeSelectorValue(target.id), "\"]"));
173
+ if (Array.from(existingRoots || []).some(x => isMatch(x.dataset, target))) populateVitalMinimums();
146
174
 
147
175
  // Track FCP - first contentful paint
148
- observeMutations(id, (_, obs) => {
176
+ const fcpObs = observeMutations(target, (_, obs) => {
149
177
  // An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
150
178
  populateVitalMinimums();
151
179
  obs.disconnect();
152
180
  });
181
+ observers.push(fcpObs);
153
182
 
154
183
  // Track LCP - largest contentful paint
155
184
  let largestSize = 0;
@@ -173,7 +202,7 @@ function trackMFEVitals(id, timings) {
173
202
  } catch (e) {
174
203
  // ResizeObserver not supported
175
204
  }
176
- const lcpObs = observeMutations(id, node => {
205
+ const lcpObs = observeMutations(target, node => {
177
206
  // an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
178
207
  populateVitalMinimums();
179
208
  if (resizeObs) {
@@ -225,7 +254,7 @@ function trackMFEVitals(id, timings) {
225
254
  }, entry => {
226
255
  if (entry.hadRecentInput) return;
227
256
  (entry.sources || []).some(source => {
228
- if (isInMFE(source.node, id)) {
257
+ if (isInMFE(source.node, target)) {
229
258
  // an observed "CLS" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
230
259
  populateVitalMinimums();
231
260
  vitals.cls.value += entry.value;
@@ -241,8 +270,8 @@ function trackMFEVitals(id, timings) {
241
270
  buffered: true,
242
271
  durationThreshold: 40
243
272
  }, entry => {
244
- if (!entry.interactionId || !isInMFE(entry.target, id)) return;
245
- if (vitals.inp.value === null || entry.duration > vitals.inp.value) {
273
+ if (!entry.interactionId || !isInMFE(entry.target, target)) return;
274
+ if (vitals.inp.value === undefined || entry.duration > vitals.inp.value) {
246
275
  // an observed "INP" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
247
276
  populateVitalMinimums();
248
277
  vitals.inp.value = entry.duration;
@@ -268,6 +297,12 @@ function trackMFEVitals(id, timings) {
268
297
  }
269
298
  });
270
299
  disconnectInteractionListeners();
300
+ ['visibilitychange', 'pagehide'].forEach(type => {
301
+ _runtime.globalScope.removeEventListener(type, vitals.disconnect, {
302
+ once: true,
303
+ passive: true
304
+ });
305
+ });
271
306
  };
272
307
 
273
308
  // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
@@ -281,7 +316,7 @@ function trackMFEVitals(id, timings) {
281
316
  }
282
317
  };
283
318
  const handleInteraction = event => {
284
- if (!isInMFE(event?.target, id)) return;
319
+ if (!isInMFE(event?.target, target)) return;
285
320
  disconnectLCP();
286
321
  disconnectInteractionListeners();
287
322
  };
@@ -53,4 +53,11 @@ exports.default = void 0;
53
53
  * @property {Object} [asset] - The asset path (if found) for the registered entity.
54
54
  * @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.
55
55
  */
56
+ /**
57
+ * @typedef {Object} RegisterAPIVitals
58
+ * @property {number} [fcp] - The first contentful paint timing for the registered entity.
59
+ * @property {number} [lcp] - The largest contentful paint timing for the registered entity.
60
+ * @property {number} [cls] - The cumulative layout shift score for the registered entity.
61
+ * @property {number} [inp] - The interaction to next paint timing for the registered entity.
62
+ */
56
63
  var _default = exports.default = {};
@@ -87,7 +87,7 @@ function register(agentRef, target) {
87
87
  const timings = (0, _scriptTracker.findScriptTimings)();
88
88
 
89
89
  // Track MFE vitals for this entity
90
- const vitals = (0, _mfeVitals.trackMFEVitals)(target.id, timings);
90
+ const vitals = (0, _mfeVitals.trackMFEVitals)(target, timings);
91
91
  const attrs = {};
92
92
 
93
93
  // Only define attributes getter if it doesn't already exist
@@ -233,13 +233,21 @@ function register(agentRef, target) {
233
233
  timeToRegister: timings.registeredAt,
234
234
  // timestamp when register() was called
235
235
  // leave room to extend these with more data keys as needed
236
- 'nr.vitals.fcp.value': vitals.fcp?.value ?? null,
236
+ ...(vitals.fcp.value >= 0 && {
237
+ 'nr.vitals.fcp.value': vitals.fcp.value
238
+ }),
237
239
  // FCP vital object with value and metadata
238
- 'nr.vitals.lcp.value': vitals.lcp?.value ?? null,
240
+ ...(vitals.lcp.value >= 0 && {
241
+ 'nr.vitals.lcp.value': vitals.lcp.value
242
+ }),
239
243
  // LCP vital object with value and metadata
240
- 'nr.vitals.cls.value': vitals.cls?.value ?? null,
244
+ ...(vitals.cls.value >= 0 && {
245
+ 'nr.vitals.cls.value': vitals.cls.value
246
+ }),
241
247
  // CLS vital object with value and metadata
242
- 'nr.vitals.inp.value': vitals.inp?.value ?? null // INP vital object with value and metadata
248
+ ...(vitals.inp.value >= 0 && {
249
+ 'nr.vitals.inp.value': vitals.inp.value
250
+ }) // INP vital object with value and metadata
243
251
  };
244
252
  api.recordCustomEvent('MicroFrontEndTiming', eventData);
245
253
  }
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.318.0-rc.9";
14
+ export const VERSION = "1.319.0-rc.0";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.318.0-rc.9";
14
+ export const VERSION = "1.319.0-rc.0";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -7,7 +7,9 @@ import { globalScope, isBrowserScope } from '../constants/runtime';
7
7
  import { now } from '../timing/now';
8
8
 
9
9
  /**
10
- * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
10
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITimings} RegisterAPITimings
11
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITarget} RegisterAPITarget
12
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPIVitals} RegisterAPIVitals
11
13
  */
12
14
 
13
15
  const isObservable = node => {
@@ -17,6 +19,15 @@ const isObservable = node => {
17
19
  return false;
18
20
  }
19
21
  };
22
+ const isMatch = (dataset, target) => dataset?.nrMfeId === target.id && (!dataset?.nrMfeObserved || dataset?.nrMfeObserved === target.instance);
23
+ const escapeSelectorValue = value => {
24
+ try {
25
+ return globalScope.CSS.escape(value);
26
+ } catch (e) {
27
+ // give up
28
+ return value;
29
+ }
30
+ };
20
31
 
21
32
  /**
22
33
  * Check if node is within a specific MFE
@@ -24,12 +35,17 @@ const isObservable = node => {
24
35
  * @param {string} id - MFE ID to match
25
36
  * @returns {boolean}
26
37
  */
27
- const isInMFE = (node, id) => {
28
- if (!node || !id) return false;
38
+ const isInMFE = (node, target = {}) => {
39
+ const id = target.id;
40
+ const instance = target.instance;
41
+ if (!node || !id || !instance) return false;
29
42
  try {
30
43
  let curr = node.nodeType === 1 ? node : node.parentElement;
31
44
  while (curr?.tagName) {
32
- if (curr.dataset?.nrMfeId === id) return true;
45
+ if (isMatch(curr.dataset, target)) {
46
+ curr.dataset.nrMfeObserved = instance; // mark that this MFE has been observed for vitals
47
+ return true;
48
+ }
33
49
  curr = curr.parentNode;
34
50
  }
35
51
  } catch (e) {}
@@ -42,16 +58,19 @@ const isInMFE = (node, id) => {
42
58
  * @param {Function} onMatch - Callback when matching node is *added*
43
59
  * @returns {MutationObserver}
44
60
  */
45
- const observeMutations = (id, onMatch) => {
61
+ const observeMutations = (target, onMatch) => {
46
62
  // Try to find existing MFE root
47
- const mfeRoot = globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"));
63
+ const potentialMatches = globalScope.document?.querySelectorAll("[data-nr-mfe-id=\"".concat(escapeSelectorValue(target.id), "\"]"));
64
+ const mfeRoot = (Array.from(potentialMatches) || []).find(x => isMatch(x.dataset, target));
48
65
  let observingRoot = !!mfeRoot;
66
+ if (observingRoot) mfeRoot.dataset.nrMfeObserved ??= target.instance; // mark that this MFE has been observed for vitals
67
+
49
68
  const obs = new globalScope.MutationObserver(mutations => {
50
69
  mutations.forEach(m => {
51
70
  m.addedNodes.forEach(node => {
52
71
  // Check if this is the MFE root being added
53
72
  const elem = node.nodeType === 1 ? node : null;
54
- if (elem?.dataset?.nrMfeId === id) {
73
+ if (!observingRoot && isMatch(elem?.dataset, target)) {
55
74
  // Found the root! Lets switch to observing just this subtree for performance reasons
56
75
  obs.disconnect();
57
76
  obs.observe(elem, {
@@ -59,10 +78,11 @@ const observeMutations = (id, onMatch) => {
59
78
  subtree: true
60
79
  });
61
80
  observingRoot = true;
81
+ elem.dataset.nrMfeObserved = target.instance; // mark that this MFE has been observed for vitals
62
82
  }
63
83
 
64
84
  // Only check isInMFE if we're observing the whole document; skip expensive ancestor walk when observing root
65
- if (isObservable(node) && (observingRoot || isInMFE(node, id))) {
85
+ if (isObservable(node) && (observingRoot || isInMFE(node, target))) {
66
86
  onMatch(node, obs);
67
87
  }
68
88
  });
@@ -99,14 +119,15 @@ const observePerformance = (observers, config, onEntry) => {
99
119
 
100
120
  /**
101
121
  * Tracks all Core Web Vitals for a specific MFE.
102
- * @param {string} id - The MFE ID to track
103
- * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
122
+ * @param {RegisterAPITarget} target - The MFE target to track vitals for
123
+ * @param {RegisterAPITimings} timings - The timings object to use for relative time calculations
124
+ * @returns {RegisterAPIVitals} An object containing the vitals values and a disconnect method to stop tracking
104
125
  */
105
- export function trackMFEVitals(id, timings) {
106
- let fcpObservedAt = null;
107
- let lcpObservedAt = null;
126
+ export function trackMFEVitals(target, timings) {
127
+ let fcpObservedAt;
128
+ let lcpObservedAt;
108
129
  const getTimeRelativeToScriptStart = capturedAt => {
109
- if (capturedAt === null) return null;
130
+ if (capturedAt == null) return capturedAt;
110
131
  return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0);
111
132
  };
112
133
  const vitals = {
@@ -121,15 +142,21 @@ export function trackMFEVitals(id, timings) {
121
142
  }
122
143
  },
123
144
  cls: {
124
- value: null
145
+ value: undefined
125
146
  },
126
147
  inp: {
127
- value: null
148
+ value: undefined
128
149
  },
129
150
  disconnect: () => {}
130
151
  };
131
- if (!id || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals;
152
+ if (!target || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals;
132
153
  const observers = [];
154
+
155
+ // If FCP hasn't been observed within 10 seconds, give up and shut down all observers.
156
+ // Once FCP is observed, the other vitals are left to record until their natural lifespan ends.
157
+ setTimeout(() => {
158
+ if (!fcpObservedAt) vitals.disconnect();
159
+ }, 10000);
133
160
  const populateVitalMinimums = () => {
134
161
  fcpObservedAt ??= now();
135
162
  lcpObservedAt ??= now();
@@ -137,14 +164,16 @@ export function trackMFEVitals(id, timings) {
137
164
  };
138
165
 
139
166
  // if the MFE has already rendered something on the page before we could set up listeners, just populate vital minimums immediately
140
- if (globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"))) populateVitalMinimums();
167
+ const existingRoots = globalScope.document?.querySelectorAll("[data-nr-mfe-id=\"".concat(escapeSelectorValue(target.id), "\"]"));
168
+ if (Array.from(existingRoots || []).some(x => isMatch(x.dataset, target))) populateVitalMinimums();
141
169
 
142
170
  // Track FCP - first contentful paint
143
- observeMutations(id, (_, obs) => {
171
+ const fcpObs = observeMutations(target, (_, obs) => {
144
172
  // An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
145
173
  populateVitalMinimums();
146
174
  obs.disconnect();
147
175
  });
176
+ observers.push(fcpObs);
148
177
 
149
178
  // Track LCP - largest contentful paint
150
179
  let largestSize = 0;
@@ -168,7 +197,7 @@ export function trackMFEVitals(id, timings) {
168
197
  } catch (e) {
169
198
  // ResizeObserver not supported
170
199
  }
171
- const lcpObs = observeMutations(id, node => {
200
+ const lcpObs = observeMutations(target, node => {
172
201
  // an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
173
202
  populateVitalMinimums();
174
203
  if (resizeObs) {
@@ -220,7 +249,7 @@ export function trackMFEVitals(id, timings) {
220
249
  }, entry => {
221
250
  if (entry.hadRecentInput) return;
222
251
  (entry.sources || []).some(source => {
223
- if (isInMFE(source.node, id)) {
252
+ if (isInMFE(source.node, target)) {
224
253
  // an observed "CLS" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
225
254
  populateVitalMinimums();
226
255
  vitals.cls.value += entry.value;
@@ -236,8 +265,8 @@ export function trackMFEVitals(id, timings) {
236
265
  buffered: true,
237
266
  durationThreshold: 40
238
267
  }, entry => {
239
- if (!entry.interactionId || !isInMFE(entry.target, id)) return;
240
- if (vitals.inp.value === null || entry.duration > vitals.inp.value) {
268
+ if (!entry.interactionId || !isInMFE(entry.target, target)) return;
269
+ if (vitals.inp.value === undefined || entry.duration > vitals.inp.value) {
241
270
  // an observed "INP" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
242
271
  populateVitalMinimums();
243
272
  vitals.inp.value = entry.duration;
@@ -263,6 +292,12 @@ export function trackMFEVitals(id, timings) {
263
292
  }
264
293
  });
265
294
  disconnectInteractionListeners();
295
+ ['visibilitychange', 'pagehide'].forEach(type => {
296
+ globalScope.removeEventListener(type, vitals.disconnect, {
297
+ once: true,
298
+ passive: true
299
+ });
300
+ });
266
301
  };
267
302
 
268
303
  // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
@@ -276,7 +311,7 @@ export function trackMFEVitals(id, timings) {
276
311
  }
277
312
  };
278
313
  const handleInteraction = event => {
279
- if (!isInMFE(event?.target, id)) return;
314
+ if (!isInMFE(event?.target, target)) return;
280
315
  disconnectLCP();
281
316
  disconnectInteractionListeners();
282
317
  };
@@ -53,4 +53,12 @@
53
53
  * @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.
54
54
  */
55
55
 
56
+ /**
57
+ * @typedef {Object} RegisterAPIVitals
58
+ * @property {number} [fcp] - The first contentful paint timing for the registered entity.
59
+ * @property {number} [lcp] - The largest contentful paint timing for the registered entity.
60
+ * @property {number} [cls] - The cumulative layout shift score for the registered entity.
61
+ * @property {number} [inp] - The interaction to next paint timing for the registered entity.
62
+ */
63
+
56
64
  export default {};
@@ -80,7 +80,7 @@ function register(agentRef, target) {
80
80
  const timings = findScriptTimings();
81
81
 
82
82
  // Track MFE vitals for this entity
83
- const vitals = trackMFEVitals(target.id, timings);
83
+ const vitals = trackMFEVitals(target, timings);
84
84
  const attrs = {};
85
85
 
86
86
  // Only define attributes getter if it doesn't already exist
@@ -226,13 +226,21 @@ function register(agentRef, target) {
226
226
  timeToRegister: timings.registeredAt,
227
227
  // timestamp when register() was called
228
228
  // leave room to extend these with more data keys as needed
229
- 'nr.vitals.fcp.value': vitals.fcp?.value ?? null,
229
+ ...(vitals.fcp.value >= 0 && {
230
+ 'nr.vitals.fcp.value': vitals.fcp.value
231
+ }),
230
232
  // FCP vital object with value and metadata
231
- 'nr.vitals.lcp.value': vitals.lcp?.value ?? null,
233
+ ...(vitals.lcp.value >= 0 && {
234
+ 'nr.vitals.lcp.value': vitals.lcp.value
235
+ }),
232
236
  // LCP vital object with value and metadata
233
- 'nr.vitals.cls.value': vitals.cls?.value ?? null,
237
+ ...(vitals.cls.value >= 0 && {
238
+ 'nr.vitals.cls.value': vitals.cls.value
239
+ }),
234
240
  // CLS vital object with value and metadata
235
- 'nr.vitals.inp.value': vitals.inp?.value ?? null // INP vital object with value and metadata
241
+ ...(vitals.inp.value >= 0 && {
242
+ 'nr.vitals.inp.value': vitals.inp.value
243
+ }) // INP vital object with value and metadata
236
244
  };
237
245
  api.recordCustomEvent('MicroFrontEndTiming', eventData);
238
246
  }
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * Tracks all Core Web Vitals for a specific MFE.
3
- * @param {string} id - The MFE ID to track
4
- * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
3
+ * @param {RegisterAPITarget} target - The MFE target to track vitals for
4
+ * @param {RegisterAPITimings} timings - The timings object to use for relative time calculations
5
+ * @returns {RegisterAPIVitals} An object containing the vitals values and a disconnect method to stop tracking
5
6
  */
6
- export function trackMFEVitals(id: string, timings: any): {
7
- fcp: object | null;
8
- lcp: object | null;
9
- cls: object | null;
10
- inp: object | null;
11
- disconnect: Function;
12
- };
13
- export type RegisterAPITimings = any;
7
+ export function trackMFEVitals(target: RegisterAPITarget, timings: RegisterAPITimings): RegisterAPIVitals;
8
+ export type RegisterAPITimings = import("../../loaders/api/register-api-types").RegisterAPITimings;
9
+ export type RegisterAPITarget = import("../../loaders/api/register-api-types").RegisterAPITarget;
10
+ export type RegisterAPIVitals = import("../../loaders/api/register-api-types").RegisterAPIVitals;
14
11
  //# sourceMappingURL=mfe-vitals.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mfe-vitals.d.ts","sourceRoot":"","sources":["../../../../src/common/v2/mfe-vitals.js"],"names":[],"mappings":"AA+FA;;;;GAIG;AACH,mCAHW,MAAM,iBACJ;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,UAAU,WAAU;CAAC,CA0L1G"}
1
+ {"version":3,"file":"mfe-vitals.d.ts","sourceRoot":"","sources":["../../../../src/common/v2/mfe-vitals.js"],"names":[],"mappings":"AAoHA;;;;;GAKG;AACH,uCAJW,iBAAiB,WACjB,kBAAkB,GAChB,iBAAiB,CAqM7B;iCApTY,OAAO,sCAAsC,EAAE,kBAAkB;gCACjE,OAAO,sCAAsC,EAAE,iBAAiB;gCAChE,OAAO,sCAAsC,EAAE,iBAAiB"}
@@ -146,4 +146,22 @@ export type RegisterAPITimings = {
146
146
  */
147
147
  type: string;
148
148
  };
149
+ export type RegisterAPIVitals = {
150
+ /**
151
+ * - The first contentful paint timing for the registered entity.
152
+ */
153
+ fcp?: number | undefined;
154
+ /**
155
+ * - The largest contentful paint timing for the registered entity.
156
+ */
157
+ lcp?: number | undefined;
158
+ /**
159
+ * - The cumulative layout shift score for the registered entity.
160
+ */
161
+ cls?: number | undefined;
162
+ /**
163
+ * - The interaction to next paint timing for the registered entity.
164
+ */
165
+ inp?: number | undefined;
166
+ };
149
167
  //# sourceMappingURL=register-api-types.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/browser-agent",
3
- "version": "1.318.0-rc.9",
3
+ "version": "1.319.0-rc.0",
4
4
  "private": false,
5
5
  "author": "New Relic Browser Agent Team <browser-agent@newrelic.com>",
6
6
  "description": "New Relic Browser Agent",
@@ -7,7 +7,9 @@ import { globalScope, isBrowserScope } from '../constants/runtime'
7
7
  import { now } from '../timing/now'
8
8
 
9
9
  /**
10
- * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
10
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITimings} RegisterAPITimings
11
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITarget} RegisterAPITarget
12
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPIVitals} RegisterAPIVitals
11
13
  */
12
14
 
13
15
  const isObservable = (node) => {
@@ -18,18 +20,34 @@ const isObservable = (node) => {
18
20
  }
19
21
  }
20
22
 
23
+ const isMatch = (dataset, target) => dataset?.nrMfeId === target.id && (!dataset?.nrMfeObserved || dataset?.nrMfeObserved === target.instance)
24
+
25
+ const escapeSelectorValue = (value) => {
26
+ try {
27
+ return globalScope.CSS.escape(value)
28
+ } catch (e) {
29
+ // give up
30
+ return value
31
+ }
32
+ }
33
+
21
34
  /**
22
35
  * Check if node is within a specific MFE
23
36
  * @param {Node} node - DOM node to check
24
37
  * @param {string} id - MFE ID to match
25
38
  * @returns {boolean}
26
39
  */
27
- const isInMFE = (node, id) => {
28
- if (!node || !id) return false
40
+ const isInMFE = (node, target = {}) => {
41
+ const id = target.id
42
+ const instance = target.instance
43
+ if (!node || !id || !instance) return false
29
44
  try {
30
45
  let curr = node.nodeType === 1 ? node : node.parentElement
31
46
  while (curr?.tagName) {
32
- if (curr.dataset?.nrMfeId === id) return true
47
+ if (isMatch(curr.dataset, target)) {
48
+ curr.dataset.nrMfeObserved = instance // mark that this MFE has been observed for vitals
49
+ return true
50
+ }
33
51
  curr = curr.parentNode
34
52
  }
35
53
  } catch (e) {}
@@ -42,25 +60,28 @@ const isInMFE = (node, id) => {
42
60
  * @param {Function} onMatch - Callback when matching node is *added*
43
61
  * @returns {MutationObserver}
44
62
  */
45
- const observeMutations = (id, onMatch) => {
63
+ const observeMutations = (target, onMatch) => {
46
64
  // Try to find existing MFE root
47
- const mfeRoot = globalScope.document?.querySelector(`[data-nr-mfe-id="${id}"]`)
65
+ const potentialMatches = globalScope.document?.querySelectorAll(`[data-nr-mfe-id="${escapeSelectorValue(target.id)}"]`)
66
+ const mfeRoot = (Array.from(potentialMatches) || []).find(x => isMatch(x.dataset, target))
48
67
  let observingRoot = !!mfeRoot
68
+ if (observingRoot) mfeRoot.dataset.nrMfeObserved ??= target.instance // mark that this MFE has been observed for vitals
49
69
 
50
70
  const obs = new globalScope.MutationObserver(mutations => {
51
71
  mutations.forEach(m => {
52
72
  m.addedNodes.forEach(node => {
53
73
  // Check if this is the MFE root being added
54
74
  const elem = node.nodeType === 1 ? node : null
55
- if (elem?.dataset?.nrMfeId === id) {
75
+ if (!observingRoot && isMatch(elem?.dataset, target)) {
56
76
  // Found the root! Lets switch to observing just this subtree for performance reasons
57
77
  obs.disconnect()
58
78
  obs.observe(elem, { childList: true, subtree: true })
59
79
  observingRoot = true
80
+ elem.dataset.nrMfeObserved = target.instance // mark that this MFE has been observed for vitals
60
81
  }
61
82
 
62
83
  // Only check isInMFE if we're observing the whole document; skip expensive ancestor walk when observing root
63
- if (isObservable(node) && (observingRoot || isInMFE(node, id))) {
84
+ if (isObservable(node) && (observingRoot || isInMFE(node, target))) {
64
85
  onMatch(node, obs)
65
86
  }
66
87
  })
@@ -95,15 +116,16 @@ const observePerformance = (observers, config, onEntry) => {
95
116
 
96
117
  /**
97
118
  * Tracks all Core Web Vitals for a specific MFE.
98
- * @param {string} id - The MFE ID to track
99
- * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
119
+ * @param {RegisterAPITarget} target - The MFE target to track vitals for
120
+ * @param {RegisterAPITimings} timings - The timings object to use for relative time calculations
121
+ * @returns {RegisterAPIVitals} An object containing the vitals values and a disconnect method to stop tracking
100
122
  */
101
- export function trackMFEVitals (id, timings) {
102
- let fcpObservedAt = null
103
- let lcpObservedAt = null
123
+ export function trackMFEVitals (target, timings) {
124
+ let fcpObservedAt
125
+ let lcpObservedAt
104
126
 
105
127
  const getTimeRelativeToScriptStart = (capturedAt) => {
106
- if (capturedAt === null) return null
128
+ if (capturedAt == null) return capturedAt
107
129
  return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0)
108
130
  }
109
131
 
@@ -115,18 +137,24 @@ export function trackMFEVitals (id, timings) {
115
137
  get value () { return getTimeRelativeToScriptStart(lcpObservedAt) }
116
138
  },
117
139
  cls: {
118
- value: null
140
+ value: undefined
119
141
  },
120
142
  inp: {
121
- value: null
143
+ value: undefined
122
144
  },
123
145
  disconnect: () => {}
124
146
  }
125
147
 
126
- if (!id || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals
148
+ if (!target || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals
127
149
 
128
150
  const observers = []
129
151
 
152
+ // If FCP hasn't been observed within 10 seconds, give up and shut down all observers.
153
+ // Once FCP is observed, the other vitals are left to record until their natural lifespan ends.
154
+ setTimeout(() => {
155
+ if (!fcpObservedAt) vitals.disconnect()
156
+ }, 10000)
157
+
130
158
  const populateVitalMinimums = () => {
131
159
  fcpObservedAt ??= now()
132
160
  lcpObservedAt ??= now()
@@ -134,14 +162,16 @@ export function trackMFEVitals (id, timings) {
134
162
  }
135
163
 
136
164
  // if the MFE has already rendered something on the page before we could set up listeners, just populate vital minimums immediately
137
- if (globalScope.document?.querySelector(`[data-nr-mfe-id="${id}"]`)) populateVitalMinimums()
165
+ const existingRoots = globalScope.document?.querySelectorAll(`[data-nr-mfe-id="${escapeSelectorValue(target.id)}"]`)
166
+ if (Array.from(existingRoots || []).some(x => isMatch(x.dataset, target))) populateVitalMinimums()
138
167
 
139
168
  // Track FCP - first contentful paint
140
- observeMutations(id, (_, obs) => {
169
+ const fcpObs = observeMutations(target, (_, obs) => {
141
170
  // An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
142
171
  populateVitalMinimums()
143
172
  obs.disconnect()
144
173
  })
174
+ observers.push(fcpObs)
145
175
 
146
176
  // Track LCP - largest contentful paint
147
177
  let largestSize = 0
@@ -167,7 +197,7 @@ export function trackMFEVitals (id, timings) {
167
197
  // ResizeObserver not supported
168
198
  }
169
199
 
170
- const lcpObs = observeMutations(id, (node) => {
200
+ const lcpObs = observeMutations(target, (node) => {
171
201
  // an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
172
202
  populateVitalMinimums()
173
203
 
@@ -214,7 +244,7 @@ export function trackMFEVitals (id, timings) {
214
244
  observePerformance(observers, { type: 'layout-shift', buffered: true }, (entry) => {
215
245
  if (entry.hadRecentInput) return
216
246
  ;(entry.sources || []).some(source => {
217
- if (isInMFE(source.node, id)) {
247
+ if (isInMFE(source.node, target)) {
218
248
  // an observed "CLS" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
219
249
  populateVitalMinimums()
220
250
  vitals.cls.value += entry.value
@@ -226,8 +256,8 @@ export function trackMFEVitals (id, timings) {
226
256
 
227
257
  // Track INP - interaction to next paint
228
258
  observePerformance(observers, { type: 'event', buffered: true, durationThreshold: 40 }, (entry) => {
229
- if (!entry.interactionId || !isInMFE(entry.target, id)) return
230
- if (vitals.inp.value === null || entry.duration > vitals.inp.value) {
259
+ if (!entry.interactionId || !isInMFE(entry.target, target)) return
260
+ if (vitals.inp.value === undefined || entry.duration > vitals.inp.value) {
231
261
  // an observed "INP" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
232
262
  populateVitalMinimums()
233
263
  vitals.inp.value = entry.duration
@@ -252,6 +282,9 @@ export function trackMFEVitals (id, timings) {
252
282
  }
253
283
  })
254
284
  disconnectInteractionListeners()
285
+ ;['visibilitychange', 'pagehide'].forEach(type => {
286
+ globalScope.removeEventListener(type, vitals.disconnect, { once: true, passive: true })
287
+ })
255
288
  }
256
289
 
257
290
  // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
@@ -266,7 +299,7 @@ export function trackMFEVitals (id, timings) {
266
299
  }
267
300
 
268
301
  const handleInteraction = (event) => {
269
- if (!isInMFE(event?.target, id)) return
302
+ if (!isInMFE(event?.target, target)) return
270
303
 
271
304
  disconnectLCP()
272
305
  disconnectInteractionListeners()
@@ -53,4 +53,12 @@
53
53
  * @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.
54
54
  */
55
55
 
56
+ /**
57
+ * @typedef {Object} RegisterAPIVitals
58
+ * @property {number} [fcp] - The first contentful paint timing for the registered entity.
59
+ * @property {number} [lcp] - The largest contentful paint timing for the registered entity.
60
+ * @property {number} [cls] - The cumulative layout shift score for the registered entity.
61
+ * @property {number} [inp] - The interaction to next paint timing for the registered entity.
62
+ */
63
+
56
64
  export default {}
@@ -85,7 +85,7 @@ function register (agentRef, target) {
85
85
  const timings = findScriptTimings()
86
86
 
87
87
  // Track MFE vitals for this entity
88
- const vitals = trackMFEVitals(target.id, timings)
88
+ const vitals = trackMFEVitals(target, timings)
89
89
 
90
90
  const attrs = {}
91
91
 
@@ -203,10 +203,10 @@ function register (agentRef, target) {
203
203
  timeToLoad: timeToFetch + timeToExecute, // fetch time and script time together
204
204
  timeToRegister: timings.registeredAt, // timestamp when register() was called
205
205
  // leave room to extend these with more data keys as needed
206
- 'nr.vitals.fcp.value': vitals.fcp?.value ?? null, // FCP vital object with value and metadata
207
- 'nr.vitals.lcp.value': vitals.lcp?.value ?? null, // LCP vital object with value and metadata
208
- 'nr.vitals.cls.value': vitals.cls?.value ?? null, // CLS vital object with value and metadata
209
- 'nr.vitals.inp.value': vitals.inp?.value ?? null // INP vital object with value and metadata
206
+ ...(vitals.fcp.value >= 0 && { 'nr.vitals.fcp.value': vitals.fcp.value }), // FCP vital object with value and metadata
207
+ ...(vitals.lcp.value >= 0 && { 'nr.vitals.lcp.value': vitals.lcp.value }), // LCP vital object with value and metadata
208
+ ...(vitals.cls.value >= 0 && { 'nr.vitals.cls.value': vitals.cls.value }), // CLS vital object with value and metadata
209
+ ...(vitals.inp.value >= 0 && { 'nr.vitals.inp.value': vitals.inp.value }) // INP vital object with value and metadata
210
210
  }
211
211
 
212
212
  api.recordCustomEvent('MicroFrontEndTiming', eventData)