@newrelic/browser-agent 1.298.0 → 1.299.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 (80) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +12 -2
  3. package/dist/cjs/common/constants/env.cdn.js +1 -1
  4. package/dist/cjs/common/constants/env.npm.js +1 -1
  5. package/dist/cjs/common/dom/selector-path.js +60 -44
  6. package/dist/cjs/common/harvest/harvester.js +37 -7
  7. package/dist/cjs/common/url/extract-url.js +21 -0
  8. package/dist/cjs/features/ajax/instrument/index.js +2 -9
  9. package/dist/cjs/features/generic_events/aggregate/index.js +29 -8
  10. package/dist/cjs/features/generic_events/aggregate/user-actions/aggregated-user-action.js +5 -3
  11. package/dist/cjs/features/generic_events/aggregate/user-actions/user-actions-aggregator.js +71 -33
  12. package/dist/cjs/features/generic_events/constants.js +2 -1
  13. package/dist/cjs/features/generic_events/instrument/index.js +45 -0
  14. package/dist/cjs/features/metrics/aggregate/framework-detection.js +2 -0
  15. package/dist/cjs/features/metrics/aggregate/harvest-metadata.js +45 -0
  16. package/dist/cjs/features/metrics/aggregate/index.js +21 -0
  17. package/dist/cjs/features/session_replay/aggregate/index.js +1 -1
  18. package/dist/cjs/features/session_replay/shared/recorder.js +6 -2
  19. package/dist/cjs/loaders/api/noticeError.js +1 -0
  20. package/dist/cjs/loaders/configure/configure.js +1 -0
  21. package/dist/esm/common/constants/env.cdn.js +1 -1
  22. package/dist/esm/common/constants/env.npm.js +1 -1
  23. package/dist/esm/common/dom/selector-path.js +58 -42
  24. package/dist/esm/common/harvest/harvester.js +37 -7
  25. package/dist/esm/common/url/extract-url.js +15 -0
  26. package/dist/esm/features/ajax/instrument/index.js +2 -9
  27. package/dist/esm/features/generic_events/aggregate/index.js +29 -8
  28. package/dist/esm/features/generic_events/aggregate/user-actions/aggregated-user-action.js +5 -3
  29. package/dist/esm/features/generic_events/aggregate/user-actions/user-actions-aggregator.js +72 -34
  30. package/dist/esm/features/generic_events/constants.js +1 -0
  31. package/dist/esm/features/generic_events/instrument/index.js +46 -1
  32. package/dist/esm/features/metrics/aggregate/framework-detection.js +2 -0
  33. package/dist/esm/features/metrics/aggregate/harvest-metadata.js +39 -0
  34. package/dist/esm/features/metrics/aggregate/index.js +21 -0
  35. package/dist/esm/features/session_replay/aggregate/index.js +1 -1
  36. package/dist/esm/features/session_replay/shared/recorder.js +6 -2
  37. package/dist/esm/loaders/api/noticeError.js +1 -0
  38. package/dist/esm/loaders/configure/configure.js +1 -0
  39. package/dist/tsconfig.tsbuildinfo +1 -1
  40. package/dist/types/common/dom/selector-path.d.ts +6 -1
  41. package/dist/types/common/dom/selector-path.d.ts.map +1 -1
  42. package/dist/types/common/harvest/harvester.d.ts.map +1 -1
  43. package/dist/types/common/url/extract-url.d.ts +7 -0
  44. package/dist/types/common/url/extract-url.d.ts.map +1 -0
  45. package/dist/types/features/ajax/instrument/index.d.ts.map +1 -1
  46. package/dist/types/features/generic_events/aggregate/index.d.ts +1 -3
  47. package/dist/types/features/generic_events/aggregate/index.d.ts.map +1 -1
  48. package/dist/types/features/generic_events/aggregate/user-actions/aggregated-user-action.d.ts +3 -1
  49. package/dist/types/features/generic_events/aggregate/user-actions/aggregated-user-action.d.ts.map +1 -1
  50. package/dist/types/features/generic_events/aggregate/user-actions/user-actions-aggregator.d.ts +3 -0
  51. package/dist/types/features/generic_events/aggregate/user-actions/user-actions-aggregator.d.ts.map +1 -1
  52. package/dist/types/features/generic_events/constants.d.ts +1 -0
  53. package/dist/types/features/generic_events/constants.d.ts.map +1 -1
  54. package/dist/types/features/generic_events/instrument/index.d.ts +2 -0
  55. package/dist/types/features/generic_events/instrument/index.d.ts.map +1 -1
  56. package/dist/types/features/metrics/aggregate/framework-detection.d.ts.map +1 -1
  57. package/dist/types/features/metrics/aggregate/harvest-metadata.d.ts +6 -0
  58. package/dist/types/features/metrics/aggregate/harvest-metadata.d.ts.map +1 -0
  59. package/dist/types/features/metrics/aggregate/index.d.ts +2 -0
  60. package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
  61. package/dist/types/features/session_replay/shared/recorder.d.ts.map +1 -1
  62. package/dist/types/loaders/api/noticeError.d.ts.map +1 -1
  63. package/dist/types/loaders/configure/configure.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/common/dom/selector-path.js +51 -39
  66. package/src/common/harvest/harvester.js +34 -7
  67. package/src/common/url/extract-url.js +17 -0
  68. package/src/features/ajax/instrument/index.js +2 -10
  69. package/src/features/generic_events/aggregate/index.js +23 -8
  70. package/src/features/generic_events/aggregate/user-actions/aggregated-user-action.js +5 -3
  71. package/src/features/generic_events/aggregate/user-actions/user-actions-aggregator.js +80 -24
  72. package/src/features/generic_events/constants.js +2 -0
  73. package/src/features/generic_events/instrument/index.js +51 -1
  74. package/src/features/metrics/aggregate/framework-detection.js +2 -0
  75. package/src/features/metrics/aggregate/harvest-metadata.js +42 -0
  76. package/src/features/metrics/aggregate/index.js +21 -0
  77. package/src/features/session_replay/aggregate/index.js +1 -1
  78. package/src/features/session_replay/shared/recorder.js +5 -1
  79. package/src/loaders/api/noticeError.js +1 -0
  80. package/src/loaders/configure/configure.js +1 -0
