@newrelic/browser-agent 1.318.0 → 1.319.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cjs/common/constants/env.cdn.js +1 -1
  3. package/dist/cjs/common/constants/env.npm.js +1 -1
  4. package/dist/cjs/common/payloads/payloads.js +22 -4
  5. package/dist/cjs/common/serialize/bel-serializer.js +3 -3
  6. package/dist/cjs/common/util/console.js +83 -3
  7. package/dist/cjs/common/v2/mfe-vitals.js +59 -24
  8. package/dist/cjs/common/v2/utils.js +1 -1
  9. package/dist/cjs/common/wrap/wrap-websocket.js +5 -3
  10. package/dist/cjs/features/ajax/aggregate/index.js +14 -8
  11. package/dist/cjs/features/generic_events/aggregate/index.js +14 -12
  12. package/dist/cjs/features/generic_events/instrument/index.js +3 -3
  13. package/dist/cjs/loaders/api/register-api-types.js +7 -0
  14. package/dist/cjs/loaders/api/register.js +13 -5
  15. package/dist/esm/common/constants/env.cdn.js +1 -1
  16. package/dist/esm/common/constants/env.npm.js +1 -1
  17. package/dist/esm/common/payloads/payloads.js +22 -4
  18. package/dist/esm/common/serialize/bel-serializer.js +3 -3
  19. package/dist/esm/common/util/console.js +83 -3
  20. package/dist/esm/common/v2/mfe-vitals.js +59 -24
  21. package/dist/esm/common/v2/utils.js +1 -1
  22. package/dist/esm/common/wrap/wrap-websocket.js +5 -3
  23. package/dist/esm/features/ajax/aggregate/index.js +14 -8
  24. package/dist/esm/features/generic_events/aggregate/index.js +14 -12
  25. package/dist/esm/features/generic_events/instrument/index.js +3 -3
  26. package/dist/esm/loaders/api/register-api-types.js +8 -0
  27. package/dist/esm/loaders/api/register.js +13 -5
  28. package/dist/types/common/payloads/payloads.d.ts +15 -4
  29. package/dist/types/common/payloads/payloads.d.ts.map +1 -1
  30. package/dist/types/common/serialize/bel-serializer.d.ts +1 -1
  31. package/dist/types/common/serialize/bel-serializer.d.ts.map +1 -1
  32. package/dist/types/common/util/console.d.ts +82 -4
  33. package/dist/types/common/util/console.d.ts.map +1 -1
  34. package/dist/types/common/v2/mfe-vitals.d.ts +7 -10
  35. package/dist/types/common/v2/mfe-vitals.d.ts.map +1 -1
  36. package/dist/types/common/wrap/wrap-websocket.d.ts +1 -1
  37. package/dist/types/common/wrap/wrap-websocket.d.ts.map +1 -1
  38. package/dist/types/features/ajax/aggregate/index.d.ts.map +1 -1
  39. package/dist/types/features/generic_events/aggregate/index.d.ts.map +1 -1
  40. package/dist/types/loaders/api/register-api-types.d.ts +18 -0
  41. package/package.json +4 -2
  42. package/src/common/payloads/payloads.js +22 -4
  43. package/src/common/serialize/bel-serializer.js +3 -3
  44. package/src/common/util/console.js +83 -3
  45. package/src/common/v2/mfe-vitals.js +57 -24
  46. package/src/common/v2/utils.js +1 -1
  47. package/src/common/wrap/wrap-websocket.js +5 -3
  48. package/src/features/ajax/aggregate/index.js +14 -8
  49. package/src/features/generic_events/aggregate/index.js +14 -12
  50. package/src/features/generic_events/instrument/index.js +3 -3
  51. package/src/loaders/api/register-api-types.js +8 -0
  52. package/src/loaders/api/register.js +5 -5
