@newrelic/browser-agent 1.316.0 → 1.317.0-rc.1

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 (170) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cjs/common/config/configurable.js +1 -1
  3. package/dist/cjs/common/config/init-types.js +1 -0
  4. package/dist/cjs/common/config/init.js +6 -4
  5. package/dist/cjs/common/constants/env.cdn.js +2 -2
  6. package/dist/cjs/common/constants/env.npm.js +2 -2
  7. package/dist/cjs/common/constants/events.js +26 -0
  8. package/dist/cjs/common/harvest/harvester.js +9 -217
  9. package/dist/cjs/common/harvest/send.js +232 -0
  10. package/dist/cjs/common/harvest/types.js +9 -5
  11. package/dist/cjs/common/payloads/payloads.js +127 -0
  12. package/dist/cjs/common/serialize/bel-serializer.js +4 -3
  13. package/dist/cjs/common/session/session-entity.js +8 -8
  14. package/dist/cjs/common/util/data-size.js +2 -2
  15. package/dist/cjs/common/util/obfuscate.js +147 -3
  16. package/dist/cjs/common/v2/mfe-vitals.js +286 -0
  17. package/dist/cjs/common/wrap/wrap-websocket.js +2 -1
  18. package/dist/cjs/common/wrap/wrap-xhr.js +1 -1
  19. package/dist/cjs/features/ajax/aggregate/gql.js +40 -1
  20. package/dist/cjs/features/ajax/aggregate/index.js +51 -13
  21. package/dist/cjs/features/ajax/constants.js +6 -1
  22. package/dist/cjs/features/ajax/instrument/index.js +105 -17
  23. package/dist/cjs/features/generic_events/aggregate/index.js +15 -10
  24. package/dist/cjs/features/generic_events/constants.js +3 -2
  25. package/dist/cjs/features/jserrors/aggregate/format-stack-trace.js +2 -2
  26. package/dist/cjs/features/jserrors/aggregate/index.js +12 -3
  27. package/dist/cjs/features/logging/aggregate/index.js +12 -8
  28. package/dist/cjs/features/metrics/aggregate/index.js +5 -1
  29. package/dist/cjs/features/page_view_event/aggregate/index.js +72 -33
  30. package/dist/cjs/features/page_view_timing/aggregate/index.js +6 -1
  31. package/dist/cjs/features/session_replay/aggregate/index.js +39 -29
  32. package/dist/cjs/features/session_trace/aggregate/index.js +35 -29
  33. package/dist/cjs/features/session_trace/constants.js +17 -3
  34. package/dist/cjs/features/soft_navigations/aggregate/ajax-node.js +36 -5
  35. package/dist/cjs/features/soft_navigations/aggregate/index.js +7 -1
  36. package/dist/cjs/features/soft_navigations/aggregate/initial-page-load-interaction.js +1 -1
  37. package/dist/cjs/features/soft_navigations/aggregate/interaction.js +6 -4
  38. package/dist/cjs/features/utils/aggregate-base.js +4 -3
  39. package/dist/cjs/loaders/api/register.js +26 -4
  40. package/dist/esm/common/config/configurable.js +1 -1
  41. package/dist/esm/common/config/init-types.js +1 -0
  42. package/dist/esm/common/config/init.js +3 -1
  43. package/dist/esm/common/constants/env.cdn.js +2 -2
  44. package/dist/esm/common/constants/env.npm.js +2 -2
  45. package/dist/esm/common/constants/events.js +20 -0
  46. package/dist/esm/common/harvest/harvester.js +9 -217
  47. package/dist/esm/common/harvest/send.js +226 -0
  48. package/dist/esm/common/harvest/types.js +9 -5
  49. package/dist/esm/common/payloads/payloads.js +118 -0
  50. package/dist/esm/common/serialize/bel-serializer.js +4 -3
  51. package/dist/esm/common/session/session-entity.js +9 -9
  52. package/dist/esm/common/util/data-size.js +2 -2
  53. package/dist/esm/common/util/obfuscate.js +147 -3
  54. package/dist/esm/common/v2/mfe-vitals.js +281 -0
  55. package/dist/esm/common/wrap/wrap-websocket.js +2 -1
  56. package/dist/esm/common/wrap/wrap-xhr.js +1 -1
  57. package/dist/esm/features/ajax/aggregate/gql.js +39 -1
  58. package/dist/esm/features/ajax/aggregate/index.js +52 -14
  59. package/dist/esm/features/ajax/constants.js +5 -0
  60. package/dist/esm/features/ajax/instrument/index.js +106 -18
  61. package/dist/esm/features/generic_events/aggregate/index.js +15 -10
  62. package/dist/esm/features/generic_events/constants.js +3 -2
  63. package/dist/esm/features/jserrors/aggregate/format-stack-trace.js +2 -2
  64. package/dist/esm/features/jserrors/aggregate/index.js +12 -3
  65. package/dist/esm/features/logging/aggregate/index.js +12 -8
  66. package/dist/esm/features/metrics/aggregate/index.js +5 -1
  67. package/dist/esm/features/page_view_event/aggregate/index.js +71 -32
  68. package/dist/esm/features/page_view_timing/aggregate/index.js +6 -1
  69. package/dist/esm/features/session_replay/aggregate/index.js +39 -29
  70. package/dist/esm/features/session_trace/aggregate/index.js +36 -30
  71. package/dist/esm/features/session_trace/constants.js +16 -2
  72. package/dist/esm/features/soft_navigations/aggregate/ajax-node.js +36 -5
  73. package/dist/esm/features/soft_navigations/aggregate/index.js +7 -1
  74. package/dist/esm/features/soft_navigations/aggregate/initial-page-load-interaction.js +1 -1
  75. package/dist/esm/features/soft_navigations/aggregate/interaction.js +6 -4
  76. package/dist/esm/features/utils/aggregate-base.js +4 -3
  77. package/dist/esm/loaders/api/register.js +26 -4
  78. package/dist/tsconfig.tsbuildinfo +1 -1
  79. package/dist/types/common/config/init-types.d.ts +4 -0
  80. package/dist/types/common/config/init-types.d.ts.map +1 -1
  81. package/dist/types/common/config/init.d.ts.map +1 -1
  82. package/dist/types/common/constants/events.d.ts +16 -0
  83. package/dist/types/common/constants/events.d.ts.map +1 -0
  84. package/dist/types/common/harvest/harvester.d.ts +2 -16
  85. package/dist/types/common/harvest/harvester.d.ts.map +1 -1
  86. package/dist/types/common/harvest/send.d.ts +9 -0
  87. package/dist/types/common/harvest/send.d.ts.map +1 -0
  88. package/dist/types/common/harvest/types.d.ts +39 -10
  89. package/dist/types/common/harvest/types.d.ts.map +1 -1
  90. package/dist/types/common/payloads/payloads.d.ts +42 -0
  91. package/dist/types/common/payloads/payloads.d.ts.map +1 -0
  92. package/dist/types/common/serialize/bel-serializer.d.ts +1 -1
  93. package/dist/types/common/serialize/bel-serializer.d.ts.map +1 -1
  94. package/dist/types/common/session/session-entity.d.ts.map +1 -1
  95. package/dist/types/common/util/obfuscate.d.ts +23 -2
  96. package/dist/types/common/util/obfuscate.d.ts.map +1 -1
  97. package/dist/types/common/v2/mfe-vitals.d.ts +14 -0
  98. package/dist/types/common/v2/mfe-vitals.d.ts.map +1 -0
  99. package/dist/types/common/wrap/wrap-websocket.d.ts.map +1 -1
  100. package/dist/types/features/ajax/aggregate/gql.d.ts +8 -0
  101. package/dist/types/features/ajax/aggregate/gql.d.ts.map +1 -1
  102. package/dist/types/features/ajax/aggregate/index.d.ts +2 -0
  103. package/dist/types/features/ajax/aggregate/index.d.ts.map +1 -1
  104. package/dist/types/features/ajax/constants.d.ts +5 -0
  105. package/dist/types/features/ajax/constants.d.ts.map +1 -1
  106. package/dist/types/features/ajax/instrument/index.d.ts.map +1 -1
  107. package/dist/types/features/generic_events/aggregate/index.d.ts +3 -1
  108. package/dist/types/features/generic_events/aggregate/index.d.ts.map +1 -1
  109. package/dist/types/features/generic_events/constants.d.ts.map +1 -1
  110. package/dist/types/features/jserrors/aggregate/index.d.ts +8 -1
  111. package/dist/types/features/jserrors/aggregate/index.d.ts.map +1 -1
  112. package/dist/types/features/logging/aggregate/index.d.ts +3 -1
  113. package/dist/types/features/logging/aggregate/index.d.ts.map +1 -1
  114. package/dist/types/features/metrics/aggregate/index.d.ts +2 -0
  115. package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
  116. package/dist/types/features/page_view_event/aggregate/index.d.ts +3 -0
  117. package/dist/types/features/page_view_event/aggregate/index.d.ts.map +1 -1
  118. package/dist/types/features/page_view_timing/aggregate/index.d.ts +2 -0
  119. package/dist/types/features/page_view_timing/aggregate/index.d.ts.map +1 -1
  120. package/dist/types/features/session_replay/aggregate/index.d.ts +4 -2
  121. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  122. package/dist/types/features/session_trace/aggregate/index.d.ts +3 -1
  123. package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
  124. package/dist/types/features/session_trace/constants.d.ts +17 -0
  125. package/dist/types/features/soft_navigations/aggregate/ajax-node.d.ts +6 -1
  126. package/dist/types/features/soft_navigations/aggregate/ajax-node.d.ts.map +1 -1
  127. package/dist/types/features/soft_navigations/aggregate/index.d.ts +3 -0
  128. package/dist/types/features/soft_navigations/aggregate/index.d.ts.map +1 -1
  129. package/dist/types/features/soft_navigations/aggregate/interaction.d.ts +3 -1
  130. package/dist/types/features/soft_navigations/aggregate/interaction.d.ts.map +1 -1
  131. package/dist/types/features/utils/aggregate-base.d.ts +0 -1
  132. package/dist/types/features/utils/aggregate-base.d.ts.map +1 -1
  133. package/dist/types/loaders/api/register.d.ts.map +1 -1
  134. package/package.json +3 -3
  135. package/src/common/config/configurable.js +1 -1
  136. package/src/common/config/init-types.js +1 -0
  137. package/src/common/config/init.js +2 -1
  138. package/src/common/constants/events.js +20 -0
  139. package/src/common/harvest/harvester.js +9 -201
  140. package/src/common/harvest/send.js +209 -0
  141. package/src/common/harvest/types.js +9 -5
  142. package/src/common/payloads/payloads.js +135 -0
  143. package/src/common/serialize/bel-serializer.js +4 -3
  144. package/src/common/session/session-entity.js +8 -9
  145. package/src/common/util/data-size.js +2 -2
  146. package/src/common/util/obfuscate.js +154 -3
  147. package/src/common/v2/mfe-vitals.js +260 -0
  148. package/src/common/wrap/wrap-websocket.js +2 -1
  149. package/src/common/wrap/wrap-xhr.js +1 -1
  150. package/src/features/ajax/aggregate/gql.js +42 -1
  151. package/src/features/ajax/aggregate/index.js +43 -13
  152. package/src/features/ajax/constants.js +5 -1
  153. package/src/features/ajax/instrument/index.js +114 -18
  154. package/src/features/generic_events/aggregate/index.js +14 -9
  155. package/src/features/generic_events/constants.js +3 -2
  156. package/src/features/jserrors/aggregate/format-stack-trace.js +3 -3
  157. package/src/features/jserrors/aggregate/index.js +12 -3
  158. package/src/features/logging/aggregate/index.js +12 -11
  159. package/src/features/metrics/aggregate/index.js +5 -1
  160. package/src/features/page_view_event/aggregate/index.js +58 -29
  161. package/src/features/page_view_timing/aggregate/index.js +6 -1
  162. package/src/features/session_replay/aggregate/index.js +37 -28
  163. package/src/features/session_trace/aggregate/index.js +37 -30
  164. package/src/features/session_trace/constants.js +7 -1
  165. package/src/features/soft_navigations/aggregate/ajax-node.js +26 -5
  166. package/src/features/soft_navigations/aggregate/index.js +7 -1
  167. package/src/features/soft_navigations/aggregate/initial-page-load-interaction.js +1 -1
  168. package/src/features/soft_navigations/aggregate/interaction.js +6 -4
  169. package/src/features/utils/aggregate-base.js +3 -3
  170. package/src/loaders/api/register.js +24 -4