@@ -29,6 +29,7 @@ const FRAMEWORKS = {
29
29
  JQUERY: 'Jquery',
30
30
  MOOTOOLS: 'MooTools',
31
31
  QWIK: 'Qwik',
32
+ FLUTTER: 'Flutter',
32
33
  ELECTRON: 'Electron'
33
34
  };
34
35
  function getFrameworks() {
@@ -64,6 +65,7 @@ function getFrameworks() {
64
65
  if (Object.prototype.hasOwnProperty.call(window, 'jQuery')) frameworks.push(FRAMEWORKS.JQUERY);
65
66
  if (Object.prototype.hasOwnProperty.call(window, 'MooTools')) frameworks.push(FRAMEWORKS.MOOTOOLS);
66
67
  if (Object.prototype.hasOwnProperty.call(window, 'qwikevents')) frameworks.push(FRAMEWORKS.QWIK);
68
+ if (Object.hasOwn(window, '_flutter')) frameworks.push(FRAMEWORKS.FLUTTER);
67
69
  if (detectElectron()) frameworks.push(FRAMEWORKS.ELECTRON);
68
70
  } catch (err) {
69
71
  // Possibly not supported
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.evaluateHarvestMetadata = evaluateHarvestMetadata;
7
+ /**
8
+ * Copyright 2020-2025 New Relic, Inc. All rights reserved.
9
+ * SPDX-License-Identifier: Apache-2.0
10
+ */
11
+
12
+ function evaluateHarvestMetadata(pageMetadata) {
13
+ try {
14
+ const supportabilityTags = [];
15
+
16
+ // Report SM like... audit/<feature_name>/<hasReplay|hasTrace|hasError>/<true|false>/<negative|positive>
17
+ const formTag = (...strings) => strings.join('/');
18
+
19
+ // Track if replay/trace/error harvests actually occurred (key only exists when harvested)
20
+ function evaluateTag(feature, flag, hasFlag, hasHarvest) {
21
+ const AUDIT = 'audit';
22
+ if (hasFlag) {
23
+ // False positive: flag true, but no harvest
24
+ if (!hasHarvest) supportabilityTags.push(formTag(AUDIT, feature, flag, 'false', 'positive'));
25
+ // True positive (correct)
26
+ else supportabilityTags.push(formTag(AUDIT, feature, flag, 'true', 'positive'));
27
+ } else {
28
+ // False negative: flag false, but harvest occurred
29
+ if (hasHarvest) supportabilityTags.push(formTag(AUDIT, feature, flag, 'false', 'negative'));
30
+ // True negative (correct)
31
+ else supportabilityTags.push(formTag(AUDIT, feature, flag, 'true', 'negative'));
32
+ }
33
+ }
34
+ if (pageMetadata.page_view_event) {
35
+ evaluateTag('page_view', 'hasReplay', pageMetadata.page_view_event.hasReplay, !!pageMetadata.session_replay);
36
+ evaluateTag('page_view', 'hasTrace', pageMetadata.page_view_event.hasTrace, !!pageMetadata.session_trace);
37
+ }
38
+ if (pageMetadata.session_replay) {
39
+ evaluateTag('session_replay', 'hasError', pageMetadata.session_replay.hasError, !!pageMetadata.jserrors);
40
+ }
41
+ return supportabilityTags;
42
+ } catch (err) {
43
+ return [];
44
+ }
45
+ }
@@ -13,6 +13,7 @@ var _eventListenerOpts = require("../../../common/event-listener/event-listener-
13
13
  var _runtime = require("../../../common/constants/runtime");
14
14
  var _aggregateBase = require("../../utils/aggregate-base");
15
15
  var _iframe = require("../../../common/dom/iframe");
16
+ var _harvestMetadata = require("./harvest-metadata");
16
17
  /**
17
18
  * Copyright 2020-2025 New Relic, Inc. All rights reserved.
18
19
  * SPDX-License-Identifier: Apache-2.0
@@ -26,6 +27,15 @@ class Aggregate extends _aggregateBase.AggregateBase {
26
27
  constructor(agentRef) {
27
28
  super(agentRef, _constants.FEATURE_NAME);
28
29
  this.harvestOpts.aggregatorTypes = ['cm', 'sm']; // the types in EventAggregator this feature cares about
30
+
31
+ /** all the harvest metadata metrics need to be evaluated simulataneously at unload time so just temporarily buffer them and dont make SMs immediately from the data */
32
+ this.harvestMetadata = {};
33
+ this.harvestOpts.beforeUnload = () => {
34
+ (0, _harvestMetadata.evaluateHarvestMetadata)(this.harvestMetadata).forEach(smTag => {
35
+ this.storeSupportabilityMetrics(smTag);
36
+ });
37
+ };
38
+
29
39
  // This feature only harvests once per potential EoL of the page, which is handled by the central harvester.
30
40
 
31
41
  // this must be read/stored synchronously, as the currentScript is removed from the DOM after this script is executed and this lookup will be void
@@ -145,6 +155,17 @@ class Aggregate extends _aggregateBase.AggregateBase {
145
155
  // handleWebsocketEvents(this.storeSupportabilityMetrics.bind(this), tag, ...args)
146
156
  // }, this.featureName, this.ee)
147
157
  // })
158
+
159
+ /** all the harvest metadata metrics need to be evaluated simulataneously at unload time so just temporarily buffer them and dont make SMs immediately from the data */
160
+ (0, _registerHandler.registerHandler)('harvest-metadata', (harvestMetadataObject = {}) => {
161
+ try {
162
+ Object.keys(harvestMetadataObject).forEach(key => {
163
+ Object.assign(this.harvestMetadata[key] ??= {}, harvestMetadataObject[key]);
164
+ });
165
+ } catch (e) {
166
+ // failed to merge harvest metadata... ignore
167
+ }
168
+ }, this.featureName, this.ee);
148
169
  }
149
170
  eachSessionChecks() {
150
171
  if (!_runtime.isBrowserScope) return;
@@ -142,7 +142,7 @@ class Aggregate extends _aggregateBase.AggregateBase {
142
142
  sessionReplayMode: this.mode
143
143
  });
144
144
  } else {
145
- this.initializeRecording(_constants2.MODE.FULL, true);
145
+ this.initializeRecording(_constants2.MODE.FULL, true, _constants.TRIGGERS.SWITCH_TO_FULL);
146
146
  }
147
147
  }
148
148
 
@@ -59,9 +59,13 @@ class Recorder {
59
59
  this.#canRecord = false;
60
60
  this.stopRecording();
61
61
  }, this.srFeatureName, this.ee);
