@newrelic/browser-agent 1.317.0 → 1.318.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 (78) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cjs/common/config/configurable.js +1 -1
  3. package/dist/cjs/common/config/init-types.js +2 -0
  4. package/dist/cjs/common/config/init.js +3 -0
  5. package/dist/cjs/common/constants/env.cdn.js +1 -1
  6. package/dist/cjs/common/constants/env.npm.js +1 -1
  7. package/dist/cjs/common/harvest/harvester.js +3 -219
  8. package/dist/cjs/common/harvest/send.js +232 -0
  9. package/dist/cjs/common/harvest/types.js +9 -5
  10. package/dist/cjs/common/session/session-entity.js +8 -8
  11. package/dist/cjs/common/v2/mfe-vitals.js +303 -0
  12. package/dist/cjs/features/generic_events/aggregate/index.js +1 -1
  13. package/dist/cjs/features/generic_events/instrument/index.js +1 -1
  14. package/dist/cjs/features/logging/aggregate/index.js +5 -5
  15. package/dist/cjs/features/page_view_event/aggregate/index.js +65 -30
  16. package/dist/cjs/features/session_replay/aggregate/index.js +32 -28
  17. package/dist/cjs/features/session_trace/aggregate/index.js +29 -27
  18. package/dist/cjs/features/session_trace/constants.js +17 -3
  19. package/dist/cjs/features/utils/aggregate-base.js +2 -0
  20. package/dist/cjs/loaders/api/register.js +27 -4
  21. package/dist/esm/common/config/configurable.js +1 -1
  22. package/dist/esm/common/config/init-types.js +2 -0
  23. package/dist/esm/common/config/init.js +3 -0
  24. package/dist/esm/common/constants/env.cdn.js +1 -1
  25. package/dist/esm/common/constants/env.npm.js +1 -1
  26. package/dist/esm/common/harvest/harvester.js +3 -219
  27. package/dist/esm/common/harvest/send.js +226 -0
  28. package/dist/esm/common/harvest/types.js +9 -5
  29. package/dist/esm/common/session/session-entity.js +9 -9
  30. package/dist/esm/common/v2/mfe-vitals.js +298 -0
  31. package/dist/esm/features/generic_events/aggregate/index.js +1 -1
  32. package/dist/esm/features/generic_events/instrument/index.js +1 -1
  33. package/dist/esm/features/logging/aggregate/index.js +5 -5
  34. package/dist/esm/features/page_view_event/aggregate/index.js +64 -29
  35. package/dist/esm/features/session_replay/aggregate/index.js +32 -28
  36. package/dist/esm/features/session_trace/aggregate/index.js +30 -28
  37. package/dist/esm/features/session_trace/constants.js +16 -2
  38. package/dist/esm/features/utils/aggregate-base.js +2 -0
  39. package/dist/esm/loaders/api/register.js +27 -4
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/dist/types/common/config/init-types.d.ts +6 -0
  42. package/dist/types/common/config/init.d.ts.map +1 -1
  43. package/dist/types/common/harvest/harvester.d.ts +0 -17
  44. package/dist/types/common/harvest/harvester.d.ts.map +1 -1
  45. package/dist/types/common/harvest/send.d.ts +9 -0
  46. package/dist/types/common/harvest/send.d.ts.map +1 -0
  47. package/dist/types/common/harvest/types.d.ts +39 -10
  48. package/dist/types/common/harvest/types.d.ts.map +1 -1
  49. package/dist/types/common/session/session-entity.d.ts.map +1 -1
  50. package/dist/types/common/v2/mfe-vitals.d.ts +14 -0
  51. package/dist/types/common/v2/mfe-vitals.d.ts.map +1 -0
  52. package/dist/types/features/page_view_event/aggregate/index.d.ts +1 -0
  53. package/dist/types/features/page_view_event/aggregate/index.d.ts.map +1 -1
  54. package/dist/types/features/session_replay/aggregate/index.d.ts +2 -2
  55. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  56. package/dist/types/features/session_trace/aggregate/index.d.ts +1 -1
  57. package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
  58. package/dist/types/features/session_trace/constants.d.ts +17 -0
  59. package/dist/types/features/utils/aggregate-base.d.ts.map +1 -1
  60. package/dist/types/loaders/api/register.d.ts.map +1 -1
  61. package/package.json +2 -2
  62. package/src/common/config/configurable.js +1 -1
  63. package/src/common/config/init-types.js +2 -0
  64. package/src/common/config/init.js +2 -1
  65. package/src/common/harvest/harvester.js +3 -202
  66. package/src/common/harvest/send.js +209 -0
  67. package/src/common/harvest/types.js +9 -5
  68. package/src/common/session/session-entity.js +8 -9
  69. package/src/common/v2/mfe-vitals.js +285 -0
  70. package/src/features/generic_events/aggregate/index.js +1 -1
  71. package/src/features/generic_events/instrument/index.js +1 -1
  72. package/src/features/logging/aggregate/index.js +5 -5
  73. package/src/features/page_view_event/aggregate/index.js +52 -27
  74. package/src/features/session_replay/aggregate/index.js +30 -27
  75. package/src/features/session_trace/aggregate/index.js +31 -28
  76. package/src/features/session_trace/constants.js +7 -1
  77. package/src/features/utils/aggregate-base.js +2 -0
  78. package/src/loaders/api/register.js +25 -4
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { JSERRORS, RUM, EVENTS, FEATURE_NAMES, BLOBS, LOGS } from '../../loaders/features/features'
6
+ import { VERSION } from '../constants/env'
7
+ import { globalScope, isWorkerScope } from '../constants/runtime'
8
+ import { handle } from '../event-emitter/handle'
9
+ import { eventListenerOpts } from '../event-listener/event-listener-opts'
10
+ import { now } from '../timing/now'
11
+ import { cleanURL } from '../url/clean-url'
12
+ import { obj, param } from '../url/encode'
13
+ import { warn } from '../util/console'
14
+ import { stringify } from '../util/stringify'
15
+ import { xhr as xhrMethod, xhrFetch as fetchMethod } from '../util/submit-data'
16
+ import { dispatchGlobalEvent } from '../dispatch/global-event'
17
+
18
+ /**
19
+ * @typedef {import('./types.js').NetworkSendSpec} NetworkSendSpec
20
+ */
21
+
22
+ const warnings = {}
23
+
24
+ /**
25
+ * Initiate a harvest call.
26
+ * @param {object} agentRef The initialized agent reference
27
+ * @param {NetworkSendSpec} spec Specification for sending data
28
+ * @returns {boolean} True if a network call was made. Note that this does not mean or guarantee that it was successful.
29
+ */
30
+ export function send (agentRef, { endpoint, payload, localOpts = {}, submitMethod, cbFinished, raw, featureName, endpointVersion = 1, harvesterObfuscator }) {
31
+ if (!agentRef.info.errorBeacon) return false
32
+
33
+ let { body, qs } = cleanPayload(payload)
34
+
35
+ if (Object.keys(body).length === 0 && !localOpts.sendEmptyBody) { // if there's no body to send, just run onfinish stuff and return
36
+ if (cbFinished) cbFinished({ sent: false })
37
+ return false
38
+ }
39
+
40
+ const protocol = agentRef.init.ssl === false ? 'http' : 'https'
41
+ const perceivedBeacon = agentRef.init.proxy.beacon || agentRef.info.errorBeacon
42
+ const url = raw
43
+ ? `${protocol}://${perceivedBeacon}/${endpoint}`
44
+ : `${protocol}://${perceivedBeacon}${endpoint !== RUM ? '/' + endpoint : ''}/${endpointVersion}/${agentRef.info.licenseKey}`
45
+ const baseParams = !raw ? baseQueryString(agentRef, qs, endpoint, harvesterObfuscator) : ''
46
+ let payloadParams = obj(qs, agentRef.runtime.maxBytes)
47
+ if (baseParams === '' && payloadParams.startsWith('&')) {
48
+ payloadParams = payloadParams.substring(1)
49
+ }
50
+
51
+ const fullUrl = `${url}?${baseParams}${payloadParams}`
52
+ const gzip = !!qs?.attributes?.includes('gzip')
53
+
54
+ // all gzipped data is already in the correct format and needs no transformation
55
+ // all features going to 'events' endpoint should already be serialized & stringified
56
+ let stringBody = gzip || endpoint === EVENTS ? body : stringify(body)
57
+
58
+ // If body is null, undefined, or an empty object or array after stringifying, send an empty string instead.
59
+ if (!stringBody || stringBody.length === 0 || stringBody === '{}' || stringBody === '[]') stringBody = ''
60
+
61
+ // Warn--once per endpoint--if the agent tries to send large payloads
62
+ if (endpoint !== BLOBS && stringBody.length > 750000 && (warnings[endpoint] = (warnings[endpoint] || 0) + 1) === 1) warn(28, endpoint)
63
+
64
+ const headers = [{ key: 'content-type', value: 'text/plain' }]
65
+
66
+ /* Since workers don't support sendBeacon right now, they can only use XHR method.
67
+ Because they still do permit synch XHR, the idea is that at final harvest time (worker is closing),
68
+ we just make a BLOCKING request--trivial impact--with the remaining data as a temp fill-in for sendBeacon.
69
+ Following the removal of img-element method. */
70
+ let result = submitMethod({ url: fullUrl, body: stringBody, sync: localOpts.isFinalHarvest && isWorkerScope, headers })
71
+
72
+ if (!localOpts.isFinalHarvest && cbFinished) { // final harvests don't hold onto buffer data (shouldRetryOnFail is false), so cleanup isn't needed
73
+ if (submitMethod === xhrMethod) {
74
+ result.addEventListener('loadend', function () {
75
+ // `this` here in block refers to the XHR object in this scope, do not change the anon function to an arrow function
76
+ // status 0 refers to a local error, such as CORS or network failure, or a blocked request by the browser (e.g. adblocker)
77
+ const cbResult = { sent: this.status !== 0, status: this.status, retry: shouldRetry(this.status), fullUrl, xhr: this, responseText: this.responseText }
78
+ cbFinished(cbResult)
79
+
80
+ /** temporary audit of consistency of harvest metadata flags */
81
+ if (!shouldRetry(this.status)) trackHarvestMetadata()
82
+ }, eventListenerOpts(false))
83
+ } else if (submitMethod === fetchMethod) {
84
+ result.then(async function (response) {
85
+ const status = response.status
86
+ const cbResult = { sent: true, status, retry: shouldRetry(status), fullUrl, fetchResponse: response, responseText: await response.text() }
87
+ cbFinished(cbResult)
88
+ /** temporary audit of consistency of harvest metadata flags */
89
+ if (!shouldRetry(status)) trackHarvestMetadata()
90
+ })
91
+ }
92
+
93
+ function trackHarvestMetadata () {
94
+ try {
95
+ if (featureName === FEATURE_NAMES.jserrors && !body?.err) return
96
+
97
+ const hasReplay = baseParams.includes('hr=1')
98
+ const hasTrace = baseParams.includes('ht=1')
99
+ const hasError = qs?.attributes?.includes('hasError=true')
100
+
101
+ handle('harvest-metadata', [{
102
+ [featureName]: {
103
+ ...(hasReplay && { hasReplay }),
104
+ ...(hasTrace && { hasTrace }),
105
+ ...(hasError && { hasError })
106
+ }
107
+ }], undefined, FEATURE_NAMES.metrics, agentRef.ee)
108
+ } catch (err) {
109
+ // do nothing
110
+ }
111
+ }
112
+ }
113
+
114
+ dispatchGlobalEvent({
115
+ drained: !!agentRef.runtime?.activatedFeatures,
116
+ type: 'data',
117
+ name: 'harvest',
118
+ feature: featureName,
119
+ data: {
120
+ endpoint,
121
+ headers,
122
+ payload,
123
+ submitMethod: getSubmitMethodName(),
124
+ raw,
125
+ synchronousXhr: !!(localOpts.isFinalHarvest && isWorkerScope)
126
+ }
127
+ })
128
+
129
+ return true
130
+
131
+ function shouldRetry (status) {
132
+ switch (status) {
133
+ case 408:
134
+ case 429:
135
+ case 500:
136
+ return true
137
+ }
138
+ return (status >= 502 && status <= 504) || (status >= 512 && status <= 530)
139
+ }
140
+
141
+ function getSubmitMethodName () {
142
+ if (submitMethod === xhrMethod) return 'xhr'
143
+ if (submitMethod === fetchMethod) return 'fetch'
144
+ return 'beacon'
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Cleans and returns a payload object containing a body and qs
150
+ * object with key/value pairs. KV pairs where the value is null,
151
+ * undefined, or an empty string are removed to save on transmission
152
+ * size.
153
+ * @param {HarvestPayload} payload Payload to be sent to the endpoint.
154
+ * @returns {HarvestPayload} Cleaned payload payload to be sent to the endpoint.
155
+ */
156
+ function cleanPayload (payload = {}) {
157
+ const clean = (input) => {
158
+ if ((typeof Uint8Array !== 'undefined' && input instanceof Uint8Array) || Array.isArray(input)) return input
159
+ if (typeof input === 'string') return input
160
+ return Object.entries(input || {}).reduce((accumulator, [key, value]) => {
161
+ if ((typeof value === 'number') ||
162
+ (typeof value === 'string' && value.length > 0) ||
163
+ (typeof value === 'object' && Object.keys(value || {}).length > 0)
164
+ ) {
165
+ accumulator[key] = value
166
+ }
167
+ return accumulator
168
+ }, {})
169
+ }
170
+
171
+ return {
172
+ body: clean(payload.body),
173
+ qs: clean(payload.qs)
174
+ }
175
+ }
176
+
177
+ // The stuff that gets sent every time.
178
+ function baseQueryString (agentRef, qs, endpoint, harvesterObfuscator) {
179
+ const cleanedURL = cleanURL('' + globalScope.location)
180
+ const ref = harvesterObfuscator?.obfuscateString(cleanedURL) ?? cleanedURL
181
+ const session = agentRef.runtime.session
182
+ const hr = !!session?.state.sessionReplaySentFirstChunk && session?.state.sessionReplayMode === 1 && endpoint !== JSERRORS
183
+ const ht = !!session?.state.traceHarvestStarted && session?.state.sessionTraceMode === 1 && ![LOGS, BLOBS].includes(endpoint)
184
+
185
+ const qps = [
186
+ 'a=' + agentRef.info.applicationID,
187
+ param('sa', (agentRef.info.sa ? '' + agentRef.info.sa : '')),
188
+ param('v', VERSION),
189
+ transactionNameParam(),
190
+ param('ct', agentRef.runtime.customTransaction),
191
+ '&rst=' + now(),
192
+ '&ck=0', // ck param DEPRECATED - still expected by backend
193
+ '&s=' + (session?.state.value || '0'), // the 0 id encaps all untrackable and default traffic
194
+ param('ref', ref),
195
+ param('ptid', (agentRef.runtime.ptid ? '' + agentRef.runtime.ptid : ''))
196
+ ]
197
+ if (hr) qps.push(param('hr', '1', qs))
198
+ if (ht) qps.push(param('ht', '1', qs))
199
+
200
+ return qps.join('')
201
+
202
+ // Constructs the transaction name param for the beacon URL.
203
+ // Prefers the obfuscated transaction name over the plain text.
204
+ // Falls back to making up a name.
205
+ function transactionNameParam () {
206
+ if (agentRef.info.transactionName) return param('to', agentRef.info.transactionName)
207
+ return param('t', agentRef.info.tNamePlain || 'Unnamed Transaction')
208
+ }
209
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Copyright 2020-2025 New Relic, Inc. All rights reserved.
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
@@ -23,11 +23,15 @@
23
23
  * @typedef {object} NetworkSendSpec
24
24
  * @property {HarvestEndpointIdentifier} endpoint The endpoint to use (jserrors, events, resources etc.)
25
25
  * @property {HarvestPayload} payload Object representing payload.
26
- * @property {object} localOpts Additional options for sending data
27
- * @property {boolean} localOpts.isFinalHarvest Specify whether the call is a final harvest during page unload.
28
- * @property {boolean} localOpts.sendEmptyBody Specify whether the call should be made even if the body is empty. Useful for rum calls.
29
- * @property {boolean} localOpts.forceNoRetry Don't save the buffered data in the case of a need to retry the transmission.
26
+ * @property {object} [localOpts] Additional options for sending data
27
+ * @property {boolean} [localOpts.isFinalHarvest] Specify whether the call is a final harvest during page unload.
28
+ * @property {boolean} [localOpts.sendEmptyBody] Specify whether the call should be made even if the body is empty. Useful for rum calls.
29
+ * @property {boolean} [localOpts.forceNoRetry] Don't save the buffered data in the case of a need to retry the transmission.
30
30
  * @property {import('../util/submit-data.js').NetworkMethods} submitMethod The network method to use {@link ../util/submit-data.js}
31
+ * @property {function(object): void} [cbFinished] Callback invoked with the result once the network request completes. Receives an object with sent, status, retry, fullUrl, responseText, and either xhr or fetchResponse.
32
+ * @property {boolean} [raw] If true, skips standard versioned URL path construction and uses a bare endpoint URL.
33
+ * @property {string} [featureName] The feature name associated with this harvest, used for metadata tracking and the global harvest event.
34
+ * @property {number} [endpointVersion] The endpoint version included in the URL path. Defaults to 1.
31
35
  */
32
36
 
33
37
  export const unused = {}
@@ -7,7 +7,7 @@ import { warn } from '../util/console'
7
7
  import { stringify } from '../util/stringify'
8
8
  import { Timer } from '../timer/timer'
9
9
  import { isBrowserScope } from '../constants/runtime'
10
- import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, MODE, SESSION_EVENTS, SESSION_EVENT_TYPES, SESSION_STORAGE_KEY_PREFIX } from './constants'
10
+ import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS, SESSION_EVENTS, SESSION_EVENT_TYPES, SESSION_STORAGE_KEY_PREFIX } from './constants'
11
11
  import { InteractionTimer } from '../timer/interaction-timer'
12
12
  import { wrapEvents } from '../wrap/wrap-events'
13
13
  import { getModeledObject } from '../config/configurable'
@@ -15,25 +15,25 @@ import { handle } from '../event-emitter/handle'
15
15
  import { SUPPORTABILITY_METRIC_CHANNEL } from '../../features/metrics/constants'
16
16
  import { FEATURE_NAMES } from '../../loaders/features/features'
17
17
  import { windowAddEventListener } from '../event-listener/event-listener-opts'
18
- import { LOGGING_MODE } from '../../features/logging/constants'
19
18
 
20
- // this is what can be stored in local storage (not enforced but probably should be)
19
+ // this is what can be stored in local storage (enforced during reads)
21
20
  // these values should sync between local storage and the parent class props
22
21
  const model = {
23
22
  value: '',
24
23
  inactiveAt: 0,
25
24
  expiresAt: 0,
26
25
  updatedAt: Date.now(),
27
- sessionReplayMode: MODE.OFF,
26
+ sessionReplayMode: null,
28
27
  sessionReplaySentFirstChunk: false,
29
- sessionTraceMode: MODE.OFF,
28
+ sessionTraceMode: null,
30
29
  traceHarvestStarted: false,
31
- loggingMode: LOGGING_MODE.OFF,
32
- logApiMode: LOGGING_MODE.OFF,
30
+ loggingMode: null,
31
+ logApiMode: null,
33
32
  serverTimeDiff: null, // set by TimeKeeper; "undefined" value will not be stringified and stored but "null" will
34
33
  custom: {},
35
34
  numOfResets: 0,
36
- consent: false // set by consent() API call
35
+ consent: false, // set by consent() API call
36
+ cachedRumResponse: null
37
37
  }
38
38
 
39
39
  export class SessionEntity {
@@ -242,7 +242,6 @@ export class SessionEntity {
242
242
  delete this.isNew
243
243
 
244
244
  this.setup({
245
- agentRef: this.agentRef,
246
245
  key: this.key,
247
246
  storage: this.storage,
248
247
  expiresMs: this.expiresMs,
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { globalScope, isBrowserScope } from '../constants/runtime'
7
+ import { now } from '../timing/now'
8
+
9
+ /**
10
+ * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
11
+ */
12
+
13
+ const isObservable = (node) => {
14
+ try {
15
+ return node?.textContent?.trim() || ['img', 'video', 'canvas', 'svg'].includes(node?.nodeName?.toLowerCase())
16
+ } catch (e) {
17
+ return false
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Check if node is within a specific MFE
23
+ * @param {Node} node - DOM node to check
24
+ * @param {string} id - MFE ID to match
25
+ * @returns {boolean}
26
+ */
27
+ const isInMFE = (node, id) => {
28
+ if (!node || !id) return false
29
+ try {
30
+ let curr = node.nodeType === 1 ? node : node.parentElement
31
+ while (curr?.tagName) {
32
+ if (curr.dataset?.nrMfeId === id) return true
33
+ curr = curr.parentNode
34
+ }
35
+ } catch (e) {}
36
+ return false
37
+ }
38
+
39
+ /**
40
+ * Create mutation observer for MFE nodes
41
+ * @param {string} id - MFE ID to track
42
+ * @param {Function} onMatch - Callback when matching node is *added*
43
+ * @returns {MutationObserver}
44
+ */
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
+
50
+ const obs = new globalScope.MutationObserver(mutations => {
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
+ })
68
+ })
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
+
73
+ return obs
74
+ }
75
+
76
+ /**
77
+ * Create performance observer for MFE entries
78
+ * @param {Array} observers - Array to track observers
79
+ * @param {Object} config - Observer configuration
80
+ * @param {Function} onEntry - Callback for each entry
81
+ * @returns {PerformanceObserver|null} Observer if successful, null if unsupported
82
+ */
83
+ const observePerformance = (observers, config, onEntry) => {
84
+ try {
85
+ const obs = new globalScope.PerformanceObserver(list => {
86
+ list.getEntries().forEach(onEntry)
87
+ })
88
+ obs.observe(config)
89
+ observers.push(obs)
90
+ return obs
91
+ } catch (e) {
92
+ return null
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 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}}
100
+ */
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
+
110
+ const vitals = {
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
+ },
123
+ disconnect: () => {}
124
+ }
125
+
126
+ if (!id || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals
127
+
128
+ const observers = []
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
+
139
+ // Track FCP - first contentful paint
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()
144
+ })
145
+
146
+ // Track LCP - largest contentful paint
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
+
170
+ const lcpObs = observeMutations(id, (node) => {
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
+ }
202
+ }
203
+ } catch (e) {
204
+ // Element may not be observable
205
+ }
206
+ }
207
+ })
208
+
209
+ if (resizeObs) observers.push(resizeObs)
210
+ observers.push(lcpObs)
211
+
212
+ // Track CLS - cumulative layout shift
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 => {
217
+ if (isInMFE(source.node, id)) {
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
221
+ return true
222
+ }
223
+ return false
224
+ })
225
+ })
226
+
227
+ // Track INP - interaction to next paint
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
234
+ }
235
+ })
236
+
237
+ const interactionEvents = ['pointerdown', 'keydown']
238
+ const disconnectInteractionListeners = () => {
239
+ interactionEvents.forEach(type => {
240
+ globalScope.removeEventListener(type, handleInteraction, { passive: true })
241
+ })
242
+ }
243
+
244
+ // Disconnect all observers
245
+ vitals.disconnect = () => {
246
+ // Disconnect all observers
247
+ observers.forEach(obs => {
248
+ try {
249
+ obs?.disconnect()
250
+ } catch (e) {
251
+ // Observer may already be disconnected
252
+ }
253
+ })
254
+ disconnectInteractionListeners()
255
+ }
256
+
257
+ // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
258
+ // CLS and INP continue tracking until visibility change or deregister
259
+ const disconnectLCP = () => {
260
+ try {
261
+ lcpObs?.disconnect()
262
+ resizeObs?.disconnect()
263
+ } catch (e) {
264
+ // Observer may already be disconnected
265
+ }
266
+ }
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 })
277
+ })
278
+
279
+ // Disconnect all observers on visibility change or page unload
280
+ ;['visibilitychange', 'pagehide'].forEach(type => {
281
+ globalScope.addEventListener(type, vitals.disconnect, { once: true, passive: true })
282
+ })
283
+
284
+ return vitals
285
+ }
@@ -251,7 +251,7 @@ export class Aggregate extends AggregateBase {
251
251
  this.addEvent(event, target)
252
252
  }, this.featureName, this.ee)