@@ -0,0 +1,260 @@
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
+ import { cleanURL } from '../url/clean-url'
9
+
10
+ /**
11
+ * @typedef {import('./register-api-types').RegisterAPITimings} RegisterAPITimings
12
+ */
13
+
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
+ const isObservable = (node) => {
61
+ try {
62
+ return node?.textContent?.trim() || ['img', 'video', 'canvas', 'svg'].includes(node?.nodeName?.toLowerCase())
63
+ } catch (e) {
64
+ return false
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Check if node is within a specific MFE
70
+ * @param {Node} node - DOM node to check
71
+ * @param {string} id - MFE ID to match
72
+ * @returns {boolean}
73
+ */
74
+ const isInMFE = (node, id) => {
75
+ try {
76
+ let curr = node.nodeType === 1 ? node : node.parentElement
77
+ while (curr?.tagName) {
78
+ if (curr.dataset?.nrMfeId === id) return true
79
+ curr = curr.parentNode
80
+ }
81
+ } catch (e) {}
82
+ return false
83
+ }
84
+
85
+ /**
86
+ * Create mutation observer for MFE nodes
87
+ * @param {string} id - MFE ID to track
88
+ * @param {Function} onMatch - Callback when matching node is added
89
+ * @returns {MutationObserver}
90
+ */
91
+ const observeMutations = (id, onMatch) => {
92
+ const obs = new globalScope.MutationObserver(mutations => {
93
+ mutations.forEach(m => m.addedNodes.forEach(node => {
94
+ if (isObservable(node) && isInMFE(node, id)) {
95
+ onMatch(node, obs)
96
+ }
97
+ }))
98
+ })
99
+ obs.observe(globalScope.document, { childList: true, subtree: true })
100
+ return obs
101
+ }
102
+
103
+ /**
104
+ * Create performance observer for MFE entries
105
+ * @param {Array} observers - Array to track observers
106
+ * @param {Object} config - Observer configuration
107
+ * @param {Function} onEntry - Callback for each entry
108
+ * @returns {PerformanceObserver|null} Observer if successful, null if unsupported
109
+ */
110
+ const observePerformance = (observers, config, onEntry) => {
111
+ try {
112
+ const obs = new globalScope.PerformanceObserver(list => {
113
+ list.getEntries().forEach(onEntry)
114
+ })
115
+ obs.observe(config)
116
+ observers.push(obs)
117
+ return obs
118
+ } catch (e) {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Tracks all Core Web Vitals for a specific MFE.
125
+ * @param {string} id - The MFE ID to track
126
+ * @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
127
+ */
128
+ export function trackMFEVitals (id) {
129
+ const vitals = {
130
+ fcp: null,
131
+ lcp: null,
132
+ cls: null,
133
+ inp: null,
134
+ disconnect: () => {}
135
+ }
136
+
137
+ if (!id || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals
138
+
139
+ const observers = []
140
+
141
+ // Track FCP - first contentful paint
142
+ observeMutations(id, (node, obs) => {
143
+ if (vitals.fcp === null) {
144
+ vitals.fcp = {
145
+ value: now(),
146
+ loadState: globalScope.document?.readyState || null
147
+ }
148
+ obs.disconnect()
149
+ }
150
+ })
151
+
152
+ // Track LCP - largest contentful paint
153
+ let largestSize = 0
154
+ const lcpObs = observeMutations(id, (node) => {
155
+ try {
156
+ const elem = node.nodeType === 1 ? node : node.parentElement
157
+ if (!elem) return
158
+ const rect = elem.getBoundingClientRect()
159
+ const size = rect.width * rect.height
160
+ if (size > largestSize) {
161
+ largestSize = size
162
+ vitals.lcp = {
163
+ value: now(),
164
+ size,
165
+ elTag: elem.tagName || null,
166
+ eid: elem.id || null,
167
+ elUrl: getElementURL(elem)
168
+ }
169
+ }
170
+ } catch (e) {
171
+ // Element may be detached from DOM
172
+ }
173
+ })
174
+ observers.push(lcpObs)
175
+
176
+ // Track CLS - cumulative layout shift
177
+ // Initialize CLS to 0 if browser supports it AND the MFE container exists in the DOM
178
+ let clsValue = 0
179
+ const clsObs = observePerformance(observers, { type: 'layout-shift', buffered: true }, (entry) => {
180
+ if (entry.hadRecentInput || !vitals.cls) return
181
+ (entry.sources || []).some(source => {
182
+ if (isInMFE(source.node, id)) {
183
+ clsValue += entry.value
184
+ vitals.cls ??= { value: 0, largestShiftValue: null, largestShiftTime: null, largestShiftTarget: null, loadState: null }
185
+ vitals.cls.value = clsValue
186
+
187
+ // Track largest shift
188
+ if (entry.value > (vitals.cls.largestShiftValue || 0)) {
189
+ vitals.cls.largestShiftValue = entry.value
190
+ vitals.cls.largestShiftTime = entry.startTime
191
+ vitals.cls.largestShiftTarget = getElementSelector(source.node)
192
+ vitals.cls.loadState = globalScope.document?.readyState || null
193
+ }
194
+ return true
195
+ }
196
+ return false
197
+ })
198
+ })
199
+
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
+ // Track INP - interaction to next paint
212
+ observePerformance(observers, { type: 'event', buffered: true, durationThreshold: 16 }, (entry) => {
213
+ if (!entry.interactionId || !entry.target || !isInMFE(entry.target, id)) return
214
+ if (vitals.inp === null || entry.duration > vitals.inp.value) {
215
+ vitals.inp = {
216
+ value: entry.duration,
217
+ interactionTarget: getElementSelector(entry.target),
218
+ interactionTime: entry.startTime,
219
+ interactionType: entry.name,
220
+ inputDelay: entry.processingStart ? entry.processingStart - entry.startTime : null,
221
+ processingDuration: (entry.processingStart && entry.processingEnd) ? entry.processingEnd - entry.processingStart : null,
222
+ presentationDelay: (entry.processingEnd && entry.duration) ? entry.duration - (entry.processingEnd - entry.startTime) : null,
223
+ nextPaintTime: entry.startTime + entry.duration,
224
+ loadState: globalScope.document?.readyState || null
225
+ }
226
+ }
227
+ })
228
+
229
+ // Disconnect all observers
230
+ vitals.disconnect = () => {
231
+ // Disconnect all observers
232
+ observers.forEach(obs => {
233
+ try {
234
+ obs?.disconnect()
235
+ } catch (e) {
236
+ // Observer may already be disconnected
237
+ }
238
+ })
239
+ }
240
+
241
+ // Auto-disconnect LCP observer on user interaction (per Web Vitals spec)
242
+ // CLS and INP continue tracking until visibility change or deregister
243
+ const disconnectLCP = () => {
244
+ try {
245
+ lcpObs?.disconnect()
246
+ } catch (e) {
247
+ // Observer may already be disconnected
248
+ }
249
+ }
250
+ ;['click', 'keydown', 'scroll'].forEach(type => {
251
+ globalScope.addEventListener(type, disconnectLCP, { once: true, passive: true })
252
+ })
253
+
254
+ // Disconnect all observers on visibility change or page unload
255
+ ;['visibilitychange', 'pagehide'].forEach(type => {
256
+ globalScope.addEventListener(type, vitals.disconnect, { once: true, passive: true })
257
+ })
258
+
259
+ return vitals
260
+ }
@@ -8,6 +8,7 @@ import { now } from '../timing/now'
8
8
  import { cleanURL } from '../url/clean-url'
9
9
  import { gosNREUMOriginals } from '../window/nreum'
10
10
  import { subscribeToPageUnload } from '../window/page-visibility'
11
+ import { EVENT_TYPES } from '../constants/events'
11
12
 
12
13
  const wrapped = {}
13
14
  const openWebSockets = new Set() // track all instances to close out metrics on page unload
@@ -37,7 +38,7 @@ export function wrapWebSocket (sharedEE) {
37
38
  })
38
39
 
39
40
  class WrappedWebSocket extends WebSocket {
40
- static name = 'WebSocket'
41
+ static name = EVENT_TYPES.WS
41
42
  static toString () { // fake native WebSocket when static class is stringified
42
43
  return 'function WebSocket() { [native code] }'
43
44
  }
@@ -17,7 +17,7 @@ import { warn } from '../util/console'
17
17
  import { findTargetsFromStackTrace } from '../v2/utils'
18
18
 
19
19
  const wrapped = {}
20
- const XHR_PROPS = ['open', 'send'] // these are the specific funcs being wrapped on all XMLHttpRequests(.prototype)
20
+ const XHR_PROPS = ['open', 'send', 'setRequestHeader'] // these are the specific funcs being wrapped on all XMLHttpRequests(.prototype)
21
21
 
22
22
  /**
23
23
  * Wraps the native XMLHttpRequest (XHR) object to emit custom events to its readystatechange event and an assortment
@@ -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
  import { isPureObject } from '../../../common/util/type-check'
@@ -97,3 +97,44 @@ function parseGQLQueryString (gqlQueryString) {
97
97
  function validateGQLObject (obj) {
98
98
  return !(typeof obj !== 'object' || !obj.query || typeof obj.query !== 'string')
99
99
  }
100
+
101
+ /**
102
+ * Checks if a response object has valid GraphQL errors.
103
+ * @param {object} response - A single GraphQL response object
104
+ * @returns {boolean} True if the response has valid errors
105
+ */
106
+ function hasValidGQLErrors (response) {
107
+ return Array.isArray(response?.errors) &&
108
+ response.errors.some(err =>
109
+ err && typeof err === 'object' && typeof err.message === 'string'
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Checks if a response body contains GraphQL errors according to the GraphQL spec.
115
+ * A valid GraphQL error response contains an "errors" array with at least one error object.
116
+ * Supports both single and batched GraphQL responses.
117
+ * @param {string|object|array} [responseBody] The response body to check
118
+ * @returns {boolean} True if the response contains GraphQL errors
119
+ */
120
+ export function hasGQLErrors (responseBody) {
121
+ if (!responseBody) return false
122
+ try {
123
+ let parsed = responseBody
124
+
125
+ // Parse string to object if needed
126
+ if (typeof responseBody === 'string') {
127
+ parsed = JSON.parse(responseBody)
128
+ }
129
+
130
+ // Handle batched GraphQL responses (array of response objects)
131
+ if (Array.isArray(parsed)) {
132
+ return parsed.some(hasValidGQLErrors)
133
+ }
134
+
135
+ // Handle single GraphQL response
136
+ return hasValidGQLErrors(parsed)
137
+ } catch (err) {
138
+ return false
139
+ }
140
+ }
@@ -6,13 +6,16 @@ import { registerHandler } from '../../../common/event-emitter/register-handler'
6
6
  import { stringify } from '../../../common/util/stringify'
7
7
  import { handle } from '../../../common/event-emitter/handle'
8
8
  import { setDenyList, shouldCollectEvent } from '../../../common/deny-list/deny-list'
9
- import { AJAX_ID, FEATURE_NAME } from '../constants'
9
+ import { FEATURE_NAME, AJAX_ID } from '../constants'
10
10
  import { FEATURE_NAMES } from '../../../loaders/features/features'
11
11
  import { AggregateBase } from '../../utils/aggregate-base'
12
- import { parseGQL } from './gql'
13
12
  import { nullable, numeric, getAddStringContext, addCustomAttributes } from '../../../common/serialize/bel-serializer'
14
13
  import { gosNREUMOriginals } from '../../../common/window/nreum'
14
+ import { hasGQLErrors, parseGQL } from './gql'
15
+ import { canCapturePayload, isLikelyHumanReadable, parseQueryString, createStringAdders } from '../../../common/payloads/payloads'
16
+ import { Obfuscator } from '../../../common/util/obfuscate'
15
17
  import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/v2/utils'
18
+ import { EVENT_TYPES } from '../../../common/constants/events'
16
19
  import { generateUuid } from '../../../common/ids/unique-id'
17
20
 
18
21
  export class Aggregate extends AggregateBase {
@@ -23,6 +26,9 @@ export class Aggregate extends AggregateBase {
23
26
  setDenyList(agentRef.runtime.denyList)
24
27
  const classThis = this
25
28
 
29
+ // Create obfuscator for AJAX requests
30
+ this.obfuscator = new Obfuscator(agentRef, EVENT_TYPES.AJAX)
31
+
26
32
  if (!agentRef.init.ajax.block_internal) {
27
33
  // if the agent is tracking ITSELF, it can spawn endless ajax requests early if they are large from custom attributes, so we just disable early harvest for ajax in this case.
28
34
  super.canHarvestEarly = false
@@ -96,6 +102,24 @@ export class Aggregate extends AggregateBase {
96
102
  [AJAX_ID]: generateUuid() // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
97
103
  }
98
104
 
105
+ event.gql = params.gql = parseGQL({
106
+ body: ctx.requestBody,
107
+ query: ctx.parsedOrigin?.search
108
+ })
109
+ if (event.gql) event.gql.operationHasErrors = params.gql.operationHasErrors = hasGQLErrors(ctx.responseBody)
110
+
111
+ const capturePayloadSetting = this.agentRef.init.ajax.capture_payloads
112
+ const shouldCapturePayload = canCapturePayload(capturePayloadSetting, params.status, event.gql?.operationHasErrors)
113
+
114
+ if (shouldCapturePayload) {
115
+ // 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
121
+ }
122
+
99
123
  if (ctx.dt) {
100
124
  event.spanId = ctx.dt.spanId
101
125
  event.traceId = ctx.dt.traceId
@@ -104,11 +128,6 @@ export class Aggregate extends AggregateBase {
104
128
  )
105
129
  }
106
130
 
107
- // parsed from the AJAX body, looking for operationName param & parsing query for operationType
108
- event.gql = params.gql = parseGQL({
109
- body: ctx.body,
110
- query: ctx.parsedOrigin?.search
111
- })
112
131
  if (event.gql) this.reportSupportabilityMetric('Ajax/Events/GraphQL/Bytes-Added', stringify(event.gql).length)
113
132
 
114
133
  /** make a copy of the event for the MFE target if it exists */
@@ -131,7 +150,9 @@ export class Aggregate extends AggregateBase {
131
150
 
132
151
  serializer (eventBuffer) {
133
152
  if (!eventBuffer.length) return
134
- const addString = getAddStringContext(this.agentRef.runtime.obfuscator)
153
+
154
+ const { addString, addStringWithTruncation } = createStringAdders(getAddStringContext, this.obfuscator)
155
+
135
156
  let payload = 'bel.7;'
136
157
 
137
158
  let firstTimestamp = 0
@@ -165,15 +186,24 @@ export class Aggregate extends AggregateBase {
165
186
  // Since configuration objects (like info) are created new each time they are set, we have to grab the current pointer to the attr object here.
166
187
  const jsAttributes = this.agentRef.info.jsAttributes
167
188
 
168
- // add custom attributes
169
- // gql decorators are added as custom attributes to alleviate need for new BEL schema
170
- const attrParts = addCustomAttributes({
189
+ // Regular attributes: obfuscate only
190
+ const regularAttrs = addCustomAttributes({
171
191
  ...(jsAttributes || {}),
172
- ...(event.gql || {}),
192
+ [AJAX_ID]: event[AJAX_ID], // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
173
193
  ...(event.targetAttributes || {}), // used to supply the version 2 attributes, either MFE target or duplication attributes for the main agent app
174
- [AJAX_ID]: event[AJAX_ID] // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
194
+ ...(event.gql || {})
175
195
  }, addString)
176
196
 
197
+ // Payload attributes: obfuscate then truncate
198
+ const payloadAttrs = addCustomAttributes({
199
+ ...(event.requestBody ? { requestBody: event.requestBody } : {}),
200
+ ...(event.requestHeaders ? { requestHeaders: event.requestHeaders } : {}),
201
+ ...(event.requestQuery ? { requestQuery: event.requestQuery } : {}),
202
+ ...(event.responseBody ? { responseBody: event.responseBody } : {}),
203
+ ...(event.responseHeaders ? { responseHeaders: event.responseHeaders } : {})
204
+ }, addStringWithTruncation)
205
+
206
+ const attrParts = [...regularAttrs, ...payloadAttrs]
177
207
  fields.unshift(numeric(attrParts.length))
178
208
 
179
209
  insert += fields.join(',')
@@ -5,5 +5,9 @@
5
5
  import { FEATURE_NAMES } from '../../loaders/features/features'
6
6
 
7
7
  export const FEATURE_NAME = FEATURE_NAMES.ajax
8
-
8
+ export const CAPTURE_PAYLOAD_SETTINGS = {
9
+ NONE: 'none',
10
+ FAILURES: 'failures',
11
+ ALL: 'all'
12
+ }
9
13
  export const AJAX_ID = 'ajaxRequest.id'
@@ -14,7 +14,7 @@ import { parseUrl } from '../../../common/url/parse-url'
14
14
  import { DT } from './distributed-tracing'
15
15
  import { responseSizeFromXhr } from './response-size'
16
16
  import { InstrumentBase } from '../../utils/instrument-base'
17
- import { FEATURE_NAME } from '../constants'
17
+ import { CAPTURE_PAYLOAD_SETTINGS, FEATURE_NAME } from '../constants'
18
18
  import { FEATURE_NAMES } from '../../../loaders/features/features'
19
19
  import { SUPPORTABILITY_METRIC } from '../../metrics/constants'
20
20
  import { now } from '../../../common/timing/now'
@@ -27,6 +27,7 @@ var handlersLen = handlers.length
27
27
  var origRequest = gosNREUMOriginals().o.REQ
28
28
  var origXHR = gosNREUMOriginals().o.XHR
29
29
  const NR_CAT_HEADER = 'X-NewRelic-App-Data'
30
+ const INTERNAL_ERROR = 'internal-error'
30
31
 
31
32
  export class Instrument extends InstrumentBase {
32
33
  static featureName = FEATURE_NAME
@@ -62,16 +63,18 @@ export class Instrument extends InstrumentBase {
62
63
  wrapFetch(this.ee, agentRef)
63
64
  wrapXhr(this.ee, agentRef)
64
65
  subscribeToEvents(agentRef, this.ee, this.handler, this.dt)
65
-
66
66
  this.importAggregator(agentRef, () => import(/* webpackChunkName: "ajax-aggregate" */ '../aggregate/index.js'))
67
67
  }
68
68
  }
69
69
 
70
70
  function subscribeToEvents (agentRef, ee, handler, dt) {
71
+ const shouldInterceptPayloads = [CAPTURE_PAYLOAD_SETTINGS.ALL, CAPTURE_PAYLOAD_SETTINGS.FAILURES].includes(agentRef.init.ajax?.capture_payloads)
72
+
71
73
  ee.on('new-xhr', onNewXhr)
72
74
  ee.on('open-xhr-start', onOpenXhrStart)
73
75
  ee.on('open-xhr-end', onOpenXhrEnd)
74
76
  ee.on('send-xhr-start', onSendXhrStart)
77
+ ee.on('setRequestHeader-xhr-start', onSetRequestHeader)
75
78
  ee.on('xhr-cb-time', onXhrCbTime)
76
79
  ee.on('xhr-load-added', onXhrLoadAdded)
77
80
  ee.on('xhr-load-removed', onXhrLoadRemoved)
@@ -147,6 +150,14 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
147
150
  }
148
151
  }
149
152
 
153
+ function onSetRequestHeader (args, xhr) {
154
+ // args[0] = header name, args[1] = header value
155
+ if (shouldInterceptPayloads && args.length >= 2) {
156
+ this.requestHeaders ??= {}
157
+ this.requestHeaders[args[0].toLowerCase()] = args[1]
158
+ }
159
+ }
160
+
150
161
  function onSendXhrStart (args, xhr) {
151
162
  var metrics = this.metrics
152
163
  var data = args[0]
@@ -159,7 +170,7 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
159
170
 
160
171
  this.startTime = now()
161
172
 
162
- this.body = data
173
+ this.requestBody = data
163
174
 
164
175
  this.listener = function (evt) {
165
176
  try {
@@ -169,7 +180,7 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
169
180
  if (evt.type !== 'load' || ((context.called === context.totalCbs) && (context.onloadCalled || typeof (xhr.onload) !== 'function') && typeof context.end === 'function')) context.end(xhr)
170
181
  } catch (e) {
171
182
  try {
172
- ee.emit('internal-error', [e])
183
+ ee.emit(INTERNAL_ERROR, [e])
173
184
  } catch (err) {
174
185
  // do nothing
175
186
  }
@@ -321,9 +332,29 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
321
332
  const method = ('' + ((target && target instanceof origRequest && target.method) ||
322
333
  opts.method || 'GET')).toUpperCase()
323
334
  this.params.method = method
324
- this.body = opts.body
325
335
 
326
- this.txSize = dataSize(opts.body) || 0
336
+ this.txSize = dataSize(opts.body || (target?.body)) || 0
337
+
338
+ // Capture request headers
339
+ try {
340
+ var headers = opts.headers || (target?.headers)
341
+ if (shouldInterceptPayloads && headers) {
342
+ this.requestHeaders ??= {}
343
+ if (headers instanceof Headers) {
344
+ headers.forEach(function (value, key) {
345
+ this.requestHeaders[key.toLowerCase()] = value
346
+ }.bind(this))
347
+ } else if (typeof headers === 'object') {
348
+ for (var key in headers) {
349
+ this.requestHeaders[key.toLowerCase()] = headers[key]
350
+ }
351
+ }
352
+ }
353
+ } catch (e) {
354
+ // Silently fail if we can't access headers
355
+ }
356
+
357
+ this.requestBody = opts.body || (target?.body)
327
358
  }
328
359
 
329
360
  // we capture failed call as status 0, the actual error is ignored
@@ -335,20 +366,47 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
335
366
 
336
367
  this.params.status = res ? res.status : 0
337
368
 
338
- // convert rxSize to a number
339
- let responseSize
340
- if (typeof this.rxSize === 'string' && this.rxSize.length > 0) {
341
- responseSize = +this.rxSize
369
+ const finishAndReport = () => {
370
+ // convert rxSize to a number - handle both string (from content-length header) and number (from fallback)
371
+ const num = +this.rxSize
372
+ const responseSize = this.rxSize != null && !isNaN(num) ? num : undefined
373
+
374
+ const metrics = {
375
+ txSize: this.txSize,
376
+ rxSize: responseSize,
377
+ duration: this.endTime - this.startTime
378
+ }
379
+
380
+ const payload = [this.params, metrics, this.startTime, this.endTime, 'fetch']
381
+ this.targets.forEach(target => reportToAgg(payload, this, target))
342
382
  }
343
383
 
344
- const metrics = {
345
- txSize: this.txSize,
346
- rxSize: responseSize,
347
- duration: now() - this.startTime
384
+ /** Since accessing fetch bodies is an async process, these are
385
+ * reasonable conditions to check to not do needless extra work */
386
+ if (!res || !shouldInterceptPayloads) {
387
+ finishAndReport()
388
+ return
348
389
  }
349
390
 
350
- const payload = [this.params, metrics, this.startTime, this.endTime, 'fetch']
351
- this.targets.forEach(target => reportToAgg(payload, this, target))
391
+ // Clone the response to read the body without consuming the original
392
+ res.clone().text().then((text) => {
393
+ this.responseBody = text
394
+ // Use captured payload size as fallback if content-length header was missing or is 0 with a body
395
+ // Only apply fallback for non-network-error responses (status !== 0)
396
+ if ((!this.rxSize || this.rxSize === '0' || this.rxSize === 0) && text !== undefined && this.params.status !== 0) {
397
+ this.rxSize = dataSize(text)
398
+ }
399
+ if (res?.headers) {
400
+ this.responseHeaders = {}
401
+ res.headers.forEach(function (value, key) {
402
+ this.responseHeaders[key.toLowerCase()] = value
403
+ }.bind(this))
404
+ }
405
+ }).catch((err) => {
406
+ ee.emit(INTERNAL_ERROR, [err])
407
+ }).finally(() => {
408
+ finishAndReport()
409
+ })
352
410
  }
353
411
 
354
412
  // Create report for XHR request that has finished
@@ -365,7 +423,7 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
365
423
  if (params.aborted) return
366
424
  if (hasUndefinedHostname(params)) return // don't bother with XHR of url with no hostname
367
425
 
368
- metrics.duration = now() - this.startTime
426
+ metrics.duration = this.endTime - this.startTime
369
427
  if (!this.loadCaptureCalled && xhr.readyState === 4) {
370
428
  captureXhrData(this, xhr)
371
429
  } else if (params.status == null) {
@@ -375,6 +433,27 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
375
433
  // Always send cbTime, even if no noticeable time was taken.
376
434
  metrics.cbTime = this.cbTime
377
435
 
436
+ if (shouldInterceptPayloads) {
437
+ try {
438
+ this.responseBody = xhr.responseText
439
+ } catch (e) {
440
+ this.responseBody = xhr.response
441
+ }
442
+
443
+ // Use captured payload size as fallback if not already determined or is 0 with a body
444
+ // Only apply fallback for non-network-error responses (status !== 0)
445
+ if ((!metrics.rxSize || metrics.rxSize === 0) && this.responseBody !== undefined && params.status !== 0) {
446
+ const size = dataSize(this.responseBody)
447
+ if (size !== undefined) metrics.rxSize = size
448
+ }
449
+
450
+ try {
451
+ this.responseHeaders = parseResponseHeaders(xhr.getAllResponseHeaders())
452
+ } catch (err) {
453
+ ee.emit(INTERNAL_ERROR, [err])
454
+ }
455
+ }
456
+
378
457
  const payload = [params, metrics, this.startTime, this.endTime, 'xhr']
379
458
  this.targets.forEach(target => reportToAgg(payload, this, target))
380
459
  }
@@ -387,7 +466,8 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
387
466
  ctx.params.status = xhr.status
388
467
 
389
468
  var size = responseSizeFromXhr(xhr, ctx.lastSize)
390
- if (size) ctx.metrics.rxSize = size
469
+ // Don't set rxSize for network errors (status 0)
470
+ if (size !== undefined && xhr.status !== 0) ctx.metrics.rxSize = size
391
471
 
392
472
  if (ctx.sameOrigin && xhr.getAllResponseHeaders().indexOf(NR_CAT_HEADER) >= 0) {
393
473
  var header = xhr.getResponseHeader(NR_CAT_HEADER)
@@ -414,4 +494,20 @@ function addUrl (ctx, url) {
414
494
  ctx.sameOrigin = parsed.sameOrigin
415
495
  }
416
496
 
497
+ function parseResponseHeaders (headerStr) {
498
+ const headers = {}
499
+ if (!headerStr) return headers
500
+
501
+ headerStr.split('\r\n').forEach(function (line) {
502
+ const separatorIndex = line.indexOf(': ')
503
+ if (separatorIndex > 0) {
504
+ const name = line.substring(0, separatorIndex)
505
+ const value = line.substring(separatorIndex + 2)
506
+ headers[name.toLowerCase()] = value
507
+ }
508
+ })
509
+
510
+ return headers
511
+ }
512
+
417
513
  export const Ajax = Instrument