@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.
- package/dist/cjs/common/constants/env.cdn.js +1 -1
- package/dist/cjs/common/constants/env.npm.js +1 -1
- package/dist/cjs/common/v2/mfe-vitals.js +151 -134
- package/dist/cjs/loaders/api/register.js +6 -5
- package/dist/esm/common/constants/env.cdn.js +1 -1
- package/dist/esm/common/constants/env.npm.js +1 -1
- package/dist/esm/common/v2/mfe-vitals.js +151 -134
- package/dist/esm/loaders/api/register.js +6 -5
- package/dist/types/common/v2/mfe-vitals.d.ts +1 -1
- package/dist/types/common/v2/mfe-vitals.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/common/v2/mfe-vitals.js +150 -125
- package/src/loaders/api/register.js +6 -5
|
@@ -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.
|
|
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.
|
|
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 =>
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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, (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
180
|
-
|
|
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
|
|
226
|
+
if (entry.hadRecentInput) return;
|
|
186
227
|
(entry.sources || []).some(source => {
|
|
187
228
|
if (isInMFE(source.node, id)) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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:
|
|
242
|
+
durationThreshold: 40
|
|
232
243
|
}, entry => {
|
|
233
|
-
if (!entry.interactionId || !
|
|
234
|
-
if (vitals.inp === null || entry.duration > vitals.inp.value) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
238
|
+
'nr.vitals.lcp.value': vitals.lcp?.value ?? null,
|
|
238
239
|
// LCP vital object with value and metadata
|
|
239
|
-
'nr.vitals.cls': vitals.cls
|
|
240
|
+
'nr.vitals.cls.value': vitals.cls?.value ?? null,
|
|
240
241
|
// CLS vital object with value and metadata
|
|
241
|
-
'nr.vitals.inp': vitals.inp
|
|
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
|
}
|
|
@@ -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 =>
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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, (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
175
|
-
|
|
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
|
|
221
|
+
if (entry.hadRecentInput) return;
|
|
181
222
|
(entry.sources || []).some(source => {
|
|
182
223
|
if (isInMFE(source.node, id)) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
237
|
+
durationThreshold: 40
|
|
227
238
|
}, entry => {
|
|
228
|
-
if (!entry.interactionId || !
|
|
229
|
-
if (vitals.inp === null || entry.duration > vitals.inp.value) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
231
|
+
'nr.vitals.lcp.value': vitals.lcp?.value ?? null,
|
|
231
232
|
// LCP vital object with value and metadata
|
|
232
|
-
'nr.vitals.cls': vitals.cls
|
|
233
|
+
'nr.vitals.cls.value': vitals.cls?.value ?? null,
|
|
233
234
|
// CLS vital object with value and metadata
|
|
234
|
-
'nr.vitals.inp': vitals.inp
|
|
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":"
|
|
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
|
@@ -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 =>
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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, (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
size
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
vitals.cls.value
|
|
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:
|
|
213
|
-
if (!entry.interactionId || !
|
|
214
|
-
if (vitals.inp === null || entry.duration > vitals.inp.value) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
206
|
-
'nr.vitals.
|
|
207
|
-
'nr.vitals.
|
|
208
|
-
'nr.vitals.
|
|
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)
|