253
253
 
254
- if (agentRef.init.feature_flags.includes('websockets')) {
254
+ if (agentRef.init.feature_flags.includes('websockets') || agentRef.init.web_sockets?.enabled) {
255
255
  registerHandler('ws-complete', (nrData) => {
256
256
  const event = {
257
257
  ...nrData,
@@ -26,7 +26,7 @@ export class Instrument extends InstrumentBase {
26
26
  static featureName = FEATURE_NAME
27
27
  constructor (agentRef) {
28
28
  super(agentRef, FEATURE_NAME)
29
- const websocketsEnabled = agentRef.init.feature_flags.includes('websockets')
29
+ const websocketsEnabled = agentRef.init.feature_flags.includes('websockets') || agentRef.init.web_sockets?.enabled
30
30
  const securityPolicyViolationEnabled = !agentRef.init.feature_flags.includes('no_spv')
31
31
 
32
32
  /** config values that gate whether the generic events aggregator should be imported at all */
@@ -52,7 +52,7 @@ export class Aggregate extends AggregateBase {
52
52
  this.loggingMode ??= { auto, api } // likewise, don't want to overwrite the mode if it was set already
53
53
  const session = this.agentRef.runtime.session
54
54
  if (canEnableSessionTracking(agentRef.init) && session) {
55
- if (session.isNew) this.#syncWithSessionManager()
55
+ if (session.state.loggingMode === null || session.state.logApiMode === null) this.#writeToStorage(this.loggingMode)
56
56
  else updateLocalLoggingMode(session.state.loggingMode, session.state.logApiMode)
57
57
  }
58
58
  if (this.loggingMode.auto === LOGGING_MODE.OFF && this.loggingMode.api === LOGGING_MODE.OFF) {
@@ -163,14 +163,14 @@ export class Aggregate extends AggregateBase {
163
163
  auto: LOGGING_MODE.OFF,
164
164
  api: LOGGING_MODE.OFF
165
165
  }
166
- this.#syncWithSessionManager()
166
+ this.#writeToStorage(this.loggingMode)
167
167
  this.deregisterDrain()
168
168
  }
169
169
 
170
- #syncWithSessionManager () {
170
+ #writeToStorage (logModes) {
171
171
  this.agentRef.runtime.session?.write({
172
- loggingMode: this.loggingMode.auto,
173
- logApiMode: this.loggingMode.api
172
+ loggingMode: logModes.auto,
173
+ logApiMode: logModes.api
174
174
  })
175
175
  }
176
176
  }