@@ -1 +1 @@
1
- {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../../../src/common/util/console.js"],"names":[],"mappings":"AASA;;;;;GAKG;AACH,4CAHW,GAAC,QAgBX"}
1
+ {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../../../src/common/util/console.js"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AAEH,2BALW,MAAM,cACN,GAAC,QAiBX"}
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * Tracks all Core Web Vitals for a specific MFE.
3
- * @param {string} id - The MFE ID to track
4
- * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
3
+ * @param {RegisterAPITarget} target - The MFE target to track vitals for
4
+ * @param {RegisterAPITimings} timings - The timings object to use for relative time calculations
5
+ * @returns {RegisterAPIVitals} An object containing the vitals values and a disconnect method to stop tracking
5
6
  */
6
- export function trackMFEVitals(id: string, timings: any): {
7
- fcp: object | null;
8
- lcp: object | null;
9
- cls: object | null;
10
- inp: object | null;
11
- disconnect: Function;
12
- };
13
- export type RegisterAPITimings = any;
7
+ export function trackMFEVitals(target: RegisterAPITarget, timings: RegisterAPITimings): RegisterAPIVitals;
8
+ export type RegisterAPITimings = import("../../loaders/api/register-api-types").RegisterAPITimings;
9
+ export type RegisterAPITarget = import("../../loaders/api/register-api-types").RegisterAPITarget;
10
+ export type RegisterAPIVitals = import("../../loaders/api/register-api-types").RegisterAPIVitals;
14
11
  //# sourceMappingURL=mfe-vitals.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mfe-vitals.d.ts","sourceRoot":"","sources":["../../../../src/common/v2/mfe-vitals.js"],"names":[],"mappings":"AA+FA;;;;GAIG;AACH,mCAHW,MAAM,iBACJ;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,GAAC,IAAI,CAAC;IAAC,UAAU,WAAU;CAAC,CA0L1G"}
1
+ {"version":3,"file":"mfe-vitals.d.ts","sourceRoot":"","sources":["../../../../src/common/v2/mfe-vitals.js"],"names":[],"mappings":"AAoHA;;;;;GAKG;AACH,uCAJW,iBAAiB,WACjB,kBAAkB,GAChB,iBAAiB,CAqM7B;iCApTY,OAAO,sCAAsC,EAAE,kBAAkB;gCACjE,OAAO,sCAAsC,EAAE,iBAAiB;gCAChE,OAAO,sCAAsC,EAAE,iBAAiB"}
@@ -1,2 +1,2 @@
1
- export function wrapWebSocket(sharedEE: any): any;
1
+ export function wrapWebSocket(sharedEE: any, agentRef: any): any;
2
2
  //# sourceMappingURL=wrap-websocket.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"wrap-websocket.d.ts","sourceRoot":"","sources":["../../../../src/common/wrap/wrap-websocket.js"],"names":[],"mappings":"AAeA,kDA2IC"}
1
+ {"version":3,"file":"wrap-websocket.d.ts","sourceRoot":"","sources":["../../../../src/common/wrap/wrap-websocket.js"],"names":[],"mappings":"AAgBA,iEA4IC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/features/ajax/aggregate/index.js"],"names":[],"mappings":"AAoBA;IACE,2BAAiC;IAEjC,2BA6BC;IAvBC,uBAA4D;IAyB9D,0GAqFC;IAED,+CAOC;IAED,iDAoEC;CACF;8BAjN6B,4BAA4B;2BAK/B,gCAAgC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/features/ajax/aggregate/index.js"],"names":[],"mappings":"AAoBA;IACE,2BAAiC;IAEjC,2BA6BC;IAvBC,uBAA4D;IAyB9D,0GA2FC;IAED,+CAOC;IAED,iDAoEC;CACF;8BAvN6B,4BAA4B;2BAK/B,gCAAgC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/features/generic_events/aggregate/index.js"],"names":[],"mappings":"AAmBA;IACE,2BAAiC;IAGjC,2BA6QC;IA3QC,gCAAkG;IAIlG,uBAA0C;IA0Q5C;;;;;;;;;;;;OAYG;IACH,eAJW,MAAM,YAAC,WACP,MAAM,YAAC,QAkCjB;IAED,6CAEC;IAED;;;MAEC;;CAsBF;8BAzW6B,4BAA4B;2BAI/B,gCAAgC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/features/generic_events/aggregate/index.js"],"names":[],"mappings":"AAmBA;IACE,2BAAiC;IAGjC,2BA+QC;IA7QC,gCAAkG;IAIlG,uBAA0C;IA4Q5C;;;;;;;;;;;;OAYG;IACH,eAJW,MAAM,YAAC,WACP,MAAM,YAAC,QAkCjB;IAED,6CAEC;IAED;;;MAEC;;CAsBF;8BA3W6B,4BAA4B;2BAI/B,gCAAgC"}
@@ -146,4 +146,22 @@ export type RegisterAPITimings = {
146
146
  */
147
147
  type: string;
148
148
  };
149
+ export type RegisterAPIVitals = {
150
+ /**
151
+ * - The first contentful paint timing for the registered entity.
152
+ */
153
+ fcp?: number | undefined;
154
+ /**
155
+ * - The largest contentful paint timing for the registered entity.
156
+ */
157
+ lcp?: number | undefined;
158
+ /**
159
+ * - The cumulative layout shift score for the registered entity.
160
+ */
161
+ cls?: number | undefined;
162
+ /**
163
+ * - The interaction to next paint timing for the registered entity.
164
+ */
165
+ inp?: number | undefined;
166
+ };
149
167
  //# sourceMappingURL=register-api-types.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/browser-agent",
3
- "version": "1.318.0",
3
+ "version": "1.319.0-rc.0",
4
4
  "private": false,
5
5
  "author": "New Relic Browser Agent Team <browser-agent@newrelic.com>",
6
6
  "description": "New Relic Browser Agent",
@@ -176,6 +176,8 @@
176
176
  "start": "npm-run-all --parallel cdn:watch test-server",
177
177
  "lint": "eslint -c .eslintrc.js --ext .js,.cjs,.mjs .",
178
178
  "lint:fix": "npm run lint -- --fix",
179
+ "generate:warning-codes": "node tools/warning-codes/generate-jsdoc.js",
180
+ "check:warning-codes": "node tools/warning-codes/check-usage.js",
179
181
  "test": " NODE_OPTIONS=--max-old-space-size=8192 jest",
180
182
  "test:unit": "jest --selectProjects unit",
