@newrelic/browser-agent 1.317.0-rc.2 → 1.317.0-rc.4

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.
@@ -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.317.0-rc.2";
20
+ const VERSION = exports.VERSION = "1.317.0-rc.4";
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.317.0-rc.2";
20
+ const VERSION = exports.VERSION = "1.317.0-rc.4";
21
21
 
22
22
  /**
23
23
  * Exposes the build type of the agent
@@ -6,7 +6,6 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.trackMFEVitals = trackMFEVitals;
7
7
  var _runtime = require("../constants/runtime");
8
8
  var _now = require("../timing/now");
9
- var _cleanUrl = require("../url/clean-url");
10
9
  /**
11
10
  * Copyright 2020-2026 New Relic, Inc. All rights reserved.
12
11
  * SPDX-License-Identifier: Apache-2.0
@@ -16,48 +15,6 @@ var _cleanUrl = require("../url/clean-url");
16
15
  * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
17
16
  */
18
17
 
19
- /**
20
- * Generate a simple CSS selector for an element
21
- * @param {Element} elem - The element to generate selector for
22
- * @returns {string|null} CSS selector or null if element is invalid
23
- */
24
- const getElementSelector = elem => {
25
- try {
26
- if (!elem || !elem.tagName) return null;
27
- let selector = elem.tagName.toLowerCase();
28
- if (elem.id) selector += "#".concat(elem.id);else if (elem.className && typeof elem.className === 'string') {
29
- const classes = elem.className.trim().split(/\s+/).slice(0, 2).join('.');
30
- if (classes) selector += ".".concat(classes);
31
- }
32
- return selector;
33
- } catch (e) {
34
- return null;
35
- }
36
- };
37
-
38
- /**
39
- * Extract URL from an element (for images, videos, etc.)
40
- * @param {Element} elem - The element to extract URL from
41
- * @returns {string|null} Cleaned URL or null
42
- */
43
- const getElementURL = elem => {
44
- try {
45
- if (!elem) return null;
46
-
47
- // For images and videos
48
- if (elem.src) return (0, _cleanUrl.cleanURL)(elem.src);
49
-
50
- // For elements with background images
51
- const bgImage = _runtime.globalScope.getComputedStyle?.(elem)?.backgroundImage;
52
- if (bgImage && bgImage !== 'none') {
53
- const match = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
54
- if (match?.[1]) return (0, _cleanUrl.cleanURL)(match[1]);
55
- }
56
- return null;
57
- } catch (e) {
58
- return null;
59
- }
60
- };
61
18
  const isObservable = node => {
62
19
  try {
63
20
  return node?.textContent?.trim() || ['img', 'video', 'canvas', 'svg'].includes(node?.nodeName?.toLowerCase());
@@ -73,6 +30,7 @@ const isObservable = node => {
73
30
  * @returns {boolean}
74
31
  */
75
32
  const isInMFE = (node, id) => {
33
+ if (!node || !id) return false;
76
34
  try {
77
35
  let curr = node.nodeType === 1 ? node : node.parentElement;
78
36
  while (curr?.tagName) {
@@ -86,18 +44,38 @@ const isInMFE = (node, id) => {
86
44
  /**
87
45
  * Create mutation observer for MFE nodes
88
46
  * @param {string} id - MFE ID to track
89
- * @param {Function} onMatch - Callback when matching node is added
47
+ * @param {Function} onMatch - Callback when matching node is *added*
90
48
  * @returns {MutationObserver}
91
49
  */
92
50
  const observeMutations = (id, onMatch) => {
51
+ // Try to find existing MFE root
52
+ const mfeRoot = _runtime.globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"));
53
+ let observingRoot = !!mfeRoot;
93
54
  const obs = new _runtime.globalScope.MutationObserver(mutations => {
94
- mutations.forEach(m => m.addedNodes.forEach(node => {
95
- if (isObservable(node) && isInMFE(node, id)) {
96
- onMatch(node, obs);
97
- }
98
- }));
55
+ mutations.forEach(m => {
56
+ m.addedNodes.forEach(node => {
57
+ // Check if this is the MFE root being added
58
+ const elem = node.nodeType === 1 ? node : null;
59
+ if (elem?.dataset?.nrMfeId === id) {
60
+ // Found the root! Lets switch to observing just this subtree for performance reasons
61
+ obs.disconnect();
62
+ obs.observe(elem, {
63
+ childList: true,
64
+ subtree: true
65
+ });
66
+ observingRoot = true;
67
+ }
68
+
69
+ // 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))) {
71
+ onMatch(node, obs);
72
+ }
73
+ });
74
+ });
99
75
  });
100
- obs.observe(_runtime.globalScope.document, {
76
+
77
+ // If root exists, observe just that subtree; otherwise observe document to catch when root is added
78
+ obs.observe(mfeRoot || _runtime.globalScope.document, {
101
79
  childList: true,
102
80
  subtree: true
103
81
  });
@@ -129,122 +107,155 @@ const observePerformance = (observers, config, onEntry) => {
129
107
  * @param {string} id - The MFE ID to track
130
108
  * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
131
109
  */
132
- function trackMFEVitals(id) {
110
+ function trackMFEVitals(id, timings) {
111
+ let fcpObservedAt = null;
112
+ let lcpObservedAt = null;
113
+ const getTimeRelativeToScriptStart = capturedAt => {
114
+ if (capturedAt === null) return null;
115
+ return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0);
116
+ };
133
117
  const vitals = {
134
- fcp: null,
135
- lcp: null,
136
- cls: null,
137
- inp: null,
118
+ fcp: {
119
+ get value() {
120
+ return getTimeRelativeToScriptStart(fcpObservedAt);
121
+ }
122
+ },
123
+ lcp: {
124
+ get value() {
125
+ return getTimeRelativeToScriptStart(lcpObservedAt);
126
+ }
127
+ },
128
+ cls: {
129
+ value: null
130
+ },
131
+ inp: {
132
+ value: null
133
+ },
138
134
  disconnect: () => {}
139
135
  };
140
136
  if (!id || !_runtime.isBrowserScope || !_runtime.globalScope.MutationObserver || !_runtime.globalScope.PerformanceObserver) return vitals;
141
137
  const observers = [];
138
+ const populateVitalMinimums = () => {
139
+ fcpObservedAt ??= (0, _now.now)();
140
+ lcpObservedAt ??= (0, _now.now)();
141
+ vitals.cls.value ??= 0;
142
+ };
143
+
144
+ // 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();
142
146
 
143
147
  // Track FCP - first contentful paint
144
- observeMutations(id, (node, obs) => {
145
- if (vitals.fcp === null) {
146
- vitals.fcp = {
147
- value: (0, _now.now)(),
148
- loadState: _runtime.globalScope.document?.readyState || null
149
- };
150
- obs.disconnect();
151
- }
148
+ observeMutations(id, (_, obs) => {
149
+ // An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
150
+ populateVitalMinimums();
151
+ obs.disconnect();
152
152
  });
153
153
 
154
154
  // Track LCP - largest contentful paint
155
155
  let largestSize = 0;
156
+ let resizeObs = null;
157
+ const observedElements = new Set();
158
+ try {
159
+ resizeObs = new _runtime.globalScope.ResizeObserver(entries => {
160
+ entries.forEach(entry => {
161
+ try {
162
+ const size = entry.contentRect.width * entry.contentRect.height;
163
+ if (size > largestSize) {
164
+ largestSize = size;
165
+ lcpObservedAt = (0, _now.now)();
166
+ }
167
+ resizeObs.unobserve(entry.target);
168
+ } catch (e) {
169
+ // Element may be detached from DOM
170
+ }
171
+ });
172
+ });
173
+ } catch (e) {
174
+ // ResizeObserver not supported
175
+ }
156
176
  const lcpObs = observeMutations(id, node => {
157
- try {
158
- const elem = node.nodeType === 1 ? node : node.parentElement;
159
- if (!elem) return;
160
- const rect = elem.getBoundingClientRect();
161
- const size = rect.width * rect.height;
162
- if (size > largestSize) {
163
- largestSize = size;
164
- vitals.lcp = {
165
- value: (0, _now.now)(),
166
- size,
167
- elTag: elem.tagName || null,
168
- eid: elem.id || null,
169
- elUrl: getElementURL(elem)
170
- };
177
+ // an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
178
+ populateVitalMinimums();
179
+ if (resizeObs) {
180
+ try {
181
+ const elem = node.nodeType === 1 ? node : node.parentElement;
182
+ if (elem && !observedElements.has(elem)) {
183
+ observedElements.add(elem);
184
+
185
+ // For media elements, wait for content to load before observing size
186
+ if (elem.tagName === 'IMG') {
187
+ if (elem.complete) {
188
+ resizeObs.observe(elem);
189
+ } else {
190
+ elem.addEventListener('load', () => {
191
+ resizeObs.observe(elem);
192
+ }, {
193
+ once: true
194
+ });
195
+ }
196
+ } else if (elem.tagName === 'VIDEO') {
197
+ // For video, wait for first frame (HAVE_CURRENT_DATA = 2)
198
+ if (elem.readyState >= 2) {
199
+ resizeObs.observe(elem);
200
+ } else {
201
+ elem.addEventListener('loadeddata', () => {
202
+ resizeObs.observe(elem);
203
+ }, {
204
+ once: true
205
+ });
206
+ }
207
+ } else {
208
+ // For other elements, observe immediately
209
+ resizeObs.observe(elem);
210
+ }
211
+ }
212
+ } catch (e) {
213
+ // Element may not be observable
171
214
  }
172
- } catch (e) {
173
- // Element may be detached from DOM
174
215
  }
175
216
  });
217
+ if (resizeObs) observers.push(resizeObs);
176
218
  observers.push(lcpObs);
177
219
 
178
220
  // Track CLS - cumulative layout shift
179
- // Initialize CLS to 0 if browser supports it AND the MFE container exists in the DOM
180
- let clsValue = 0;
181
- const clsObs = observePerformance(observers, {
221
+ // Initialize CLS to 0 if browser supports it
222
+ observePerformance(observers, {
182
223
  type: 'layout-shift',
183
224
  buffered: true
184
225
  }, entry => {
185
- if (entry.hadRecentInput || !vitals.cls) return;
226
+ if (entry.hadRecentInput) return;
186
227
  (entry.sources || []).some(source => {
187
228
  if (isInMFE(source.node, id)) {
188
- clsValue += entry.value;
189
- vitals.cls ??= {
190
- value: 0,
191
- largestShiftValue: null,
192
- largestShiftTime: null,
193
- largestShiftTarget: null,
194
- loadState: null
195
- };
196
- vitals.cls.value = clsValue;
197
-
198
- // Track largest shift
199
- if (entry.value > (vitals.cls.largestShiftValue || 0)) {
200
- vitals.cls.largestShiftValue = entry.value;
201
- vitals.cls.largestShiftTime = entry.startTime;
202
- vitals.cls.largestShiftTarget = getElementSelector(source.node);
203
- vitals.cls.loadState = _runtime.globalScope.document?.readyState || null;
204
- }
229
+ // 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
+ populateVitalMinimums();
231
+ vitals.cls.value += entry.value;
205
232
  return true;
206
233
  }
207
234
  return false;
208
235
  });
209
236
  });
210
- if (clsObs) {
211
- try {
212
- const mfeContainer = _runtime.globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"));
213
- if (mfeContainer) {
214
- vitals.cls ??= {
215
- value: 0,
216
- largestShiftValue: null,
217
- largestShiftTime: null,
218
- largestShiftTarget: null,
219
- loadState: null
220
- };
221
- }
222
- } catch (e) {
223
- // querySelector may fail, leave CLS as null
224
- }
225
- }
226
237
 
227
238
  // Track INP - interaction to next paint
228
239
  observePerformance(observers, {
229
240
  type: 'event',
230
241
  buffered: true,
231
- durationThreshold: 16
242
+ durationThreshold: 40
232
243
  }, entry => {
233
- if (!entry.interactionId || !entry.target || !isInMFE(entry.target, id)) return;
234
- if (vitals.inp === null || entry.duration > vitals.inp.value) {
235
- vitals.inp = {
236
- value: entry.duration,
237
- interactionTarget: getElementSelector(entry.target),
238
- interactionTime: entry.startTime,
239
- interactionType: entry.name,
240
- inputDelay: entry.processingStart ? entry.processingStart - entry.startTime : null,
241
- processingDuration: entry.processingStart && entry.processingEnd ? entry.processingEnd - entry.processingStart : null,
242
- presentationDelay: entry.processingEnd && entry.duration ? entry.duration - (entry.processingEnd - entry.startTime) : null,
243
- nextPaintTime: entry.startTime + entry.duration,
244
- loadState: _runtime.globalScope.document?.readyState || null
245
- };
244
+ if (!entry.interactionId || !isInMFE(entry.target, id)) return;
245
+ if (vitals.inp.value === null || entry.duration > vitals.inp.value) {
246
+ // 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
+ populateVitalMinimums();
248
+ vitals.inp.value = entry.duration;
246
249
  }
247
250
  });
251
+ const interactionEvents = ['pointerdown', 'keydown'];
252
+ const disconnectInteractionListeners = () => {
253
+ interactionEvents.forEach(type => {
254
+ _runtime.globalScope.removeEventListener(type, handleInteraction, {
255
+ passive: true
256
+ });
257
+ });
258
+ };
248
259
 
249
260
  // Disconnect all observers
250
261
  vitals.disconnect = () => {
@@ -256,6 +267,7 @@ function trackMFEVitals(id) {
256
267
  // Observer may already be disconnected
257
268
  }
258
269
  });
270
+ disconnectInteractionListeners();
259
271
  };
260
272
 
261
273
  // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
@@ -263,19 +275,24 @@ function trackMFEVitals(id) {
263
275
  const disconnectLCP = () => {
264
276
  try {
265
277
  lcpObs?.disconnect();
278
+ resizeObs?.disconnect();
266
279
  } catch (e) {
267
280
  // Observer may already be disconnected
268
281
  }
269
282
  };
270
- ['click', 'keydown', 'scroll'].forEach(type => {
271
- _runtime.globalScope.addEventListener(type, disconnectLCP, {
272
- once: true,
283
+ const handleInteraction = event => {
284
+ if (!isInMFE(event?.target, id)) return;
285
+ disconnectLCP();
286
+ disconnectInteractionListeners();
287
+ };
288
+ interactionEvents.forEach(type => {
289
+ _runtime.globalScope.addEventListener(type, handleInteraction, {
273
290
  passive: true
274
291
  });
275
292
  })
276
293
 
277
294
  // Disconnect all observers on visibility change or page unload
278
- ;
295
+ ;
279
296
  ['visibilitychange', 'pagehide'].forEach(type => {
280
297
  _runtime.globalScope.addEventListener(type, vitals.disconnect, {
281
298
  once: true,
@@ -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);
90
+ const vitals = (0, _mfeVitals.trackMFEVitals)(target.id, timings);
91
91
  const attrs = {};
92
92
 
93
93
  // Only define attributes getter if it doesn't already exist
@@ -232,13 +232,14 @@ function register(agentRef, target) {
232
232
  // fetch time and script time together
233
233
  timeToRegister: timings.registeredAt,
234
234
  // timestamp when register() was called
235
- 'nr.vitals.fcp': vitals.fcp || null,
235
+ // leave room to extend these with more data keys as needed
236
+ 'nr.vitals.fcp.value': vitals.fcp?.value ?? null,
236
237
  // FCP vital object with value and metadata
237
- 'nr.vitals.lcp': vitals.lcp || null,
238
+ 'nr.vitals.lcp.value': vitals.lcp?.value ?? null,
238
239
  // LCP vital object with value and metadata
239
- 'nr.vitals.cls': vitals.cls || null,
240
+ 'nr.vitals.cls.value': vitals.cls?.value ?? null,
240
241
  // CLS vital object with value and metadata
241
- 'nr.vitals.inp': vitals.inp || null // INP vital object with value and metadata
242
+ 'nr.vitals.inp.value': vitals.inp?.value ?? null // INP vital object with value and metadata
242
243
  };
243
244
  api.recordCustomEvent('MicroFrontEndTiming', eventData);
244
245
  }
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.317.0-rc.2";
14
+ export const VERSION = "1.317.0-rc.4";
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.317.0-rc.2";
14
+ export const VERSION = "1.317.0-rc.4";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -5,54 +5,11 @@
5
5
 
6
6
  import { globalScope, isBrowserScope } from '../constants/runtime';
7
7
  import { now } from '../timing/now';
8
- import { cleanURL } from '../url/clean-url';
9
8
 
10
9
  /**
11
10
  * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
12
11
  */
13
12
 
14
- /**
15
- * Generate a simple CSS selector for an element
16
- * @param {Element} elem - The element to generate selector for
17
- * @returns {string|null} CSS selector or null if element is invalid
18
- */
19
- const getElementSelector = elem => {
20
- try {
21
- if (!elem || !elem.tagName) return null;
22
- let selector = elem.tagName.toLowerCase();
23
- if (elem.id) selector += "#".concat(elem.id);else if (elem.className && typeof elem.className === 'string') {
24
- const classes = elem.className.trim().split(/\s+/).slice(0, 2).join('.');
25
- if (classes) selector += ".".concat(classes);
26
- }
27
- return selector;
28
- } catch (e) {
29
- return null;
30
- }
31
- };
32
-
33
- /**
34
- * Extract URL from an element (for images, videos, etc.)
35
- * @param {Element} elem - The element to extract URL from
36
- * @returns {string|null} Cleaned URL or null
37
- */
38
- const getElementURL = elem => {
39
- try {
40
- if (!elem) return null;
41
-
42
- // For images and videos
43
- if (elem.src) return cleanURL(elem.src);
44
-
45
- // For elements with background images
46
- const bgImage = globalScope.getComputedStyle?.(elem)?.backgroundImage;
47
- if (bgImage && bgImage !== 'none') {
48
- const match = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
49
- if (match?.[1]) return cleanURL(match[1]);
50
- }
51
- return null;
52
- } catch (e) {
53
- return null;
54
- }
55
- };
56
13
  const isObservable = node => {
57
14
  try {
58
15
  return node?.textContent?.trim() || ['img', 'video', 'canvas', 'svg'].includes(node?.nodeName?.toLowerCase());
@@ -68,6 +25,7 @@ const isObservable = node => {
68
25
  * @returns {boolean}
69
26
  */
70
27
  const isInMFE = (node, id) => {
28
+ if (!node || !id) return false;
71
29
  try {
72
30
  let curr = node.nodeType === 1 ? node : node.parentElement;
73
31
  while (curr?.tagName) {
@@ -81,18 +39,38 @@ const isInMFE = (node, id) => {
81
39
  /**
82
40
  * Create mutation observer for MFE nodes
83
41
  * @param {string} id - MFE ID to track
84
- * @param {Function} onMatch - Callback when matching node is added
42
+ * @param {Function} onMatch - Callback when matching node is *added*
85
43
  * @returns {MutationObserver}
86
44
  */
87
45
  const observeMutations = (id, onMatch) => {
46
+ // Try to find existing MFE root
47
+ const mfeRoot = globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"));
48
+ let observingRoot = !!mfeRoot;
88
49
  const obs = new globalScope.MutationObserver(mutations => {
89
- mutations.forEach(m => m.addedNodes.forEach(node => {
90
- if (isObservable(node) && isInMFE(node, id)) {
91
- onMatch(node, obs);
92
- }
93
- }));
50
+ mutations.forEach(m => {
51
+ m.addedNodes.forEach(node => {
52
+ // Check if this is the MFE root being added
53
+ const elem = node.nodeType === 1 ? node : null;
54
+ if (elem?.dataset?.nrMfeId === id) {
55
+ // Found the root! Lets switch to observing just this subtree for performance reasons
56
+ obs.disconnect();
57
+ obs.observe(elem, {
58
+ childList: true,
59
+ subtree: true
60
+ });
61
+ observingRoot = true;
62
+ }
63
+
64
+ // 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))) {
66
+ onMatch(node, obs);
67
+ }
68
+ });
69
+ });
94
70
  });
95
- obs.observe(globalScope.document, {
71
+
72
+ // If root exists, observe just that subtree; otherwise observe document to catch when root is added
73
+ obs.observe(mfeRoot || globalScope.document, {
96
74
  childList: true,
97
75
  subtree: true
98
76
  });
@@ -124,122 +102,155 @@ const observePerformance = (observers, config, onEntry) => {
124
102
  * @param {string} id - The MFE ID to track
125
103
  * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
126
104
  */
127
- export function trackMFEVitals(id) {
105
+ export function trackMFEVitals(id, timings) {
106
+ let fcpObservedAt = null;
107
+ let lcpObservedAt = null;
108
+ const getTimeRelativeToScriptStart = capturedAt => {
109
+ if (capturedAt === null) return null;
110
+ return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0);
111
+ };
128
112
  const vitals = {
129
- fcp: null,
130
- lcp: null,
131
- cls: null,
132
- inp: null,
113
+ fcp: {
114
+ get value() {
115
+ return getTimeRelativeToScriptStart(fcpObservedAt);
116
+ }
117
+ },
118
+ lcp: {
119
+ get value() {
120
+ return getTimeRelativeToScriptStart(lcpObservedAt);
121
+ }
122
+ },
123
+ cls: {
124
+ value: null
125
+ },
126
+ inp: {
127
+ value: null
128
+ },
133
129
  disconnect: () => {}
134
130
  };
135
131
  if (!id || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals;
136
132
  const observers = [];
133
+ const populateVitalMinimums = () => {
134
+ fcpObservedAt ??= now();
135
+ lcpObservedAt ??= now();
136
+ vitals.cls.value ??= 0;
137
+ };
138
+
139
+ // 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();
137
141
 
138
142
  // Track FCP - first contentful paint
139
- observeMutations(id, (node, obs) => {
140
- if (vitals.fcp === null) {
141
- vitals.fcp = {
142
- value: now(),
143
- loadState: globalScope.document?.readyState || null
144
- };
145
- obs.disconnect();
146
- }
143
+ observeMutations(id, (_, obs) => {
144
+ // An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
145
+ populateVitalMinimums();
146
+ obs.disconnect();
147
147
  });
148
148
 
149
149
  // Track LCP - largest contentful paint
150
150
  let largestSize = 0;
151
+ let resizeObs = null;
152
+ const observedElements = new Set();
153
+ try {
154
+ resizeObs = new globalScope.ResizeObserver(entries => {
155
+ entries.forEach(entry => {
156
+ try {
157
+ const size = entry.contentRect.width * entry.contentRect.height;
158
+ if (size > largestSize) {
159
+ largestSize = size;
160
+ lcpObservedAt = now();
161
+ }
162
+ resizeObs.unobserve(entry.target);
163
+ } catch (e) {
164
+ // Element may be detached from DOM
165
+ }
166
+ });
167
+ });
168
+ } catch (e) {
169
+ // ResizeObserver not supported
170
+ }
151
171
  const lcpObs = observeMutations(id, node => {
152
- try {
153
- const elem = node.nodeType === 1 ? node : node.parentElement;
154
- if (!elem) return;
155
- const rect = elem.getBoundingClientRect();
156
- const size = rect.width * rect.height;
157
- if (size > largestSize) {
158
- largestSize = size;
159
- vitals.lcp = {
160
- value: now(),
161
- size,
162
- elTag: elem.tagName || null,
163
- eid: elem.id || null,
164
- elUrl: getElementURL(elem)
165
- };
172
+ // an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
173
+ populateVitalMinimums();
174
+ if (resizeObs) {
175
+ try {
176
+ const elem = node.nodeType === 1 ? node : node.parentElement;
177
+ if (elem && !observedElements.has(elem)) {
178
+ observedElements.add(elem);
179
+
180
+ // For media elements, wait for content to load before observing size
181
+ if (elem.tagName === 'IMG') {
182
+ if (elem.complete) {
183
+ resizeObs.observe(elem);
184
+ } else {
185
+ elem.addEventListener('load', () => {
186
+ resizeObs.observe(elem);
187
+ }, {
188
+ once: true
189
+ });
190
+ }
191
+ } else if (elem.tagName === 'VIDEO') {
192
+ // For video, wait for first frame (HAVE_CURRENT_DATA = 2)
193
+ if (elem.readyState >= 2) {
194
+ resizeObs.observe(elem);
195
+ } else {
196
+ elem.addEventListener('loadeddata', () => {
197
+ resizeObs.observe(elem);
198
+ }, {
199
+ once: true
200
+ });
201
+ }
202
+ } else {
203
+ // For other elements, observe immediately
204
+ resizeObs.observe(elem);
205
+ }
206
+ }
207
+ } catch (e) {
208
+ // Element may not be observable
166
209
  }
167
- } catch (e) {
168
- // Element may be detached from DOM
169
210
  }
170
211
  });
212
+ if (resizeObs) observers.push(resizeObs);
171
213
  observers.push(lcpObs);
172
214
 
173
215
  // Track CLS - cumulative layout shift
174
- // Initialize CLS to 0 if browser supports it AND the MFE container exists in the DOM
175
- let clsValue = 0;
176
- const clsObs = observePerformance(observers, {
216
+ // Initialize CLS to 0 if browser supports it
217
+ observePerformance(observers, {
177
218
  type: 'layout-shift',
178
219
  buffered: true
179
220
  }, entry => {
180
- if (entry.hadRecentInput || !vitals.cls) return;
221
+ if (entry.hadRecentInput) return;
181
222
  (entry.sources || []).some(source => {
182
223
  if (isInMFE(source.node, id)) {
183
- clsValue += entry.value;
184
- vitals.cls ??= {
185
- value: 0,
186
- largestShiftValue: null,
187
- largestShiftTime: null,
188
- largestShiftTarget: null,
189
- loadState: null
190
- };
191
- vitals.cls.value = clsValue;
192
-
193
- // Track largest shift
194
- if (entry.value > (vitals.cls.largestShiftValue || 0)) {
195
- vitals.cls.largestShiftValue = entry.value;
196
- vitals.cls.largestShiftTime = entry.startTime;
197
- vitals.cls.largestShiftTarget = getElementSelector(source.node);
198
- vitals.cls.loadState = globalScope.document?.readyState || null;
199
- }
224
+ // 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
+ populateVitalMinimums();
226
+ vitals.cls.value += entry.value;
200
227
  return true;
201
228
  }
202
229
  return false;
203
230
  });
204
231
  });
205
- if (clsObs) {
206
- try {
207
- const mfeContainer = globalScope.document?.querySelector("[data-nr-mfe-id=\"".concat(id, "\"]"));
208
- if (mfeContainer) {
209
- vitals.cls ??= {
210
- value: 0,
211
- largestShiftValue: null,
212
- largestShiftTime: null,
213
- largestShiftTarget: null,
214
- loadState: null
215
- };
216
- }
217
- } catch (e) {
218
- // querySelector may fail, leave CLS as null
219
- }
220
- }
221
232
 
222
233
  // Track INP - interaction to next paint
223
234
  observePerformance(observers, {
224
235
  type: 'event',
225
236
  buffered: true,
226
- durationThreshold: 16
237
+ durationThreshold: 40
227
238
  }, entry => {
228
- if (!entry.interactionId || !entry.target || !isInMFE(entry.target, id)) return;
229
- if (vitals.inp === null || entry.duration > vitals.inp.value) {
230
- vitals.inp = {
231
- value: entry.duration,
232
- interactionTarget: getElementSelector(entry.target),
233
- interactionTime: entry.startTime,
234
- interactionType: entry.name,
235
- inputDelay: entry.processingStart ? entry.processingStart - entry.startTime : null,
236
- processingDuration: entry.processingStart && entry.processingEnd ? entry.processingEnd - entry.processingStart : null,
237
- presentationDelay: entry.processingEnd && entry.duration ? entry.duration - (entry.processingEnd - entry.startTime) : null,
238
- nextPaintTime: entry.startTime + entry.duration,
239
- loadState: globalScope.document?.readyState || null
240
- };
239
+ if (!entry.interactionId || !isInMFE(entry.target, id)) return;
240
+ if (vitals.inp.value === null || entry.duration > vitals.inp.value) {
241
+ // 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
+ populateVitalMinimums();
243
+ vitals.inp.value = entry.duration;
241
244
  }
242
245
  });
246
+ const interactionEvents = ['pointerdown', 'keydown'];
247
+ const disconnectInteractionListeners = () => {
248
+ interactionEvents.forEach(type => {
249
+ globalScope.removeEventListener(type, handleInteraction, {
250
+ passive: true
251
+ });
252
+ });
253
+ };
243
254
 
244
255
  // Disconnect all observers
245
256
  vitals.disconnect = () => {
@@ -251,6 +262,7 @@ export function trackMFEVitals(id) {
251
262
  // Observer may already be disconnected
252
263
  }
253
264
  });
265
+ disconnectInteractionListeners();
254
266
  };
255
267
 
256
268
  // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
@@ -258,19 +270,24 @@ export function trackMFEVitals(id) {
258
270
  const disconnectLCP = () => {
259
271
  try {
260
272
  lcpObs?.disconnect();
273
+ resizeObs?.disconnect();
261
274
  } catch (e) {
262
275
  // Observer may already be disconnected
263
276
  }
264
277
  };
265
- ['click', 'keydown', 'scroll'].forEach(type => {
266
- globalScope.addEventListener(type, disconnectLCP, {
267
- once: true,
278
+ const handleInteraction = event => {
279
+ if (!isInMFE(event?.target, id)) return;
280
+ disconnectLCP();
281
+ disconnectInteractionListeners();
282
+ };
283
+ interactionEvents.forEach(type => {
284
+ globalScope.addEventListener(type, handleInteraction, {
268
285
  passive: true
269
286
  });
270
287
  })
271
288
 
272
289
  // Disconnect all observers on visibility change or page unload
273
- ;
290
+ ;
274
291
  ['visibilitychange', 'pagehide'].forEach(type => {
275
292
  globalScope.addEventListener(type, vitals.disconnect, {
276
293
  once: true,
@@ -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);
83
+ const vitals = trackMFEVitals(target.id, timings);
84
84
  const attrs = {};
85
85
 
86
86
  // Only define attributes getter if it doesn't already exist
@@ -225,13 +225,14 @@ function register(agentRef, target) {
225
225
  // fetch time and script time together
226
226
  timeToRegister: timings.registeredAt,
227
227
  // timestamp when register() was called
228
- 'nr.vitals.fcp': vitals.fcp || null,
228
+ // leave room to extend these with more data keys as needed
229
+ 'nr.vitals.fcp.value': vitals.fcp?.value ?? null,
229
230
  // FCP vital object with value and metadata
230
- 'nr.vitals.lcp': vitals.lcp || null,
231
+ 'nr.vitals.lcp.value': vitals.lcp?.value ?? null,
231
232
  // LCP vital object with value and metadata
232
- 'nr.vitals.cls': vitals.cls || null,
233
+ 'nr.vitals.cls.value': vitals.cls?.value ?? null,
233
234
  // CLS vital object with value and metadata
234
- 'nr.vitals.inp': vitals.inp || null // INP vital object with value and metadata
235
+ 'nr.vitals.inp.value': vitals.inp?.value ?? null // INP vital object with value and metadata
235
236
  };
236
237
  api.recordCustomEvent('MicroFrontEndTiming', eventData);
237
238
  }
@@ -3,7 +3,7 @@
3
3
  * @param {string} id - The MFE ID to track
4
4
  * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
5
5
  */
6
- export function trackMFEVitals(id: string): {
6
+ export function trackMFEVitals(id: string, timings: any): {
7
7
  fcp: object | null;
8
8
  lcp: object | null;
9
9
  cls: object | null;
@@ -1 +1 @@
1
- {"version":3,"file":"mfe-vitals.d.ts","sourceRoot":"","sources":["../../../../src/common/v2/mfe-vitals.js"],"names":[],"mappings":"AA0HA;;;;GAIG;AACH,mCAHW,MAAM,GACJ;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,CAsI1G"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/browser-agent",
3
- "version": "1.317.0-rc.2",
3
+ "version": "1.317.0-rc.4",
4
4
  "private": false,
5
5
  "author": "New Relic Browser Agent Team <browser-agent@newrelic.com>",
6
6
  "description": "New Relic Browser Agent",
@@ -5,58 +5,11 @@
5
5
 
6
6
  import { globalScope, isBrowserScope } from '../constants/runtime'
7
7
  import { now } from '../timing/now'
8
- import { cleanURL } from '../url/clean-url'
9
8
 
10
9
  /**
11
10
  * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
12
11
  */
13
12
 
14
- /**
15
- * Generate a simple CSS selector for an element
16
- * @param {Element} elem - The element to generate selector for
17
- * @returns {string|null} CSS selector or null if element is invalid
18
- */
19
- const getElementSelector = (elem) => {
20
- try {
21
- if (!elem || !elem.tagName) return null
22
-
23
- let selector = elem.tagName.toLowerCase()
24
- if (elem.id) selector += `#${elem.id}`
25
- else if (elem.className && typeof elem.className === 'string') {
26
- const classes = elem.className.trim().split(/\s+/).slice(0, 2).join('.')
27
- if (classes) selector += `.${classes}`
28
- }
29
- return selector
30
- } catch (e) {
31
- return null
32
- }
33
- }
34
-
35
- /**
36
- * Extract URL from an element (for images, videos, etc.)
37
- * @param {Element} elem - The element to extract URL from
38
- * @returns {string|null} Cleaned URL or null
39
- */
40
- const getElementURL = (elem) => {
41
- try {
42
- if (!elem) return null
43
-
44
- // For images and videos
45
- if (elem.src) return cleanURL(elem.src)
46
-
47
- // For elements with background images
48
- const bgImage = globalScope.getComputedStyle?.(elem)?.backgroundImage
49
- if (bgImage && bgImage !== 'none') {
50
- const match = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/)
51
- if (match?.[1]) return cleanURL(match[1])
52
- }
53
-
54
- return null
55
- } catch (e) {
56
- return null
57
- }
58
- }
59
-
60
13
  const isObservable = (node) => {
61
14
  try {
62
15
  return node?.textContent?.trim() || ['img', 'video', 'canvas', 'svg'].includes(node?.nodeName?.toLowerCase())
@@ -72,6 +25,7 @@ const isObservable = (node) => {
72
25
  * @returns {boolean}
73
26
  */
74
27
  const isInMFE = (node, id) => {
28
+ if (!node || !id) return false
75
29
  try {
76
30
  let curr = node.nodeType === 1 ? node : node.parentElement
77
31
  while (curr?.tagName) {
@@ -85,18 +39,37 @@ const isInMFE = (node, id) => {
85
39
  /**
86
40
  * Create mutation observer for MFE nodes
87
41
  * @param {string} id - MFE ID to track
88
- * @param {Function} onMatch - Callback when matching node is added
42
+ * @param {Function} onMatch - Callback when matching node is *added*
89
43
  * @returns {MutationObserver}
90
44
  */
91
45
  const observeMutations = (id, onMatch) => {
46
+ // Try to find existing MFE root
47
+ const mfeRoot = globalScope.document?.querySelector(`[data-nr-mfe-id="${id}"]`)
48
+ let observingRoot = !!mfeRoot
49
+
92
50
  const obs = new globalScope.MutationObserver(mutations => {
93
- mutations.forEach(m => m.addedNodes.forEach(node => {
94
- if (isObservable(node) && isInMFE(node, id)) {
95
- onMatch(node, obs)
96
- }
97
- }))
51
+ mutations.forEach(m => {
52
+ m.addedNodes.forEach(node => {
53
+ // Check if this is the MFE root being added
54
+ const elem = node.nodeType === 1 ? node : null
55
+ if (elem?.dataset?.nrMfeId === id) {
56
+ // Found the root! Lets switch to observing just this subtree for performance reasons
57
+ obs.disconnect()
58
+ obs.observe(elem, { childList: true, subtree: true })
59
+ observingRoot = true
60
+ }
61
+
62
+ // 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))) {
64
+ onMatch(node, obs)
65
+ }
66
+ })
67
+ })
98
68
  })
99
- obs.observe(globalScope.document, { childList: true, subtree: true })
69
+
70
+ // If root exists, observe just that subtree; otherwise observe document to catch when root is added
71
+ obs.observe(mfeRoot || globalScope.document, { childList: true, subtree: true })
72
+
100
73
  return obs
101
74
  }
102
75
 
@@ -125,12 +98,28 @@ const observePerformance = (observers, config, onEntry) => {
125
98
  * @param {string} id - The MFE ID to track
126
99
  * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
127
100
  */
128
- export function trackMFEVitals (id) {
101
+ export function trackMFEVitals (id, timings) {
102
+ let fcpObservedAt = null
103
+ let lcpObservedAt = null
104
+
105
+ const getTimeRelativeToScriptStart = (capturedAt) => {
106
+ if (capturedAt === null) return null
107
+ return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0)
108
+ }
109
+
129
110
  const vitals = {
130
- fcp: null,
131
- lcp: null,
132
- cls: null,
133
- inp: null,
111
+ fcp: {
112
+ get value () { return getTimeRelativeToScriptStart(fcpObservedAt) }
113
+ },
114
+ lcp: {
115
+ get value () { return getTimeRelativeToScriptStart(lcpObservedAt) }
116
+ },
117
+ cls: {
118
+ value: null
119
+ },
120
+ inp: {
121
+ value: null
122
+ },
134
123
  disconnect: () => {}
135
124
  }
136
125
 
@@ -138,94 +127,120 @@ export function trackMFEVitals (id) {
138
127
 
139
128
  const observers = []
140
129
 
130
+ const populateVitalMinimums = () => {
131
+ fcpObservedAt ??= now()
132
+ lcpObservedAt ??= now()
133
+ vitals.cls.value ??= 0
134
+ }
135
+
136
+ // 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()
138
+
141
139
  // Track FCP - first contentful paint
142
- observeMutations(id, (node, obs) => {
143
- if (vitals.fcp === null) {
144
- vitals.fcp = {
145
- value: now(),
146
- loadState: globalScope.document?.readyState || null
147
- }
148
- obs.disconnect()
149
- }
140
+ observeMutations(id, (_, obs) => {
141
+ // An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
142
+ populateVitalMinimums()
143
+ obs.disconnect()
150
144
  })
151
145
 
152
146
  // Track LCP - largest contentful paint
153
147
  let largestSize = 0
148
+ let resizeObs = null
149
+ const observedElements = new Set()
150
+
151
+ try {
152
+ resizeObs = new globalScope.ResizeObserver((entries) => {
153
+ entries.forEach((entry) => {
154
+ try {
155
+ const size = entry.contentRect.width * entry.contentRect.height
156
+ if (size > largestSize) {
157
+ largestSize = size
158
+ lcpObservedAt = now()
159
+ }
160
+ resizeObs.unobserve(entry.target)
161
+ } catch (e) {
162
+ // Element may be detached from DOM
163
+ }
164
+ })
165
+ })
166
+ } catch (e) {
167
+ // ResizeObserver not supported
168
+ }
169
+
154
170
  const lcpObs = observeMutations(id, (node) => {
155
- try {
156
- const elem = node.nodeType === 1 ? node : node.parentElement
157
- if (!elem) return
158
- const rect = elem.getBoundingClientRect()
159
- const size = rect.width * rect.height
160
- if (size > largestSize) {
161
- largestSize = size
162
- vitals.lcp = {
163
- value: now(),
164
- size,
165
- elTag: elem.tagName || null,
166
- eid: elem.id || null,
167
- elUrl: getElementURL(elem)
171
+ // an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
172
+ populateVitalMinimums()
173
+
174
+ if (resizeObs) {
175
+ try {
176
+ const elem = node.nodeType === 1 ? node : node.parentElement
177
+ if (elem && !observedElements.has(elem)) {
178
+ observedElements.add(elem)
179
+
180
+ // For media elements, wait for content to load before observing size
181
+ if (elem.tagName === 'IMG') {
182
+ if (elem.complete) {
183
+ resizeObs.observe(elem)
184
+ } else {
185
+ elem.addEventListener('load', () => {
186
+ resizeObs.observe(elem)
187
+ }, { once: true })
188
+ }
189
+ } else if (elem.tagName === 'VIDEO') {
190
+ // For video, wait for first frame (HAVE_CURRENT_DATA = 2)
191
+ if (elem.readyState >= 2) {
192
+ resizeObs.observe(elem)
193
+ } else {
194
+ elem.addEventListener('loadeddata', () => {
195
+ resizeObs.observe(elem)
196
+ }, { once: true })
197
+ }
198
+ } else {
199
+ // For other elements, observe immediately
200
+ resizeObs.observe(elem)
201
+ }
168
202
  }
203
+ } catch (e) {
204
+ // Element may not be observable
169
205
  }
170
- } catch (e) {
171
- // Element may be detached from DOM
172
206
  }
173
207
  })
208
+
209
+ if (resizeObs) observers.push(resizeObs)
174
210
  observers.push(lcpObs)
175
211
 
176
212
  // Track CLS - cumulative layout shift
177
- // Initialize CLS to 0 if browser supports it AND the MFE container exists in the DOM
178
- let clsValue = 0
179
- const clsObs = observePerformance(observers, { type: 'layout-shift', buffered: true }, (entry) => {
180
- if (entry.hadRecentInput || !vitals.cls) return
181
- (entry.sources || []).some(source => {
213
+ // Initialize CLS to 0 if browser supports it
214
+ observePerformance(observers, { type: 'layout-shift', buffered: true }, (entry) => {
215
+ if (entry.hadRecentInput) return
216
+ ;(entry.sources || []).some(source => {
182
217
  if (isInMFE(source.node, id)) {
183
- clsValue += entry.value
184
- vitals.cls ??= { value: 0, largestShiftValue: null, largestShiftTime: null, largestShiftTarget: null, loadState: null }
185
- vitals.cls.value = clsValue
186
-
187
- // Track largest shift
188
- if (entry.value > (vitals.cls.largestShiftValue || 0)) {
189
- vitals.cls.largestShiftValue = entry.value
190
- vitals.cls.largestShiftTime = entry.startTime
191
- vitals.cls.largestShiftTarget = getElementSelector(source.node)
192
- vitals.cls.loadState = globalScope.document?.readyState || null
193
- }
218
+ // 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
+ populateVitalMinimums()
220
+ vitals.cls.value += entry.value
194
221
  return true
195
222
  }
196
223
  return false
197
224
  })
198
225
  })
199
226
 
200
- if (clsObs) {
201
- try {
202
- const mfeContainer = globalScope.document?.querySelector(`[data-nr-mfe-id="${id}"]`)
203
- if (mfeContainer) {
204
- vitals.cls ??= { value: 0, largestShiftValue: null, largestShiftTime: null, largestShiftTarget: null, loadState: null }
205
- }
206
- } catch (e) {
207
- // querySelector may fail, leave CLS as null
208
- }
209
- }
210
-
211
227
  // Track INP - interaction to next paint
212
- observePerformance(observers, { type: 'event', buffered: true, durationThreshold: 16 }, (entry) => {
213
- if (!entry.interactionId || !entry.target || !isInMFE(entry.target, id)) return
214
- if (vitals.inp === null || entry.duration > vitals.inp.value) {
215
- vitals.inp = {
216
- value: entry.duration,
217
- interactionTarget: getElementSelector(entry.target),
218
- interactionTime: entry.startTime,
219
- interactionType: entry.name,
220
- inputDelay: entry.processingStart ? entry.processingStart - entry.startTime : null,
221
- processingDuration: (entry.processingStart && entry.processingEnd) ? entry.processingEnd - entry.processingStart : null,
222
- presentationDelay: (entry.processingEnd && entry.duration) ? entry.duration - (entry.processingEnd - entry.startTime) : null,
223
- nextPaintTime: entry.startTime + entry.duration,
224
- loadState: globalScope.document?.readyState || null
225
- }
228
+ 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) {
231
+ // 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
+ populateVitalMinimums()
233
+ vitals.inp.value = entry.duration
226
234
  }
227
235
  })
228
236
 
237
+ const interactionEvents = ['pointerdown', 'keydown']
238
+ const disconnectInteractionListeners = () => {
239
+ interactionEvents.forEach(type => {
240
+ globalScope.removeEventListener(type, handleInteraction, { passive: true })
241
+ })
242
+ }
243
+
229
244
  // Disconnect all observers
230
245
  vitals.disconnect = () => {
231
246
  // Disconnect all observers
@@ -236,6 +251,7 @@ export function trackMFEVitals (id) {
236
251
  // Observer may already be disconnected
237
252
  }
238
253
  })
254
+ disconnectInteractionListeners()
239
255
  }
240
256
 
241
257
  // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
@@ -243,12 +259,21 @@ export function trackMFEVitals (id) {
243
259
  const disconnectLCP = () => {
244
260
  try {
245
261
  lcpObs?.disconnect()
262
+ resizeObs?.disconnect()
246
263
  } catch (e) {
247
264
  // Observer may already be disconnected
248
265
  }
249
266
  }
250
- ;['click', 'keydown', 'scroll'].forEach(type => {
251
- globalScope.addEventListener(type, disconnectLCP, { once: true, passive: true })
267
+
268
+ const handleInteraction = (event) => {
269
+ if (!isInMFE(event?.target, id)) return
270
+
271
+ disconnectLCP()
272
+ disconnectInteractionListeners()
273
+ }
274
+
275
+ interactionEvents.forEach(type => {
276
+ globalScope.addEventListener(type, handleInteraction, { passive: true })
252
277
  })
253
278
 
254
279
  // Disconnect all observers on visibility change or page unload
@@ -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)
88
+ const vitals = trackMFEVitals(target.id, timings)
89
89
 
90
90
  const attrs = {}
91
91
 
@@ -202,10 +202,11 @@ function register (agentRef, target) {
202
202
  timeToFetch, // fetchStart to fetchEnd
203
203
  timeToLoad: timeToFetch + timeToExecute, // fetch time and script time together
204
204
  timeToRegister: timings.registeredAt, // timestamp when register() was called
205
- 'nr.vitals.fcp': vitals.fcp || null, // FCP vital object with value and metadata
206
- 'nr.vitals.lcp': vitals.lcp || null, // LCP vital object with value and metadata
207
- 'nr.vitals.cls': vitals.cls || null, // CLS vital object with value and metadata
208
- 'nr.vitals.inp': vitals.inp || null // INP vital object with value and metadata
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
209
210
  }
210
211
 
211
212
  api.recordCustomEvent('MicroFrontEndTiming', eventData)