62
- (0, _registerHandler.registerHandler)(RRWEB_DATA_CHANNEL, (event, isCheckout) => {
62
+
63
+ /** If Agg is already drained before importing the recorder (likely deferred API call pattern),
64
+ * registerHandler wont do anything. Just set up the on listener directly */
65
+ const processReplayNode = (event, isCheckout) => {
63
66
  this.audit(event, isCheckout);
64
- }, this.srFeatureName, this.ee);
67
+ };
68
+ if (this.srInstrument.featAggregate?.drained) this.ee.on(RRWEB_DATA_CHANNEL, processReplayNode);else (0, _registerHandler.registerHandler)(RRWEB_DATA_CHANNEL, processReplayNode, this.srFeatureName, this.ee);
65
69
  }
66
70
  get trigger() {
67
71
  return this.triggerHistory[this.triggerHistory.length - 1];
@@ -21,4 +21,5 @@ function setupNoticeErrorAPI(agent) {
21
21
  function noticeError(err, customAttributes, agentRef, targetEntityGuid, timestamp = (0, _now.now)()) {
22
22
  if (typeof err === 'string') err = new Error(err);
23
23
  (0, _handle.handle)('err', [err, timestamp, false, customAttributes, agentRef.runtime.isRecording, undefined, targetEntityGuid], undefined, _features.FEATURE_NAMES.jserrors, agentRef.ee);
24
+ (0, _handle.handle)('uaErr', [], undefined, _features.FEATURE_NAMES.genericEvents, agentRef.ee);
24
25
  }
@@ -59,6 +59,7 @@ function configure(agent, opts = {}, loaderType, forceDrain) {
59
59
  internalTrafficList.push(updatedInit.proxy.assets);
60
60
  }
61
61
  if (updatedInit.proxy.beacon) internalTrafficList.push(updatedInit.proxy.beacon);
62
+ agent.beacons = [...internalTrafficList];
62
63
  (0, _topLevelCallers.setTopLevelCallers)(agent); // no need to set global APIs on newrelic obj more than once
63
64
  (0, _nreum.addToNREUM)('activatedFeatures', _featureFlags.activatedFeatures);
64
65
 
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.298.0";
14
+ export const VERSION = "1.299.0";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.298.0";
14
+ export const VERSION = "1.299.0";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -4,60 +4,76 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Generates a CSS selector path for the given element, if possible
7
+ * Generates a CSS selector path for the given element, if possible.
8
+ * Also gather metadata about the element's nearest fields, and whether there are any links or buttons in the path.
9
+ *
10
+ * Starts with simple cases like window or document and progresses to more complex dom-tree traversals as needed.
11
+ * Will return path: undefined if no other path can be determined.
12
+ *
8
13
  * @param {HTMLElement} elem
9
- * @param {boolean} includeId
10
- * @param {boolean} includeClass
11
- * @returns {string|undefined}
14
+ * @param {Array<string>} [targetFields=[]] specifies which fields to gather from the nearest element in the path
15
+ * @returns {{path: (undefined|string), nearestFields: {}, hasButton: boolean, hasLink: boolean}}
12
16
  */
13
- export const generateSelectorPath = (elem, targetFields = []) => {
14
- if (!elem) return {
17
+ export const analyzeElemPath = (elem, targetFields = []) => {
18
+ const result = {
15
19
  path: undefined,
16
- nearestFields: {}
17
- };
18
- const getNthOfTypeIndex = node => {
19
- try {
20
- let i = 1;
21
- const {
22
- tagName
23
- } = node;
24
- while (node.previousElementSibling) {
25
- if (node.previousElementSibling.tagName === tagName) i++;
26
- node = node.previousElementSibling;
27
- }
28
- return i;
29
- } catch (err) {
30
- // do nothing for now. An invalid child count will make the path selector not return a nth-of-type selector statement
31
- }
20
+ nearestFields: {},
21
+ hasButton: false,
22
+ hasLink: false
32
23
  };
24
+ if (!elem) return result;
25
+ if (elem === window) {
26
+ result.path = 'window';
27
+ return result;
28
+ }
29
+ if (elem === document) {
30
+ result.path = 'document';
31
+ return result;
32
+ }
33
33
  let pathSelector = '';
34
- let index = getNthOfTypeIndex(elem);
35
- const nearestFields = {};
34
+ const index = getNthOfTypeIndex(elem);
36
35
  try {
37
36
  while (elem?.tagName) {
38
- const {
39
- id,
40
- localName
41
- } = elem;
37
+ const tagName = elem.tagName.toLowerCase();
38
+ result.hasLink ||= tagName === 'a';
39
+ result.hasButton ||= tagName === 'button' || tagName === 'input' && elem.type.toLowerCase() === 'button';
42
40
  targetFields.forEach(field => {
43
- nearestFields[nearestAttrName(field)] ||= elem[field]?.baseVal || elem[field];
41
+ result.nearestFields[nearestAttrName(field)] ||= elem[field]?.baseVal || elem[field];
44
42
  });
45
- const selector = [localName, id ? "#".concat(id) : '', pathSelector ? ">".concat(pathSelector) : ''].join('');
46
- pathSelector = selector;
43
+ pathSelector = buildPathSelector(elem, pathSelector);
47
44
  elem = elem.parentNode;
48
45
  }
49
46
  } catch (err) {
50
47
  // do nothing for now
51
48
  }
52
- const path = pathSelector ? index ? "".concat(pathSelector, ":nth-of-type(").concat(index, ")") : pathSelector : undefined;
53
- return {
54
- path,
55
- nearestFields
56
- };
57
- function nearestAttrName(originalFieldName) {
58
- /** preserve original renaming structure for pre-existing field maps */
59
- if (originalFieldName === 'tagName') originalFieldName = 'tag';
60
- if (originalFieldName === 'className') originalFieldName = 'class';
61
- return "nearest".concat(originalFieldName.charAt(0).toUpperCase() + originalFieldName.slice(1));
49
+ result.path = pathSelector ? index ? "".concat(pathSelector, ":nth-of-type(").concat(index, ")") : pathSelector : undefined;
50
+ return result;
51
+ };
52
+ function buildPathSelector(elem, pathSelector) {
53
+ const {
54
+ id,
55
+ localName
56
+ } = elem;
57
+ return [localName, id ? "#".concat(id) : '', pathSelector ? ">".concat(pathSelector) : ''].join('');
58
+ }
59
+ function getNthOfTypeIndex(node) {
60
+ try {
61
+ let i = 1;
62
+ const {
63
+ tagName
64
+ } = node;
65
+ while (node.previousElementSibling) {
66
+ if (node.previousElementSibling.tagName === tagName) i++;
67
+ node = node.previousElementSibling;
68
+ }
69
+ return i;
70
+ } catch (err) {
71
+ // do nothing for now. An invalid child count will make the path selector not return a nth-of-type selector statement
62
72
  }
63
- };
73
+ }
74
+ function nearestAttrName(originalFieldName) {
75
+ /** preserve original renaming structure for pre-existing field maps */
76
+ if (originalFieldName === 'tagName') originalFieldName = 'tag';
77
+ if (originalFieldName === 'className') originalFieldName = 'class';
78
+ return "nearest".concat(originalFieldName.charAt(0).toUpperCase() + originalFieldName.slice(1));
79
+ }
@@ -145,14 +145,16 @@ export function send(agentRef, {
145
145
  }
146
146
  const fullUrl = "".concat(url, "?").concat(baseParams).concat(payloadParams);
147
147
  const gzip = !!qs?.attributes?.includes('gzip');
148
- if (!gzip) {
149
- if (endpoint !== EVENTS) body = stringify(body); // all features going to 'events' endpoint should already be serialized & stringified
150
- // Warn--once per endpoint--if the agent tries to send large payloads
151
- if (body.length > 750000 && (warnings[endpoint] = (warnings[endpoint] || 0) + 1) === 1) warn(28, endpoint);
152
- }
148
+
149
+ // all gzipped data is already in the correct format and needs no transformation
150
+ // all features going to 'events' endpoint should already be serialized & stringified
151
+ let stringBody = gzip || endpoint === EVENTS ? body : stringify(body);
153
152
 
154
153
  // If body is null, undefined, or an empty object or array after stringifying, send an empty string instead.
155
- if (!body || body.length === 0 || body === '{}' || body === '[]') body = '';
154
+ if (!stringBody || stringBody.length === 0 || stringBody === '{}' || stringBody === '[]') stringBody = '';
155
+
156
+ // Warn--once per endpoint--if the agent tries to send large payloads
157
+ if (endpoint !== BLOBS && stringBody.length > 750000 && (warnings[endpoint] = (warnings[endpoint] || 0) + 1) === 1) warn(28, endpoint);
156
158
  const headers = [{
157
159
  key: 'content-type',
158
160
  value: 'text/plain'
@@ -164,7 +166,7 @@ export function send(agentRef, {
164
166
  Following the removal of img-element method. */
165
167
  let result = submitMethod({
166
168
  url: fullUrl,
167
- body,
169
+ body: stringBody,
168
170
  sync: localOpts.isFinalHarvest && isWorkerScope,
169
171
  headers
170
172
  });
@@ -184,6 +186,9 @@ export function send(agentRef, {
184
186
  };
185
187
  if (localOpts.needResponse) cbResult.responseText = this.responseText;
186
188
  cbFinished(cbResult);
189
+
190
+ /** temporary audit of consistency of harvest metadata flags */
191
+ if (!shouldRetry(this.status)) trackHarvestMetadata();
187
192
  }, eventListenerOpts(false));
188
193
  } else if (submitMethod === fetchMethod) {
189
194
  result.then(async function (response) {
@@ -198,8 +203,33 @@ export function send(agentRef, {
198
203
  };
199
204
  if (localOpts.needResponse) cbResult.responseText = await response.text();
200
205
  cbFinished(cbResult);
206
+ /** temporary audit of consistency of harvest metadata flags */
207
+ if (!shouldRetry(status)) trackHarvestMetadata();
201
208
  });
202
209
  }
210
+ function trackHarvestMetadata() {
211
+ try {
212
+ if (featureName === FEATURE_NAMES.jserrors && !body?.err) return;
213
+ const hasReplay = baseParams.includes('hr=1');
214
+ const hasTrace = baseParams.includes('ht=1');
215
+ const hasError = qs?.attributes?.includes('hasError=true');
216
+ handle('harvest-metadata', [{
217
+ [featureName]: {
218
+ ...(hasReplay && {
219
+ hasReplay
220
+ }),
221
+ ...(hasTrace && {
222
+ hasTrace
223
+ }),
224
+ ...(hasError && {
225
+ hasError
226
+ })
227
+ }
228
+ }], undefined, FEATURE_NAMES.metrics, agentRef.ee);
229
+ } catch (err) {
230
+ // do nothing
231
+ }
232
+ }
203
233
  }
204
234
  dispatchGlobalEvent({
205
235
  agentIdentifier: agentRef.agentIdentifier,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Copyright 2020-2025 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { globalScope } from '../constants/runtime';
6
+ import { gosNREUMOriginals } from '../window/nreum';
7
+
8
+ /**
9
+ * Extracts a URL from various target types.
10
+ * @param {string|Request|URL} target - The target to extract the URL from. It can be a string, a Fetch Request object, or a URL object.
11
+ * @returns {string|undefined} The extracted URL as a string, or undefined if the target type is not supported.
12
+ */
13
+ export function extractUrl(target) {
14
+ if (typeof target === 'string') return target;else if (target instanceof gosNREUMOriginals().o.REQ) return target.url;else if (globalScope?.URL && target instanceof URL) return target.href;
15
+ }
@@ -19,6 +19,7 @@ import { FEATURE_NAMES } from '../../../loaders/features/features';
19
19
  import { SUPPORTABILITY_METRIC } from '../../metrics/constants';
20
20
  import { now } from '../../../common/timing/now';
21
21
  import { hasUndefinedHostname } from '../../../common/deny-list/deny-list';
22
+ import { extractUrl } from '../../../common/url/extract-url';
22
23
  var handlers = ['load', 'error', 'abort', 'timeout'];
23
24
  var handlersLen = handlers.length;
24
25
  var origRequest = gosNREUMOriginals().o.REQ;
@@ -290,15 +291,7 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
290
291
  if (fetchArguments.length >= 2) this.opts = fetchArguments[1];
291
292
  var opts = this.opts || {};
292
293
  var target = this.target;
293
- var url;
294
- if (typeof target === 'string') {
295
- url = target;
296
- } else if (typeof target === 'object' && target instanceof origRequest) {
297
- url = target.url;
298
- } else if (globalScope?.URL && typeof target === 'object' && target instanceof URL) {
299
- url = target.href;
300
- }
301
- addUrl(this, url);
294
+ addUrl(this, extractUrl(target));
302
295
  var method = ('' + (target && target instanceof origRequest && target.method || opts.method || 'GET')).toUpperCase();
303
296
  this.params.method = method;
304
297
  this.body = opts.body;
@@ -16,6 +16,7 @@ import { isIFrameWindow } from '../../../common/dom/iframe';
16
16
  import { isPureObject } from '../../../common/util/type-check';
17
17
  export class Aggregate extends AggregateBase {
18
18
  static featureName = FEATURE_NAME;
19
+ #userActionAggregator;
19
20
  constructor(agentRef) {
20
21
  super(agentRef, FEATURE_NAME);
21
22
  this.referrerUrl = isBrowserScope && document.referrer ? cleanURL(document.referrer) : undefined;
@@ -25,7 +26,7 @@ export class Aggregate extends AggregateBase {
25
26
  this.deregisterDrain();
26
27
  return;
27
28
  }
28
- this.trackSupportabilityMetrics();
29
+ this.#trackSupportabilityMetrics();
29
30
  registerHandler('api-recordCustomEvent', (timestamp, eventType, attributes) => {
30
31
  if (RESERVED_EVENT_TYPES.includes(eventType)) return warn(46);
31
32
  this.addEvent({
@@ -53,8 +54,8 @@ export class Aggregate extends AggregateBase {
53
54
  }
54
55
  let addUserAction = () => {/** no-op */};
55
56
  if (isBrowserScope && agentRef.init.user_actions.enabled) {
56
- this.userActionAggregator = new UserActionsAggregator();
57
- this.harvestOpts.beforeUnload = () => addUserAction?.(this.userActionAggregator.aggregationEvent);
57
+ this.#userActionAggregator = new UserActionsAggregator(agentRef.init.feature_flags.includes('user_frustrations'));
58
+ this.harvestOpts.beforeUnload = () => addUserAction?.(this.#userActionAggregator.aggregationEvent);
58
59
  addUserAction = aggregatedUserAction => {
59
60
  try {
60
61
  /** The aggregator process only returns an event when it is "done" aggregating -
@@ -65,7 +66,7 @@ export class Aggregate extends AggregateBase {
65
66
  timeStamp,
66
67
  type
67
68
  } = aggregatedUserAction.event;
68
- this.addEvent({
69
+ const userActionEvent = {
69
70
  eventType: 'UserAction',
70
71
  timestamp: this.toEpoch(timeStamp),
71
72
  action: type,
@@ -83,8 +84,16 @@ export class Aggregate extends AggregateBase {
83
84
  if (canTrustTargetAttribute(field)) acc[targetAttrName(field)] = String(target[field]).trim().slice(0, 128);
84
85
  return acc;
85
86
  }, {}),
86
- ...aggregatedUserAction.nearestTargetFields
87
- });
87
+ ...aggregatedUserAction.nearestTargetFields,
88
+ ...(aggregatedUserAction.deadClick && {
89
+ deadClick: true
90
+ }),
91
+ ...(aggregatedUserAction.errorClick && {
92
+ errorClick: true
93
+ })
94
+ };
95
+ this.addEvent(userActionEvent);
96
+ this.#trackUserActionSM(userActionEvent);
88
97
 
89
98
  /**
90
99
  * Returns the original target field name with `target` prepended and camelCased
@@ -114,8 +123,15 @@ export class Aggregate extends AggregateBase {
114
123
  };
115
124
  registerHandler('ua', evt => {
116
125
  /** the processor will return the previously aggregated event if it has been completed by processing the current event */
117
- addUserAction(this.userActionAggregator.process(evt, this.agentRef.init.user_actions.elementAttributes));
126
+ addUserAction(this.#userActionAggregator.process(evt, this.agentRef.init.user_actions.elementAttributes));
127
+ }, this.featureName, this.ee);
128
+ registerHandler('navChange', () => {
129
+ this.#userActionAggregator.isLiveClick();
118
130
  }, this.featureName, this.ee);
131
+ registerHandler('uaXhr', () => {
132
+ this.#userActionAggregator.isLiveClick();
133
+ }, this.featureName, this.ee);
134
+ registerHandler('uaErr', () => this.#userActionAggregator.markAsErrorClick(), this.featureName, this.ee);
119
135
  }
120
136
 
121
137
  /**
@@ -291,7 +307,7 @@ export class Aggregate extends AggregateBase {
291
307
  toEpoch(timestamp) {
292
308
  return Math.floor(this.agentRef.runtime.timeKeeper.correctRelativeTimestamp(timestamp));
293
309
  }
294
- trackSupportabilityMetrics() {
310
+ #trackSupportabilityMetrics() {
295
311
  /** track usage SMs to improve these experimental features */
296
312
  const configPerfTag = 'Config/Performance/';
297
313
  if (this.agentRef.init.performance.capture_marks) this.reportSupportabilityMetric(configPerfTag + 'CaptureMarks/Enabled');
@@ -301,4 +317,9 @@ export class Aggregate extends AggregateBase {
301
317
  if (this.agentRef.init.performance.resources.first_party_domains?.length !== 0) this.reportSupportabilityMetric(configPerfTag + 'Resources/FirstPartyDomains/Changed');
302
318
  if (this.agentRef.init.performance.resources.ignore_newrelic === false) this.reportSupportabilityMetric(configPerfTag + 'Resources/IgnoreNewrelic/Changed');
303
319
  }
320
+ #trackUserActionSM(ua) {
321
+ if (ua.rageClick) this.reportSupportabilityMetric('UserAction/RageClick/Seen');
322
+ if (ua.deadClick) this.reportSupportabilityMetric('UserAction/DeadClick/Seen');
323
+ if (ua.errorClick) this.reportSupportabilityMetric('UserAction/ErrorClick/Seen');
324
+ }
304
325
  }
@@ -5,15 +5,17 @@
5
5
  import { RAGE_CLICK_THRESHOLD_EVENTS, RAGE_CLICK_THRESHOLD_MS } from '../../constants';
6
6
  import { cleanURL } from '../../../../common/url/clean-url';
7
7
  export class AggregatedUserAction {
8
- constructor(evt, selectorPath, nearestTargetFields) {
8
+ constructor(evt, selectorInfo) {
9
9
  this.event = evt;
10
10
  this.count = 1;
11
11
  this.originMs = Math.floor(evt.timeStamp);
12
12
  this.relativeMs = [0];
13
- this.selectorPath = selectorPath;
13
+ this.selectorPath = selectorInfo.path;
14
14
  this.rageClick = undefined;
15
- this.nearestTargetFields = nearestTargetFields;
15
+ this.nearestTargetFields = selectorInfo.nearestFields;
16
16
  this.currentUrl = cleanURL('' + location);
17
+ this.deadClick = false;
18
+ this.errorClick = false;
17
19
  }
18
20
 
19
21
  /**
@@ -2,13 +2,25 @@
2
2
  * Copyright 2020-2025 New Relic, Inc. All rights reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
- import { generateSelectorPath } from '../../../../common/dom/selector-path';
6
- import { OBSERVED_WINDOW_EVENTS } from '../../constants';
5
+ import { analyzeElemPath } from '../../../../common/dom/selector-path';
6
+ import { FRUSTRATION_TIMEOUT_MS, OBSERVED_WINDOW_EVENTS } from '../../constants';
7
7
  import { AggregatedUserAction } from './aggregated-user-action';
8
+ import { Timer } from '../../../../common/timer/timer';
9
+ import { gosNREUMOriginals } from '../../../../common/window/nreum';
8
10
  export class UserActionsAggregator {
9
11
  /** @type {AggregatedUserAction=} */
10
12
  #aggregationEvent = undefined;
11
13
  #aggregationKey = '';
14
+ #ufEnabled = false;
15
+ #deadClickTimer = undefined;
16
+ #domObserver = undefined;
17
+ #errorClickTimer = undefined;
18
+ constructor(userFrustrationsEnabled) {
19
+ if (userFrustrationsEnabled && gosNREUMOriginals().o.MO) {
20
+ this.#domObserver = new MutationObserver(this.isLiveClick.bind(this));
21
+ this.#ufEnabled = true;
22
+ }
23
+ }
12
24
  get aggregationEvent() {
13
25
  // if this is accessed externally, we need to be done aggregating on it
14
26
  // to prevent potential mutability and duplication issues, so the state is cleared upon returning.
@@ -26,49 +38,75 @@ export class UserActionsAggregator {
26
38
  */
27
39
  process(evt, targetFields) {
28
40
  if (!evt) return;
29
- const {
30
- selectorPath,
31
- nearestTargetFields
32
- } = getSelectorPath(evt, targetFields);
33
- const aggregationKey = getAggregationKey(evt, selectorPath);
41
+ const targetElem = OBSERVED_WINDOW_EVENTS.includes(evt.type) ? window : evt.target;
42
+ const selectorInfo = analyzeElemPath(targetElem, targetFields);
43
+
44
+ // if selectorInfo.path is undefined, aggregation will be skipped for this event
45
+ const aggregationKey = getAggregationKey(evt, selectorInfo.path);
34
46
  if (!!aggregationKey && aggregationKey === this.#aggregationKey) {
35
47
  // an aggregation exists already, so lets just continue to increment
36
48
  this.#aggregationEvent.aggregate(evt);
37
49
  } else {
38
50
  // return the prev existing one (if there is one)
39
51
  const finishedEvent = this.#aggregationEvent;
40
- // then set as this new event aggregation
52
+ if (this.#ufEnabled) {
53
+ this.#deadClickCleanup();
54
+ this.#errorClickCleanup();
55
+ }
56
+
57
+ // then start new event aggregation
41
58
  this.#aggregationKey = aggregationKey;
42
- this.#aggregationEvent = new AggregatedUserAction(evt, selectorPath, nearestTargetFields);
59
+ this.#aggregationEvent = new AggregatedUserAction(evt, selectorInfo);
60
+ if (this.#ufEnabled && evt.type === 'click' && (selectorInfo.hasButton || selectorInfo.hasLink)) {
61
+ this.#deadClickSetup(this.#aggregationEvent);
62
+ this.#errorClickSetup();
63
+ }
43
64
  return finishedEvent;
44
65
  }
45
66
  }
46
- }
47
-
48
- /**
49
- * Generates a selector path for the event, starting with simple cases like window or document and getting more complex for dom-tree traversals as needed.
50
- * Will return a random selector path value if no other path can be determined, to force the aggregator to skip aggregation for this event.
51
- * @param {Event} evt
52
- * @returns {string}
53
- */
54
- function getSelectorPath(evt, targetFields) {
55
- let selectorPath;
56
- let nearestTargetFields = {};
57
- if (OBSERVED_WINDOW_EVENTS.includes(evt.type) || evt.target === window) selectorPath = 'window';else if (evt.target === document) selectorPath = 'document';
58
- // if still no selectorPath, generate one from target tree that includes elem ids
59
- else {
60
- const {
61
- path,
62
- nearestFields
63
- } = generateSelectorPath(evt.target, targetFields);
64
- selectorPath = path;
65
- nearestTargetFields = nearestFields;
67
+ markAsErrorClick() {
68
+ if (this.#aggregationEvent && this.#errorClickTimer) {
69
+ this.#aggregationEvent.errorClick = true;
70
+ this.#errorClickCleanup();
71
+ }
72
+ }
73
+ #errorClickSetup() {
74
+ this.#errorClickTimer = new Timer({
75
+ onEnd: () => {
76
+ this.#errorClickCleanup();
77
+ }
78
+ }, FRUSTRATION_TIMEOUT_MS);
79
+ }
80
+ #errorClickCleanup() {
81
+ this.#errorClickTimer?.clear();
82
+ this.#errorClickTimer = undefined;
83
+ }
84
+ #deadClickSetup(userAction) {
85
+ if (this.#isEvaluatingDeadClick() || !this.#domObserver) return;
86
+ this.#domObserver.observe(document, {
87
+ attributes: true,
88
+ characterData: true,
89
+ childList: true,
90
+ subtree: true
91
+ });
92
+ this.#deadClickTimer = new Timer({
93
+ onEnd: () => {
94
+ userAction.deadClick = true;
95
+ this.#deadClickCleanup();
96
+ }
97
+ }, FRUSTRATION_TIMEOUT_MS);
98
+ }
99
+ #deadClickCleanup() {
100
+ this.#domObserver?.disconnect();
101
+ this.#deadClickTimer?.clear();
102
+ this.#deadClickTimer = undefined;
103
+ }
104
+ #isEvaluatingDeadClick() {
105
+ return this.#deadClickTimer !== undefined;
106
+ }
107
+ isLiveClick() {
108
+ if (this.#isEvaluatingDeadClick()) this.#deadClickCleanup();
66
109
  }
67
- // if STILL no selectorPath, it will return undefined which will skip aggregation for this event
68
- return {
69
- selectorPath,
70
- nearestTargetFields
71
- };
72
110
  }
73
111
 
74
112
  /**