181
183
  "test:component": "jest --selectProjects component",
@@ -284,4 +286,4 @@
284
286
  "README.md",
285
287
  "CHANGELOG.md"
286
288
  ]
287
- }
289
+ }
@@ -8,13 +8,31 @@ import { CAPTURE_PAYLOAD_SETTINGS } from '../../features/ajax/constants'
8
8
 
9
9
  /**
10
10
  * Determines whether payload data should be captured based on the capture mode setting,
11
- * HTTP status code, and GraphQL error status.
11
+ * HTTP status code, and GraphQL error status. Will never capture agent's own payloads.
12
12
  * @param {string} captureMode - The capture mode setting ('none', 'all', or 'failures')
13
- * @param {number} statusCode - The HTTP status code
14
- * @param {boolean} hasGQLErrors - Whether the response contains GraphQL errors
13
+ * @param {Object} event - An object representing the AJAX event
14
+ * @param {number} event.statusCode - The HTTP status code
15
+ * @param {boolean} event.hasGQLErrors - Whether the response contains GraphQL errors
16
+ * @param {string} event.payloadHost - The host of the AJAX payload (includes port)
17
+ * @param {string} event.payloadHostname - The hostname of the AJAX payload
18
+ * @param {string} [event.payloadPathname=''] - The pathname of the AJAX payload
19
+ * @param {string[]} [beacons=[]] - Array of beacon hostnames to avoid capturing
15
20
  * @returns {boolean} True if payload should be captured
16
21
  */
17
- export function canCapturePayload (captureMode, statusCode, hasGQLErrors) {
22
+ export function canCapturePayload (captureMode, {
23
+ statusCode,
24
+ hasGQLErrors,
25
+ payloadHost,
26
+ payloadHostname,
27
+ payloadPathname = ''
28
+ }, beacons = []) {
29
+ if (payloadHostname) {
30
+ const payloadUrl = payloadHostname + payloadPathname
31
+ const payloadUrlWithPort = payloadHost + payloadPathname
32
+ if (beacons.some((b) => {
33
+ return b === payloadUrl || payloadUrl.startsWith(b + '/') || b === payloadUrlWithPort || payloadUrlWithPort.startsWith(b + '/')
34
+ })) return false
35
+ }
18
36
  if (captureMode === CAPTURE_PAYLOAD_SETTINGS.ALL) return true
19
37
  if (!captureMode || captureMode === CAPTURE_PAYLOAD_SETTINGS.NONE) return false
20
38
 
@@ -27,9 +27,9 @@ export function getAddStringContext (obfuscator, truncator) {
27
27
 
28
28
  return addString
29
29
 
30
- function addString (str) {
30
+ function addString (str, obfuscate = true) {
31
31
  if (typeof str === 'undefined' || str === '') return ''
32
- str = obfuscator?.obfuscateString(String(str)) ?? String(str)
32
+ str = obfuscate ? (obfuscator?.obfuscateString(String(str)) ?? String(str)) : String(str)
33
33
  str = truncator?.(str) ?? str
34
34
  if (hasOwnProp.call(stringTable, str)) {
35
35
  return numeric(stringTable[str], true)
@@ -48,7 +48,7 @@ export function addCustomAttributes (attrs, addString) {
48
48
  var type = 5
49
49
  var serializedValue
50
50
  // add key to string table first
51
- key = addString(key)
51
+ key = addString(key, false)
52
52
 
53
53
  switch (typeof val) {
54
54
  case 'object':
@@ -7,12 +7,92 @@ import { dispatchGlobalEvent } from '../dispatch/global-event'
7
7
 
8
8
  /* eslint no-console: ["error", { allow: ["debug"] }] */
9
9
 
10
+ /* GENERATED-WARNING-CODES:START -- run `npm run generate:warning-codes` after editing docs/warning-codes.md, do not hand-edit this block */
10
11
  /**
11
- * A helper method to warn to the console with New Relic: decoration
12
- * @param {string} message The primary message to warn
13
- * @param {*} [secondary] Secondary data to include, usually an error or object
12
+ * A helper method to warn to the console with New Relic: decoration.
13
+ *
14
+ * `code` corresponds to an entry in
15
+ * {@link https://github.com/newrelic/newrelic-browser-agent/blob/main/docs/warning-codes.md warning-codes.md}:
16
+ *
17
+ * | Code | Message |
18
+ * |------|---------|
19
+ * | 1 | An error occurred while setting a property of a Configurable |
20
+ * | 2 | An error occurred while setting a Configurable |
21
+ * | 3 | Setting a Configurable requires an object as input |
22
+ * | 4 | Setting a Configurable requires a model to set its initial properties |
23
+ * | 5 | An invalid session_replay.mask_selector was provided. \* will be used. |
24
+ * | 6 | An invalid session_replay.block_selector was provided and will not be used |
25
+ * | 7 | An invalid session_replay.mask_input_option was provided and will not be used |
26
+ * | 8 | Shared context requires an object as input |
27
+ * | 9 | An error occurred while setting SharedContext |
28
+ * | 10 | Failed to read from storage API |
29
+ * | 11 | Failed to write to the storage API |
30
+ * | 12 | An obfuscation replacement rule was detected missing a "regex" value. |
31
+ * | 13 | An obfuscation replacement rule contains a "regex" value with an invalid type (must be a string or RegExp) |
32
+ * | 14 | An obfuscation replacement rule contains a "replacement" value with an invalid type (must be a string) |
33
+ * | 15 | An error occurred while intercepting XHR |
34
+ * | 16 | Could not cast log message to string |
35
+ * | 17 | Could not calculate New Relic server time. Agent shutting down. |
36
+ * | 18 | RUM call failed. Agent shutting down. |
37
+ * | 19 | SPA scheduler is not initialized. Saved interaction is not sent! |
38
+ * | 20 | A problem occurred when starting up session manager. This page will not start or extend any session. |
39
+ * | 21 | Failed to initialize the agent. Could not determine the runtime environment. |
40
+ * | 22 | Failed to initialize all enabled instrument classes (agent aborted) - |
41
+ * | 23 | An unexpected issue occurred |
42
+ * | 24 | Something prevented the agent from instrumenting. |
43
+ * | 25 | Something prevented the agent from being downloaded. |
44
+ * | 26 | Failed to initialize instrument classes. |
45
+ * | 27 | Downloading runtime APIs failed... |
46
+ * | 28 | The Browser Agent is attempting to send a very large payload. This is usually tied to large amounts of custom attributes. Please check your configurations. |
47
+ * | 29 | Failed to wrap logger: invalid argument(s) |
48
+ * | 30 | Invalid log level |
49
+ * | 31 | Ignored log: Log is larger than maximum payload size |
50
+ * | 32 | Ignored log: Invalid message |
51
+ * | 33 | Session Replay Aborted |
52
+ * | 34 | Downloading and initializing a feature failed... |
53
+ * | 35 | Call to agent api failed. The API is not currently initialized. |
54
+ * | 36 | A feature is enabled but one or more dependent features have not been initialized. This may cause unintended consequences or missing data... |
55
+ * | 37 | Invalid feature name supplied. |
56
+ * | 38 | Call to api was made before agent fully initialized. |
57
+ * | 39 | Failed to execute setCustomAttribute. Name must be a string type. |
58
+ * | 40 | Failed to execute setCustomAttribute. Non-null value must be a string, number or boolean type. |
59
+ * | 41 | Failed to execute setUserId. Non-null value must be a string type. |
60
+ * | 42 | Failed to execute setApplicationVersion. Expected <String \| null> |
61
+ * | 43 | Agent not configured properly. |
62
+ * | 44 | Invalid object passed to generic event aggregate. Missing "eventType". |
63
+ * | 45 | An internal agent process failed to execute. |
64
+ * | 46 | A reserved eventType was provided to recordCustomEvent(...) -- The event was not recorded. |
65
+ * | 47 | We tried to access a stylesheet's contents but failed due to browser security. For best results, ensure that cross-domain CSS assets are decorated with "crossorigin='anonymous'" attribution or are otherwise publicly accessible. |
66
+ * | 48 | Supplied an invalid API target. Must be an <Object> that contains valid (string) id and name properties. |
67
+ * | 49 | Supplied API target is missing an entityGuid. Some APIs may not behave correctly without a valid entityGuid (ex. logs). |
68
+ * | 50 | Failed to connect. Cannot allow registered API. |
69
+ * | 51 | Container agent is not available to register with. Can not connect |
70
+ * | 52 | Unexpected problem encountered. There should be at least one app for harvest! |
71
+ * | 53 | Did not receive a valid entityGuid from connection response |
72
+ * | 54 | An experimental feature is being used. Support can not be offered for issues |
73
+ * | 55 | Register API has been disabled on the container agent |
74
+ * | 56 | Could not find a matching entity to store data |
75
+ * | 57 | Failed to execute measure. Arguments must have valid types. |
76
+ * | 58 | Failed to execute measure. Resulting duration must be non-negative. |
77
+ * | 59 | Session replay harvested before a session trace payload could be sent. This could be problematic for replays that rely on a trace |
78
+ * | 60 | Session trace aborted |
79
+ * | 61 | Timestamps must be non-negative and end time cannot be before start time. |
80
+ * | 62 | Timestamp must be a unix timestamp greater than the page origin time |
81
+ * | 63 | A single event was larger than the maximum allowed payload size |
82
+ * | 64 | Required globals have been mutated before being accessed by the browser agent. This can cause issues and should be avoided. |
83
+ * | 65 | Consent API argument must be boolean or undefined |
84
+ * | 66 | A new agent session has started |
85
+ * | 67 | The "spa" feature has been deprecated and disabled. Please use/import "soft_navigations" instead for tracking of BrowserInteraction data. |
86
+ * | 68 | API has been deregistered and can no longer be used. Call "register" API again with credentials to start over. |
87
+ * | 69 | More than one Browser agent is running on the page |
88
+ * | 70 | A session replay payload failed to send and is being retried. Recording is paused during the retry period, and will resume when a successful harvest is made. Some replay activity may be missed during retry phases. |
89
+ * | 71 | An invalid feature mode was detected and set to "off". |
90
+ *
91
+ * @param {number} code The warning code to emit, which will be used to link to the warning code documentation
92
+ * @param {*} [secondary] Secondary data to include, usually an extra message, error or object
14
93
  * @returns
15
94
  */
95
+ /* GENERATED-WARNING-CODES:END */
16
96
  export function warn (code, secondary) {
17
97
  if (typeof console.debug !== 'function') return
18
98
  console.debug(`New Relic Warning: https://github.com/newrelic/newrelic-browser-agent/blob/main/docs/warning-codes.md#${code}`, secondary)
@@ -7,7 +7,9 @@ import { globalScope, isBrowserScope } from '../constants/runtime'
7
7
  import { now } from '../timing/now'
8
8
 
9
9
  /**
10
- * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
10
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITimings} RegisterAPITimings
11
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPITarget} RegisterAPITarget
12
+ * @typedef {import('../../loaders/api/register-api-types').RegisterAPIVitals} RegisterAPIVitals
11
13
  */
12
14
 
13
15
  const isObservable = (node) => {
@@ -18,18 +20,34 @@ const isObservable = (node) => {
18
20
  }
19
21
  }
20
22
 
23
+ const isMatch = (dataset, target) => dataset?.nrMfeId === target.id && (!dataset?.nrMfeObserved || dataset?.nrMfeObserved === target.instance)
24
+
25
+ const escapeSelectorValue = (value) => {
26
+ try {
27
+ return globalScope.CSS.escape(value)
28
+ } catch (e) {
29
+ // give up
30
+ return value
31
+ }
32
+ }
33
+
21
34
  /**
22
35
  * Check if node is within a specific MFE
23
36
  * @param {Node} node - DOM node to check
24
37
  * @param {string} id - MFE ID to match
25
38
  * @returns {boolean}
26
39
  */
27
- const isInMFE = (node, id) => {
28
- if (!node || !id) return false
40
+ const isInMFE = (node, target = {}) => {
41
+ const id = target.id
42
+ const instance = target.instance
43
+ if (!node || !id || !instance) return false
29
44
  try {
30
45
  let curr = node.nodeType === 1 ? node : node.parentElement
31
46
  while (curr?.tagName) {
32
- if (curr.dataset?.nrMfeId === id) return true
47
+ if (isMatch(curr.dataset, target)) {
48
+ curr.dataset.nrMfeObserved = instance // mark that this MFE has been observed for vitals
49
+ return true
50
+ }
33
51
  curr = curr.parentNode
34
52
  }
35
53
  } catch (e) {}
@@ -42,25 +60,28 @@ const isInMFE = (node, id) => {
42
60
  * @param {Function} onMatch - Callback when matching node is *added*
43
61
  * @returns {MutationObserver}
44
62
  */
45
- const observeMutations = (id, onMatch) => {
63
+ const observeMutations = (target, onMatch) => {
46
64
  // Try to find existing MFE root
47
- const mfeRoot = globalScope.document?.querySelector(`[data-nr-mfe-id="${id}"]`)
65
+ const potentialMatches = globalScope.document?.querySelectorAll(`[data-nr-mfe-id="${escapeSelectorValue(target.id)}"]`)
66
+ const mfeRoot = (Array.from(potentialMatches) || []).find(x => isMatch(x.dataset, target))
48
67
  let observingRoot = !!mfeRoot
68
+ if (observingRoot) mfeRoot.dataset.nrMfeObserved ??= target.instance // mark that this MFE has been observed for vitals
49
69
 
50
70
  const obs = new globalScope.MutationObserver(mutations => {
51
71
  mutations.forEach(m => {
52
72
  m.addedNodes.forEach(node => {
53
73
  // Check if this is the MFE root being added
54
74
  const elem = node.nodeType === 1 ? node : null
55
- if (elem?.dataset?.nrMfeId === id) {
75
+ if (!observingRoot && isMatch(elem?.dataset, target)) {
56
76
  // Found the root! Lets switch to observing just this subtree for performance reasons
57
77
  obs.disconnect()
58
78
  obs.observe(elem, { childList: true, subtree: true })
59
79
  observingRoot = true
80
+ elem.dataset.nrMfeObserved = target.instance // mark that this MFE has been observed for vitals
60
81
  }
61
82
 
62
83
  // Only check isInMFE if we're observing the whole document; skip expensive ancestor walk when observing root
63
- if (isObservable(node) && (observingRoot || isInMFE(node, id))) {
84
+ if (isObservable(node) && (observingRoot || isInMFE(node, target))) {
64
85
  onMatch(node, obs)
65
86
  }
66
87
  })
@@ -95,15 +116,16 @@ const observePerformance = (observers, config, onEntry) => {
95
116
 
96
117
  /**
97
118
  * Tracks all Core Web Vitals for a specific MFE.
98
- * @param {string} id - The MFE ID to track
99
- * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
119
+ * @param {RegisterAPITarget} target - The MFE target to track vitals for
120
+ * @param {RegisterAPITimings} timings - The timings object to use for relative time calculations
121
+ * @returns {RegisterAPIVitals} An object containing the vitals values and a disconnect method to stop tracking
100
122
  */
101
- export function trackMFEVitals (id, timings) {
102
- let fcpObservedAt = null
103
- let lcpObservedAt = null
123
+ export function trackMFEVitals (target, timings) {
124
+ let fcpObservedAt
125
+ let lcpObservedAt
104
126
 
105
127
  const getTimeRelativeToScriptStart = (capturedAt) => {
106
- if (capturedAt === null) return null
128
+ if (capturedAt == null) return capturedAt
107
129
  return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0)
108
130
  }
109
131
 
@@ -115,18 +137,24 @@ export function trackMFEVitals (id, timings) {
115
137
  get value () { return getTimeRelativeToScriptStart(lcpObservedAt) }
116
138
  },
117
139
  cls: {
118
- value: null
140
+ value: undefined
119
141
  },
120
142
  inp: {
121
- value: null
143
+ value: undefined
122
144
  },
123
145
  disconnect: () => {}
124
146
  }
125
147
 
126
- if (!id || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals
148
+ if (!target || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals
127
149
 
128
150
  const observers = []
129
151
 
152
+ // If FCP hasn't been observed within 10 seconds, give up and shut down all observers.
153
+ // Once FCP is observed, the other vitals are left to record until their natural lifespan ends.
154
+ setTimeout(() => {
155
+ if (!fcpObservedAt) vitals.disconnect()
156
+ }, 10000)
157
+
130
158
  const populateVitalMinimums = () => {
131
159
  fcpObservedAt ??= now()
132
160
  lcpObservedAt ??= now()
@@ -134,14 +162,16 @@ export function trackMFEVitals (id, timings) {
134
162
  }
135
163
 
136
164
  // if the MFE has already rendered something on the page before we could set up listeners, just populate vital minimums immediately
137
- if (globalScope.document?.querySelector(`[data-nr-mfe-id="${id}"]`)) populateVitalMinimums()
165
+ const existingRoots = globalScope.document?.querySelectorAll(`[data-nr-mfe-id="${escapeSelectorValue(target.id)}"]`)
166
+ if (Array.from(existingRoots || []).some(x => isMatch(x.dataset, target))) populateVitalMinimums()
138
167
 
139
168
  // Track FCP - first contentful paint
140
- observeMutations(id, (_, obs) => {
169
+ const fcpObs = observeMutations(target, (_, obs) => {
141
170
  // An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
142
171
  populateVitalMinimums()
143
172
  obs.disconnect()
144
173
  })
174
+ observers.push(fcpObs)
145
175
 
146
176
  // Track LCP - largest contentful paint
147
177
  let largestSize = 0
@@ -167,7 +197,7 @@ export function trackMFEVitals (id, timings) {
167
197
  // ResizeObserver not supported
168
198
  }
169
199
 
170
- const lcpObs = observeMutations(id, (node) => {
200
+ const lcpObs = observeMutations(target, (node) => {
171
201
  // an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
172
202
  populateVitalMinimums()
173
203
 
@@ -214,7 +244,7 @@ export function trackMFEVitals (id, timings) {
214
244
  observePerformance(observers, { type: 'layout-shift', buffered: true }, (entry) => {
215
245
  if (entry.hadRecentInput) return
216
246
  ;(entry.sources || []).some(source => {
217
- if (isInMFE(source.node, id)) {
247
+ if (isInMFE(source.node, target)) {
218
248
  // an observed "CLS" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
219
249
  populateVitalMinimums()
220
250
  vitals.cls.value += entry.value
@@ -226,8 +256,8 @@ export function trackMFEVitals (id, timings) {
226
256
 
227
257
  // Track INP - interaction to next paint
228
258
  observePerformance(observers, { type: 'event', buffered: true, durationThreshold: 40 }, (entry) => {
229
- if (!entry.interactionId || !isInMFE(entry.target, id)) return
230
- if (vitals.inp.value === null || entry.duration > vitals.inp.value) {
259
+ if (!entry.interactionId || !isInMFE(entry.target, target)) return
260
+ if (vitals.inp.value === undefined || entry.duration > vitals.inp.value) {
231
261
  // an observed "INP" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
232
262
  populateVitalMinimums()
233
263
  vitals.inp.value = entry.duration
@@ -252,6 +282,9 @@ export function trackMFEVitals (id, timings) {
252
282
  }
253
283
  })
254
284
  disconnectInteractionListeners()
285
+ ;['visibilitychange', 'pagehide'].forEach(type => {
286
+ globalScope.removeEventListener(type, vitals.disconnect, { once: true, passive: true })
287
+ })
255
288
  }
256
289
 
257
290
  // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
@@ -266,7 +299,7 @@ export function trackMFEVitals (id, timings) {
266
299
  }
267
300
 
268
301
  const handleInteraction = (event) => {
269
- if (!isInMFE(event?.target, id)) return
302
+ if (!isInMFE(event?.target, target)) return
270
303
 
271
304
  disconnectLCP()
272
305
  disconnectInteractionListeners()
@@ -92,7 +92,7 @@ export function shouldDuplicate (target, aggregateInstance) {
92
92
  * @returns {Array} An array of targets found from the stack trace. If no targets are found or allowed, returns an array with undefined.
93
93
  */
94
94
  export function findTargetsFromStackTrace (agentRef) {
95
- if (!agentRef?.init.api.register.enabled) return [undefined]
95
+ if (!agentRef?.init.api.register.enabled || !agentRef?.runtime?.registeredEntities?.length) return [undefined]
96
96
 
97
97
  const targets = []
98
98
  try {
@@ -6,6 +6,7 @@ import { globalScope } from '../constants/runtime'
6
6
  import { generateRandomHexString } from '../ids/unique-id'
7
7
  import { now } from '../timing/now'
8
8
  import { cleanURL } from '../url/clean-url'
9
+ import { findTargetsFromStackTrace } from '../v2/utils'
9
10
  import { gosNREUMOriginals } from '../window/nreum'
10
11
  import { subscribeToPageUnload } from '../window/page-visibility'
11
12
  import { EVENT_TYPES } from '../constants/events'
@@ -13,7 +14,7 @@ import { EVENT_TYPES } from '../constants/events'
13
14
  const wrapped = {}
14
15
  const openWebSockets = new Set() // track all instances to close out metrics on page unload
15
16
 
16
- export function wrapWebSocket (sharedEE) {
17
+ export function wrapWebSocket (sharedEE, agentRef) {
17
18
  const originals = gosNREUMOriginals().o
18
19
  if (!originals.WS) return sharedEE
19
20
 
@@ -33,7 +34,7 @@ export function wrapWebSocket (sharedEE) {
33
34
  ws.nrData.connectedDuration = unloadTime - ws.nrData.openedAt
34
35
  }
35
36
 
36
- wsEE.emit('ws', [ws.nrData], ws)
37
+ wsEE.emit('ws', [ws.nrData, ws.targets], ws)
37
38
  })
38
39
  })
39
40
 
@@ -61,6 +62,7 @@ export function wrapWebSocket (sharedEE) {
61
62
  super(...args)
62
63
  /** @type {WebSocketData} */
63
64
  this.nrData = new WebSocketData(args[0], args[1])
65
+ this.targets = findTargetsFromStackTrace(agentRef)
64
66
 
65
67
  this.addEventListener('open', () => {
66
68
  this.nrData.openedAt = now()
@@ -90,7 +92,7 @@ export function wrapWebSocket (sharedEE) {
90
92
  this.nrData.connectedDuration = this.nrData.closedAt - this.nrData.openedAt
91
93
 
92
94
  openWebSockets.delete(this) // remove from tracking set since it's now closed
93
- wsEE.emit('ws', [this.nrData], this)
95
+ wsEE.emit('ws', [this.nrData, this.targets], this)
94
96
  })
95
97
  }
96
98
 
@@ -102,22 +102,28 @@ export class Aggregate extends AggregateBase {
102
102
  [AJAX_ID]: generateUuid() // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
103
103
  }
104
104
 
105
- event.gql = params.gql = parseGQL({
105
+ event.gql = parseGQL({
106
106
  body: ctx.requestBody,
107
107
  query: ctx.parsedOrigin?.search
108
108
  })
109
- if (event.gql) event.gql.operationHasErrors = params.gql.operationHasErrors = hasGQLErrors(ctx.responseBody)
109
+ if (event.gql) event.gql.operationHasErrors = hasGQLErrors(ctx.responseBody)
110
110
 
111
111
  const capturePayloadSetting = this.agentRef.init.ajax.capture_payloads
112
- const shouldCapturePayload = canCapturePayload(capturePayloadSetting, params.status, event.gql?.operationHasErrors)
112
+ const shouldCapturePayload = canCapturePayload(capturePayloadSetting, {
113
+ statusCode: event.status,
114
+ hasGQLErrors: event.gql?.operationHasErrors,
115
+ payloadHostname: params.hostname,
116
+ payloadHost: params.host,
117
+ payloadPathname: params.pathname
118
+ }, this.agentRef.beacons)
113
119
 
114
120
  if (shouldCapturePayload) {
115
121
  // Store raw data; obfuscation and truncation will happen in the serializer
116
- params.requestQuery = event.requestQuery = parseQueryString(ctx.parsedOrigin?.search)
117
- params.requestHeaders = event.requestHeaders = ctx.requestHeaders
118
- params.responseHeaders = event.responseHeaders = ctx.responseHeaders
119
- if (isLikelyHumanReadable(ctx.requestHeaders, ctx.requestBody)) params.requestBody = event.requestBody = ctx.requestBody
120
- if (isLikelyHumanReadable(ctx.responseHeaders, ctx.responseBody)) params.responseBody = event.responseBody = ctx.responseBody
122
+ event.requestQuery = parseQueryString(ctx.parsedOrigin?.search)
123
+ event.requestHeaders = ctx.requestHeaders
124
+ event.responseHeaders = ctx.responseHeaders
125
+ if (isLikelyHumanReadable(ctx.requestHeaders, ctx.requestBody)) event.requestBody = ctx.requestBody
126
+ if (isLikelyHumanReadable(ctx.responseHeaders, ctx.responseBody)) event.responseBody = ctx.responseBody
121
127
  }
122
128
 
123
129
  if (ctx.dt) {
@@ -252,20 +252,22 @@ export class Aggregate extends AggregateBase {
252
252
  }, this.featureName, this.ee)
253
253
 
254
254
  if (agentRef.init.feature_flags.includes('websockets') || agentRef.init.web_sockets?.enabled) {
255
- registerHandler('ws-complete', (nrData) => {
256
- const event = {
257
- ...nrData,
258
- eventType: EVENT_TYPES.WS,
259
- timestamp: this.#toEpoch(nrData.timestamp),
260
- openedAt: this.#toEpoch(nrData.openedAt),
261
- closedAt: this.#toEpoch(nrData.closedAt)
262
- }
255
+ registerHandler('ws-complete', (nrData, targets = [undefined]) => {
256
+ targets.forEach(target => {
257
+ const event = {
258
+ ...nrData,
259
+ eventType: EVENT_TYPES.WS,
260
+ timestamp: this.#toEpoch(nrData.timestamp),
261
+ openedAt: this.#toEpoch(nrData.openedAt),
262
+ closedAt: this.#toEpoch(nrData.closedAt)
263
+ }
263
264
 
264
- // Report supportability metrics for WebSocket completion
265
- this.reportSupportabilityMetric('WebSocket/Completed/Seen')
266
- this.reportSupportabilityMetric('WebSocket/Completed/Bytes', stringify(event).length)
265
+ // Report supportability metrics for WebSocket completion
266
+ this.reportSupportabilityMetric('WebSocket/Completed/Seen')
267
+ this.reportSupportabilityMetric('WebSocket/Completed/Bytes', stringify(event).length)
267
268
 
268
- this.addEvent(event)
269
+ this.addEvent(event, target)
270
+ })
269
271
  }, this.featureName, this.ee)
270
272
  }
271
273
  if (!agentRef.init.feature_flags.includes('no_spv')) {
@@ -55,9 +55,9 @@ export class Instrument extends InstrumentBase {
55
55
 
56
56
  let historyEE
57
57
  if (websocketsEnabled) { // this can apply outside browser scope such as in worker
58
- const websocketsEE = wrapWebSocket(this.ee)
59
- websocketsEE.on('ws', (nrData) => {
60
- handle('ws-complete', [nrData], undefined, this.featureName, this.ee)
58
+ const websocketsEE = wrapWebSocket(this.ee, agentRef)
59
+ websocketsEE.on('ws', (nrData, targets) => {
60
+ handle('ws-complete', [nrData, targets], undefined, this.featureName, this.ee)
61
61
  })
62
62
  }
63
63
  if (securityPolicyViolationEnabled) {
@@ -53,4 +53,12 @@
53
53
  * @property {string} type - The type of timing associated with the registered entity, 'script' or 'link' if found with the performance resource API, 'fetch' for dynamic imports, 'inline' if found to be associated with the root document URL, or 'unknown' if no associated resource could be found.
54
54
  */
55
55
 
56
+ /**
57
+ * @typedef {Object} RegisterAPIVitals
58
+ * @property {number} [fcp] - The first contentful paint timing for the registered entity.
59
+ * @property {number} [lcp] - The largest contentful paint timing for the registered entity.
60
+ * @property {number} [cls] - The cumulative layout shift score for the registered entity.
61
+ * @property {number} [inp] - The interaction to next paint timing for the registered entity.
62
+ */
63
+
56
64
  export default {}
@@ -85,7 +85,7 @@ function register (agentRef, target) {
85
85
  const timings = findScriptTimings()
86
86
 
87
87
  // Track MFE vitals for this entity
88
- const vitals = trackMFEVitals(target.id, timings)
88
+ const vitals = trackMFEVitals(target, timings)
89
89
 
90
90
  const attrs = {}
91
91
 
@@ -203,10 +203,10 @@ function register (agentRef, target) {
203
203
  timeToLoad: timeToFetch + timeToExecute, // fetch time and script time together
204
204
  timeToRegister: timings.registeredAt, // timestamp when register() was called
205
205
  // leave room to extend these with more data keys as needed
206
- 'nr.vitals.fcp.value': vitals.fcp?.value ?? null, // FCP vital object with value and metadata
207
- 'nr.vitals.lcp.value': vitals.lcp?.value ?? null, // LCP vital object with value and metadata
208
- 'nr.vitals.cls.value': vitals.cls?.value ?? null, // CLS vital object with value and metadata
209
- 'nr.vitals.inp.value': vitals.inp?.value ?? null // INP vital object with value and metadata
206
+ ...(vitals.fcp.value >= 0 && { 'nr.vitals.fcp.value': vitals.fcp.value }), // FCP vital object with value and metadata
207
+ ...(vitals.lcp.value >= 0 && { 'nr.vitals.lcp.value': vitals.lcp.value }), // LCP vital object with value and metadata
208
+ ...(vitals.cls.value >= 0 && { 'nr.vitals.cls.value': vitals.cls.value }), // CLS vital object with value and metadata
209
+ ...(vitals.inp.value >= 0 && { 'nr.vitals.inp.value': vitals.inp.value }) // INP vital object with value and metadata
210
210
  }
211
211
 
212
212
  api.recordCustomEvent('MicroFrontEndTiming', eventData)