@newrelic/browser-agent 1.316.0-rc.6 → 1.317.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 (64) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cjs/common/config/init-types.js +1 -0
  3. package/dist/cjs/common/config/init.js +6 -4
  4. package/dist/cjs/common/constants/env.cdn.js +1 -1
  5. package/dist/cjs/common/constants/env.npm.js +1 -1
  6. package/dist/cjs/common/payloads/payloads.js +127 -0
  7. package/dist/cjs/common/serialize/bel-serializer.js +2 -1
  8. package/dist/cjs/common/util/data-size.js +2 -2
  9. package/dist/cjs/common/wrap/wrap-xhr.js +1 -1
  10. package/dist/cjs/features/ajax/aggregate/gql.js +40 -1
  11. package/dist/cjs/features/ajax/aggregate/index.js +46 -13
  12. package/dist/cjs/features/ajax/constants.js +6 -1
  13. package/dist/cjs/features/ajax/instrument/index.js +105 -17
  14. package/dist/cjs/features/jserrors/aggregate/format-stack-trace.js +2 -2
  15. package/dist/cjs/features/jserrors/aggregate/index.js +1 -1
  16. package/dist/cjs/features/soft_navigations/aggregate/ajax-node.js +35 -4
  17. package/dist/cjs/features/soft_navigations/aggregate/initial-page-load-interaction.js +1 -1
  18. package/dist/esm/common/config/init-types.js +1 -0
  19. package/dist/esm/common/config/init.js +3 -1
  20. package/dist/esm/common/constants/env.cdn.js +1 -1
  21. package/dist/esm/common/constants/env.npm.js +1 -1
  22. package/dist/esm/common/payloads/payloads.js +118 -0
  23. package/dist/esm/common/serialize/bel-serializer.js +2 -1
  24. package/dist/esm/common/util/data-size.js +2 -2
  25. package/dist/esm/common/wrap/wrap-xhr.js +1 -1
  26. package/dist/esm/features/ajax/aggregate/gql.js +39 -1
  27. package/dist/esm/features/ajax/aggregate/index.js +47 -14
  28. package/dist/esm/features/ajax/constants.js +5 -0
  29. package/dist/esm/features/ajax/instrument/index.js +106 -18
  30. package/dist/esm/features/jserrors/aggregate/format-stack-trace.js +2 -2
  31. package/dist/esm/features/jserrors/aggregate/index.js +1 -1
  32. package/dist/esm/features/soft_navigations/aggregate/ajax-node.js +35 -4
  33. package/dist/esm/features/soft_navigations/aggregate/initial-page-load-interaction.js +1 -1
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/dist/types/common/config/init-types.d.ts +4 -0
  36. package/dist/types/common/config/init-types.d.ts.map +1 -1
  37. package/dist/types/common/config/init.d.ts.map +1 -1
  38. package/dist/types/common/payloads/payloads.d.ts +42 -0
  39. package/dist/types/common/payloads/payloads.d.ts.map +1 -0
  40. package/dist/types/common/serialize/bel-serializer.d.ts +1 -1
  41. package/dist/types/common/serialize/bel-serializer.d.ts.map +1 -1
  42. package/dist/types/features/ajax/aggregate/gql.d.ts +8 -0
  43. package/dist/types/features/ajax/aggregate/gql.d.ts.map +1 -1
  44. package/dist/types/features/ajax/aggregate/index.d.ts.map +1 -1
  45. package/dist/types/features/ajax/constants.d.ts +5 -0
  46. package/dist/types/features/ajax/constants.d.ts.map +1 -1
  47. package/dist/types/features/ajax/instrument/index.d.ts.map +1 -1
  48. package/dist/types/features/soft_navigations/aggregate/ajax-node.d.ts +5 -0
  49. package/dist/types/features/soft_navigations/aggregate/ajax-node.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/common/config/init-types.js +1 -0
  52. package/src/common/config/init.js +2 -1
  53. package/src/common/payloads/payloads.js +135 -0
  54. package/src/common/serialize/bel-serializer.js +2 -1
  55. package/src/common/util/data-size.js +2 -2
  56. package/src/common/wrap/wrap-xhr.js +1 -1
  57. package/src/features/ajax/aggregate/gql.js +42 -1
  58. package/src/features/ajax/aggregate/index.js +38 -13
  59. package/src/features/ajax/constants.js +5 -1
  60. package/src/features/ajax/instrument/index.js +114 -18
  61. package/src/features/jserrors/aggregate/format-stack-trace.js +3 -3
  62. package/src/features/jserrors/aggregate/index.js +1 -1
  63. package/src/features/soft_navigations/aggregate/ajax-node.js +25 -4
  64. package/src/features/soft_navigations/aggregate/initial-page-load-interaction.js +1 -1
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.AjaxNode = void 0;
7
7
  var _belSerializer = require("../../../common/serialize/bel-serializer");
8
+ var _payloads = require("../../../common/payloads/payloads");
8
9
  var _constants = require("../../ajax/constants");
9
10
  var _constants2 = require("../constants");
10
11
  var _belNode = require("./bel-node");
@@ -31,6 +32,12 @@ class AjaxNode extends _belNode.BelNode {
31
32
  this.targetAttributes = ajaxEvent.targetAttributes;
32
33
  this[_constants.AJAX_ID] = ajaxEvent[_constants.AJAX_ID]; // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
33
34
 
35
+ // optional payload metadata attributes
36
+ this.requestBody = ajaxEvent.requestBody;
37
+ this.requestHeaders = ajaxEvent.requestHeaders;
38
+ this.requestQuery = ajaxEvent.requestQuery;
39
+ this.responseBody = ajaxEvent.responseBody;
40
+ this.responseHeaders = ajaxEvent.responseHeaders;
34
41
  this.start = ajaxEvent.startTime;
35
42
  this.end = ajaxEvent.endTime;
36
43
  if (ajaxContext?.latestLongtaskEnd) {
@@ -39,7 +46,10 @@ class AjaxNode extends _belNode.BelNode {
39
46
  } else this.callbackEnd = this.end; // if no long task was observed, callbackEnd is the same as end
40
47
  }
41
48
  serialize(parentStartTimestamp, agentRef, ajaxObfuscator) {
42
- const addString = (0, _belSerializer.getAddStringContext)(ajaxObfuscator);
49
+ const {
50
+ addString,
51
+ addStringWithTruncation
52
+ } = (0, _payloads.createStringAdders)(_belSerializer.getAddStringContext, ajaxObfuscator);
43
53
  const nodeList = [];
44
54
 
45
55
  // IMPORTANT: The order in which addString is called matters and correlates to the order in which string shows up in the harvest payload. Do not re-order the following code.
@@ -54,11 +64,32 @@ class AjaxNode extends _belNode.BelNode {
54
64
  (0, _belSerializer.numeric)(this.callbackDuration),
55
65
  // not relative
56
66
  addString(this.method), (0, _belSerializer.numeric)(this.status), addString(this.domain), addString(this.path), (0, _belSerializer.numeric)(this.txSize), (0, _belSerializer.numeric)(this.rxSize), this.requestedWith, addString(this.nodeId), (0, _belSerializer.nullable)(this.spanId, addString, true) + (0, _belSerializer.nullable)(this.traceId, addString, true) + (0, _belSerializer.nullable)(this.spanTimestamp, _belSerializer.numeric)];
57
- let allAttachedNodes = (0, _belSerializer.addCustomAttributes)({
58
- ...(this.gql || {}),
67
+ // Regular attributes: obfuscate only
68
+ const regularAttrs = (0, _belSerializer.addCustomAttributes)({
69
+ [_constants.AJAX_ID]: this[_constants.AJAX_ID],
59
70
  ...(this.targetAttributes || {}),
60
- [_constants.AJAX_ID]: this[_constants.AJAX_ID]
71
+ ...(this.gql || {})
61
72
  }, addString);
73
+
74
+ // Payload attributes: obfuscate then truncate
75
+ const payloadAttrs = (0, _belSerializer.addCustomAttributes)({
76
+ ...(this.requestBody ? {
77
+ requestBody: this.requestBody
78
+ } : {}),
79
+ ...(this.requestHeaders ? {
80
+ requestHeaders: this.requestHeaders
81
+ } : {}),
82
+ ...(this.requestQuery ? {
83
+ requestQuery: this.requestQuery
84
+ } : {}),
85
+ ...(this.responseBody ? {
86
+ responseBody: this.responseBody
87
+ } : {}),
88
+ ...(this.responseHeaders ? {
89
+ responseHeaders: this.responseHeaders
90
+ } : {})
91
+ }, addStringWithTruncation);
92
+ let allAttachedNodes = [...regularAttrs, ...payloadAttrs];
62
93
  this.children.forEach(node => allAttachedNodes.push(node.serialize())); // no children is expected under ajax nodes at this time
63
94
 
64
95
  fields[1] = (0, _belSerializer.numeric)(allAttachedNodes.length);
@@ -11,7 +11,7 @@ var _firstPaint = require("../../../common/vitals/first-paint");
11
11
  var _firstContentfulPaint = require("../../../common/vitals/first-contentful-paint");
12
12
  var _constants = require("../constants");
13
13
  /**
14
- * Copyright 2020-2025 New Relic, Inc. All rights reserved.
14
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
@@ -11,6 +11,7 @@
11
11
  * @property {boolean} [ajax.block_internal] - If true, agent requests going to harvest endpoint are treated as on deny list. In other words, agent will not self-report AJAX.
12
12
  * @property {boolean} [ajax.enabled] - Turn on/off the ajax feature (on by default).
13
13
  * @property {boolean} [ajax.autoStart] - If true, the agent will automatically start the ajax feature. Otherwise, it will be in a deferred state until the `start` API method is called.
14
+ * @property {('none'|'failures'|'all')} [ajax.capture_payloads] - Controls when AJAX request/response payloads are captured. 'none' = never capture, 'failures' = capture only on errors (4xx, 5xx, network errors, GraphQL errors), 'all' = always capture.
14
15
  * @property {Object} [api]
15
16
  * @property {Object} [api.register]
16
17
  * @property {boolean} [api.register.enabled] - If true, the agent will allow registered children to be sent to the server.
@@ -3,6 +3,7 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
  import { FEATURE_FLAGS } from '../../features/generic_events/constants';
6
+ import { CAPTURE_PAYLOAD_SETTINGS } from '../../features/ajax/constants';
6
7
  import { isValidSelector } from '../dom/query-selector';
7
8
  import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS } from '../session/constants';
8
9
  import { warn } from '../util/console';
@@ -51,7 +52,8 @@ const InitModelFn = () => {
51
52
  deny_list: undefined,
52
53
  block_internal: true,
53
54
  enabled: true,
54
- autoStart: true
55
+ autoStart: true,
56
+ capture_payloads: CAPTURE_PAYLOAD_SETTINGS.NONE
55
57
  },
56
58
  api: {
57
59
  register: {
@@ -11,7 +11,7 @@
11
11
  /**
12
12
  * Exposes the version of the agent
13
13
  */
14
- export const VERSION = "1.316.0-rc.6";
14
+ export const VERSION = "1.317.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.316.0-rc.6";
14
+ export const VERSION = "1.317.0";
15
15
 
16
16
  /**
17
17
  * Exposes the build type of the agent
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Copyright 2020-2026 New Relic, Inc. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { stringify } from '../util/stringify';
7
+ import { CAPTURE_PAYLOAD_SETTINGS } from '../../features/ajax/constants';
8
+
9
+ /**
10
+ * Determines whether payload data should be captured based on the capture mode setting,
11
+ * HTTP status code, and GraphQL error status.
12
+ * @param {string} captureMode - The capture mode setting ('none', 'all', or 'failures')
13
+ * @param {number} statusCode - The HTTP status code
14
+ * @param {boolean} hasGQLErrors - Whether the response contains GraphQL errors
15
+ * @returns {boolean} True if payload should be captured
16
+ */
17
+ export function canCapturePayload(captureMode, statusCode, hasGQLErrors) {
18
+ if (captureMode === CAPTURE_PAYLOAD_SETTINGS.ALL) return true;
19
+ if (!captureMode || captureMode === CAPTURE_PAYLOAD_SETTINGS.NONE) return false;
20
+
21
+ // Default "failures" mode
22
+ return statusCode === 0 || statusCode >= 400 || hasGQLErrors === true;
23
+ }
24
+
25
+ /**
26
+ * Parses a query string into an object of key-value pairs.
27
+ * @param {string} search a query string starting with "?" or without (ex. new URL(...).search)
28
+ * @returns {Object|undefined} Parsed query parameters as key-value pairs. Returns undefined if no valid parameters are found.
29
+ */
30
+ export function parseQueryString(search) {
31
+ if (!search || search.length === 0) return;
32
+ const queryParams = {};
33
+ try {
34
+ const searchParams = new URLSearchParams(search);
35
+ searchParams.forEach(function (value, key) {
36
+ queryParams[key] = value;
37
+ });
38
+ } catch (e) {
39
+ // Fallback for environments without URLSearchParams
40
+ }
41
+ return queryParams;
42
+ }
43
+
44
+ /**
45
+ * Determines if the given content type is likely to be human-readable (text-based).
46
+ * @param {Object} headers - The headers object containing content-type
47
+ * @param {*} data - The data to check
48
+ * @returns {boolean} True if the content type is human-readable (text-based)
49
+ */
50
+ export function isLikelyHumanReadable(headers, data) {
51
+ if (!headers) return typeof data === 'string';
52
+ var contentType = headers['content-type'];
53
+ if (!contentType) return typeof data === 'string';
54
+ // Normalize to lowercase and extract the mime type (ignore charset, etc.)
55
+ var mimeType = contentType.toLowerCase().split(';')[0].trim();
56
+
57
+ // Check for text/* types
58
+ if (mimeType.indexOf('text/') === 0) return true;
59
+
60
+ // Check for specific application/* types
61
+ var readableAppTypes = ['/json', '/xml', '/xhtml+xml', '/ld+json', '/yaml', '/x-www-form-urlencoded'];
62
+ for (var i = 0; i < readableAppTypes.length; i++) {
63
+ if (mimeType === 'application' + readableAppTypes[i]) return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Truncates a string to ensure its UTF-8 byte length does not exceed 4092 bytes. If truncation is necessary,
70
+ * the string is cut off at a character boundary to avoid breaking multi-byte characters and " ..." is appended to indicate truncation.
71
+ * If not a string, it is first converted to a string using JSON.stringify.
72
+ * @param {*} data The data to truncate.
73
+ * @returns {string}
74
+ */
75
+ export function truncateAsString(data) {
76
+ if (!data) return data;
77
+ try {
78
+ if (typeof data !== 'string') data = stringify(data);
79
+ let bytes = 0;
80
+ let i = 0;
81
+ let needsEllipsis = false;
82
+ while (i < data.length) {
83
+ const c = data.charCodeAt(i);
84
+ const charBytes = c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0xD800 || c >= 0xE000 ? 3 : 4;
85
+ if (bytes + charBytes > 4092) {
86
+ needsEllipsis = true;
87
+ break;
88
+ }
89
+ bytes += charBytes;
90
+ i += charBytes === 4 ? 2 : 1;
91
+ }
92
+ return data.slice(0, i) + (needsEllipsis ? ' ...' : '');
93
+ } catch (e) {
94
+ return data;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Creates string adder functions for BEL serialization with obfuscation and optional truncation.
100
+ * This ensures a single string table is used while providing separate handling for regular vs payload attributes.
101
+ * @param {Function} getAddStringContext - Function that creates a new string table context
102
+ * @param {Object} obfuscator - Optional obfuscator instance for string obfuscation
103
+ * @returns {{addString: Function, addStringWithTruncation: Function}} Object containing both string adder functions
104
+ */
105
+ export function createStringAdders(getAddStringContext, obfuscator) {
106
+ const addStringRaw = getAddStringContext();
107
+ const processString = (str, shouldTruncate) => {
108
+ if (typeof str === 'undefined' || str === '') return addStringRaw(str);
109
+ if (typeof str !== 'string') str = stringify(str);
110
+ const obfuscated = obfuscator?.obfuscateString(str) ?? str;
111
+ const processed = shouldTruncate ? truncateAsString(obfuscated) : obfuscated;
112
+ return addStringRaw(processed);
113
+ };
114
+ return {
115
+ addString: str => processString(str, false),
116
+ addStringWithTruncation: str => processString(str, true)
117
+ };
118
+ }
@@ -15,13 +15,14 @@ export function numeric(n, noDefault) {
15
15
  }
16
16
  return n === undefined || n === 0 ? '' : Math.floor(n).toString(36);
17
17
  }
18
- export function getAddStringContext(obfuscator) {
18
+ export function getAddStringContext(obfuscator, truncator) {
19
19
  let stringTableIdx = 0;
20
20
  const stringTable = Object.prototype.hasOwnProperty.call(Object, 'create') ? Object.create(null) : {};
21
21
  return addString;
22
22
  function addString(str) {
23
23
  if (typeof str === 'undefined' || str === '') return '';
24
24
  str = obfuscator?.obfuscateString(String(str)) ?? String(str);
25
+ str = truncator?.(str) ?? str;
25
26
  if (hasOwnProp.call(stringTable, str)) {
26
27
  return numeric(stringTable[str], true);
27
28
  } else {
@@ -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
 
@@ -12,7 +12,7 @@ import { stringify } from './stringify';
12
12
  * @returns {(number|undefined)} - The size of the data or undefined if size cannot be determined.
13
13
  */
14
14
  export function dataSize(data) {
15
- if (typeof data === 'string' && data.length) return data.length;
15
+ if (typeof data === 'string') return data.length;
16
16
  if (typeof data !== 'object') return undefined;
17
17
  // eslint-disable-next-line
18
18
  if (typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer && data.byteLength) return data.byteLength;
@@ -16,7 +16,7 @@ import { globalScope } from '../constants/runtime';
16
16
  import { warn } from '../util/console';
17
17
  import { findTargetsFromStackTrace } from '../v2/utils';
18
18
  const wrapped = {};
19
- const XHR_PROPS = ['open', 'send']; // these are the specific funcs being wrapped on all XMLHttpRequests(.prototype)
19
+ const XHR_PROPS = ['open', 'send', 'setRequestHeader']; // these are the specific funcs being wrapped on all XMLHttpRequests(.prototype)
20
20
 
21
21
  /**
22
22
  * 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';
@@ -89,4 +89,42 @@ function parseGQLQueryString(gqlQueryString) {
89
89
  }
90
90
  function validateGQLObject(obj) {
91
91
  return !(typeof obj !== 'object' || !obj.query || typeof obj.query !== 'string');
92
+ }
93
+
94
+ /**
95
+ * Checks if a response object has valid GraphQL errors.
96
+ * @param {object} response - A single GraphQL response object
97
+ * @returns {boolean} True if the response has valid errors
98
+ */
99
+ function hasValidGQLErrors(response) {
100
+ return Array.isArray(response?.errors) && response.errors.some(err => err && typeof err === 'object' && typeof err.message === 'string');
101
+ }
102
+
103
+ /**
104
+ * Checks if a response body contains GraphQL errors according to the GraphQL spec.
105
+ * A valid GraphQL error response contains an "errors" array with at least one error object.
106
+ * Supports both single and batched GraphQL responses.
107
+ * @param {string|object|array} [responseBody] The response body to check
108
+ * @returns {boolean} True if the response contains GraphQL errors
109
+ */
110
+ export function hasGQLErrors(responseBody) {
111
+ if (!responseBody) return false;
112
+ try {
113
+ let parsed = responseBody;
114
+
115
+ // Parse string to object if needed
116
+ if (typeof responseBody === 'string') {
117
+ parsed = JSON.parse(responseBody);
118
+ }
119
+
120
+ // Handle batched GraphQL responses (array of response objects)
121
+ if (Array.isArray(parsed)) {
122
+ return parsed.some(hasValidGQLErrors);
123
+ }
124
+
125
+ // Handle single GraphQL response
126
+ return hasValidGQLErrors(parsed);
127
+ } catch (err) {
128
+ return false;
129
+ }
92
130
  }
@@ -6,12 +6,13 @@ 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';
15
16
  import { Obfuscator } from '../../../common/util/obfuscate';
16
17
  import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/v2/utils';
17
18
  import { EVENT_TYPES } from '../../../common/constants/events';
@@ -89,17 +90,26 @@ export class Aggregate extends AggregateBase {
89
90
  callbackDuration: metrics.cbTime,
90
91
  [AJAX_ID]: generateUuid() // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
91
92
  };
93
+ event.gql = params.gql = parseGQL({
94
+ body: ctx.requestBody,
95
+ query: ctx.parsedOrigin?.search
96
+ });
97
+ if (event.gql) event.gql.operationHasErrors = params.gql.operationHasErrors = hasGQLErrors(ctx.responseBody);
98
+ const capturePayloadSetting = this.agentRef.init.ajax.capture_payloads;
99
+ const shouldCapturePayload = canCapturePayload(capturePayloadSetting, params.status, event.gql?.operationHasErrors);
100
+ if (shouldCapturePayload) {
101
+ // Store raw data; obfuscation and truncation will happen in the serializer
102
+ params.requestQuery = event.requestQuery = parseQueryString(ctx.parsedOrigin?.search);
103
+ params.requestHeaders = event.requestHeaders = ctx.requestHeaders;
104
+ params.responseHeaders = event.responseHeaders = ctx.responseHeaders;
105
+ if (isLikelyHumanReadable(ctx.requestHeaders, ctx.requestBody)) params.requestBody = event.requestBody = ctx.requestBody;
106
+ if (isLikelyHumanReadable(ctx.responseHeaders, ctx.responseBody)) params.responseBody = event.responseBody = ctx.responseBody;
107
+ }
92
108
  if (ctx.dt) {
93
109
  event.spanId = ctx.dt.spanId;
94
110
  event.traceId = ctx.dt.traceId;
95
111
  event.spanTimestamp = Math.floor(this.agentRef.runtime.timeKeeper.correctAbsoluteTimestamp(ctx.dt.timestamp));
96
112
  }
97
-
98
- // parsed from the AJAX body, looking for operationName param & parsing query for operationType
99
- event.gql = params.gql = parseGQL({
100
- body: ctx.body,
101
- query: ctx.parsedOrigin?.search
102
- });
103
113
  if (event.gql) this.reportSupportabilityMetric('Ajax/Events/GraphQL/Bytes-Added', stringify(event.gql).length);
104
114
 
105
115
  /** make a copy of the event for the MFE target if it exists */
@@ -127,7 +137,10 @@ export class Aggregate extends AggregateBase {
127
137
  }
128
138
  serializer(eventBuffer) {
129
139
  if (!eventBuffer.length) return;
130
- const addString = getAddStringContext(this.obfuscator);
140
+ const {
141
+ addString,
142
+ addStringWithTruncation
143
+ } = createStringAdders(getAddStringContext, this.obfuscator);
131
144
  let payload = 'bel.7;';
132
145
  let firstTimestamp = 0;
133
146
  for (let i = 0; i < eventBuffer.length; i++) {
@@ -152,15 +165,35 @@ export class Aggregate extends AggregateBase {
152
165
  // 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.
153
166
  const jsAttributes = this.agentRef.info.jsAttributes;
154
167
 
155
- // add custom attributes
156
- // gql decorators are added as custom attributes to alleviate need for new BEL schema
157
- const attrParts = addCustomAttributes({
168
+ // Regular attributes: obfuscate only
169
+ const regularAttrs = addCustomAttributes({
158
170
  ...(jsAttributes || {}),
159
- ...(event.gql || {}),
171
+ [AJAX_ID]: event[AJAX_ID],
172
+ // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
160
173
  ...(event.targetAttributes || {}),
161
174
  // used to supply the version 2 attributes, either MFE target or duplication attributes for the main agent app
162
- [AJAX_ID]: event[AJAX_ID] // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
175
+ ...(event.gql || {})
163
176
  }, addString);
177
+
178
+ // Payload attributes: obfuscate then truncate
179
+ const payloadAttrs = addCustomAttributes({
180
+ ...(event.requestBody ? {
181
+ requestBody: event.requestBody
182
+ } : {}),
183
+ ...(event.requestHeaders ? {
184
+ requestHeaders: event.requestHeaders
185
+ } : {}),
186
+ ...(event.requestQuery ? {
187
+ requestQuery: event.requestQuery
188
+ } : {}),
189
+ ...(event.responseBody ? {
190
+ responseBody: event.responseBody
191
+ } : {}),
192
+ ...(event.responseHeaders ? {
193
+ responseHeaders: event.responseHeaders
194
+ } : {})
195
+ }, addStringWithTruncation);
196
+ const attrParts = [...regularAttrs, ...payloadAttrs];
164
197
  fields.unshift(numeric(attrParts.length));
165
198
  insert += fields.join(',');
166
199
  if (attrParts && attrParts.length > 0) {
@@ -4,4 +4,9 @@
4
4
  */
5
5
  import { FEATURE_NAMES } from '../../loaders/features/features';
6
6
  export const FEATURE_NAME = FEATURE_NAMES.ajax;
7
+ export const CAPTURE_PAYLOAD_SETTINGS = {
8
+ NONE: 'none',
9
+ FAILURES: 'failures',
10
+ ALL: 'all'
11
+ };
7
12
  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';
@@ -25,6 +25,7 @@ var handlersLen = handlers.length;
25
25
  var origRequest = gosNREUMOriginals().o.REQ;
26
26
  var origXHR = gosNREUMOriginals().o.XHR;
27
27
  const NR_CAT_HEADER = 'X-NewRelic-App-Data';
28
+ const INTERNAL_ERROR = 'internal-error';
28
29
  export class Instrument extends InstrumentBase {
29
30
  static featureName = FEATURE_NAME;
30
31
  constructor(agentRef) {
@@ -70,10 +71,12 @@ export class Instrument extends InstrumentBase {
70
71
  }
71
72
  }
72
73
  function subscribeToEvents(agentRef, ee, handler, dt) {
74
+ const shouldInterceptPayloads = [CAPTURE_PAYLOAD_SETTINGS.ALL, CAPTURE_PAYLOAD_SETTINGS.FAILURES].includes(agentRef.init.ajax?.capture_payloads);
73
75
  ee.on('new-xhr', onNewXhr);
74
76
  ee.on('open-xhr-start', onOpenXhrStart);
75
77
  ee.on('open-xhr-end', onOpenXhrEnd);
76
78
  ee.on('send-xhr-start', onSendXhrStart);
79
+ ee.on('setRequestHeader-xhr-start', onSetRequestHeader);
77
80
  ee.on('xhr-cb-time', onXhrCbTime);
78
81
  ee.on('xhr-load-added', onXhrLoadAdded);
79
82
  ee.on('xhr-load-removed', onXhrLoadRemoved);
@@ -145,6 +148,13 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
145
148
  }
146
149
  }
147
150
  }
151
+ function onSetRequestHeader(args, xhr) {
152
+ // args[0] = header name, args[1] = header value
153
+ if (shouldInterceptPayloads && args.length >= 2) {
154
+ this.requestHeaders ??= {};
155
+ this.requestHeaders[args[0].toLowerCase()] = args[1];
156
+ }
157
+ }
148
158
  function onSendXhrStart(args, xhr) {
149
159
  var metrics = this.metrics;
150
160
  var data = args[0];
@@ -154,7 +164,7 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
154
164
  if (size) metrics.txSize = size;
155
165
  }
156
166
  this.startTime = now();
157
- this.body = data;
167
+ this.requestBody = data;
158
168
  this.listener = function (evt) {
159
169
  try {
160
170
  if (evt.type === 'abort' && !context.loadCaptureCalled) {
@@ -163,7 +173,7 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
163
173
  if (evt.type !== 'load' || context.called === context.totalCbs && (context.onloadCalled || typeof xhr.onload !== 'function') && typeof context.end === 'function') context.end(xhr);
164
174
  } catch (e) {
165
175
  try {
166
- ee.emit('internal-error', [e]);
176
+ ee.emit(INTERNAL_ERROR, [e]);
167
177
  } catch (err) {
168
178
  // do nothing
169
179
  }
@@ -291,8 +301,27 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
291
301
  addUrl(this, extractUrl(target));
292
302
  const method = ('' + (target && target instanceof origRequest && target.method || opts.method || 'GET')).toUpperCase();
293
303
  this.params.method = method;
294
- this.body = opts.body;
295
- this.txSize = dataSize(opts.body) || 0;
304
+ this.txSize = dataSize(opts.body || target?.body) || 0;
305
+
306
+ // Capture request headers
307
+ try {
308
+ var headers = opts.headers || target?.headers;
309
+ if (shouldInterceptPayloads && headers) {
310
+ this.requestHeaders ??= {};
311
+ if (headers instanceof Headers) {
312
+ headers.forEach(function (value, key) {
313
+ this.requestHeaders[key.toLowerCase()] = value;
314
+ }.bind(this));
315
+ } else if (typeof headers === 'object') {
316
+ for (var key in headers) {
317
+ this.requestHeaders[key.toLowerCase()] = headers[key];
318
+ }
319
+ }
320
+ }
321
+ } catch (e) {
322
+ // Silently fail if we can't access headers
323
+ }
324
+ this.requestBody = opts.body || target?.body;
296
325
  }
297
326
 
298
327
  // we capture failed call as status 0, the actual error is ignored
@@ -303,19 +332,45 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
303
332
  if (hasUndefinedHostname(this.params)) return; // don't bother with fetch to url with no hostname
304
333
 
305
334
  this.params.status = res ? res.status : 0;
335
+ const finishAndReport = () => {
336
+ // convert rxSize to a number - handle both string (from content-length header) and number (from fallback)
337
+ const num = +this.rxSize;
338
+ const responseSize = this.rxSize != null && !isNaN(num) ? num : undefined;
339
+ const metrics = {
340
+ txSize: this.txSize,
341
+ rxSize: responseSize,
342
+ duration: this.endTime - this.startTime
343
+ };
344
+ const payload = [this.params, metrics, this.startTime, this.endTime, 'fetch'];
345
+ this.targets.forEach(target => reportToAgg(payload, this, target));
346
+ };
306
347
 
307
- // convert rxSize to a number
308
- let responseSize;
309
- if (typeof this.rxSize === 'string' && this.rxSize.length > 0) {
310
- responseSize = +this.rxSize;
348
+ /** Since accessing fetch bodies is an async process, these are
349
+ * reasonable conditions to check to not do needless extra work */
350
+ if (!res || !shouldInterceptPayloads) {
351
+ finishAndReport();
352
+ return;
311
353
  }
312
- const metrics = {
313
- txSize: this.txSize,
314
- rxSize: responseSize,
315
- duration: now() - this.startTime
316
- };
317
- const payload = [this.params, metrics, this.startTime, this.endTime, 'fetch'];
318
- this.targets.forEach(target => reportToAgg(payload, this, target));
354
+
355
+ // Clone the response to read the body without consuming the original
356
+ res.clone().text().then(text => {
357
+ this.responseBody = text;
358
+ // Use captured payload size as fallback if content-length header was missing or is 0 with a body
359
+ // Only apply fallback for non-network-error responses (status !== 0)
360
+ if ((!this.rxSize || this.rxSize === '0' || this.rxSize === 0) && text !== undefined && this.params.status !== 0) {
361
+ this.rxSize = dataSize(text);
362
+ }
363
+ if (res?.headers) {
364
+ this.responseHeaders = {};
365
+ res.headers.forEach(function (value, key) {
366
+ this.responseHeaders[key.toLowerCase()] = value;
367
+ }.bind(this));
368
+ }
369
+ }).catch(err => {
370
+ ee.emit(INTERNAL_ERROR, [err]);
371
+ }).finally(() => {
372
+ finishAndReport();
373
+ });
319
374
  }
320
375
 
321
376
  // Create report for XHR request that has finished
@@ -330,7 +385,7 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
330
385
  if (params.aborted) return;
331
386
  if (hasUndefinedHostname(params)) return; // don't bother with XHR of url with no hostname
332
387
 
333
- metrics.duration = now() - this.startTime;
388
+ metrics.duration = this.endTime - this.startTime;
334
389
  if (!this.loadCaptureCalled && xhr.readyState === 4) {
335
390
  captureXhrData(this, xhr);
336
391
  } else if (params.status == null) {
@@ -339,6 +394,25 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
339
394
 
340
395
  // Always send cbTime, even if no noticeable time was taken.
341
396
  metrics.cbTime = this.cbTime;
397
+ if (shouldInterceptPayloads) {
398
+ try {
399
+ this.responseBody = xhr.responseText;
400
+ } catch (e) {
401
+ this.responseBody = xhr.response;
402
+ }
403
+
404
+ // Use captured payload size as fallback if not already determined or is 0 with a body
405
+ // Only apply fallback for non-network-error responses (status !== 0)
406
+ if ((!metrics.rxSize || metrics.rxSize === 0) && this.responseBody !== undefined && params.status !== 0) {
407
+ const size = dataSize(this.responseBody);
408
+ if (size !== undefined) metrics.rxSize = size;
409
+ }
410
+ try {
411
+ this.responseHeaders = parseResponseHeaders(xhr.getAllResponseHeaders());
412
+ } catch (err) {
413
+ ee.emit(INTERNAL_ERROR, [err]);
414
+ }
415
+ }
342
416
  const payload = [params, metrics, this.startTime, this.endTime, 'xhr'];
343
417
  this.targets.forEach(target => reportToAgg(payload, this, target));
344
418
  }
@@ -348,7 +422,8 @@ function subscribeToEvents(agentRef, ee, handler, dt) {
348
422
  function captureXhrData(ctx, xhr) {
349
423
  ctx.params.status = xhr.status;
350
424
  var size = responseSizeFromXhr(xhr, ctx.lastSize);
351
- if (size) ctx.metrics.rxSize = size;
425
+ // Don't set rxSize for network errors (status 0)
426
+ if (size !== undefined && xhr.status !== 0) ctx.metrics.rxSize = size;
352
427
  if (ctx.sameOrigin && xhr.getAllResponseHeaders().indexOf(NR_CAT_HEADER) >= 0) {
353
428
  var header = xhr.getResponseHeader(NR_CAT_HEADER);
354
429
  if (header) {
@@ -370,4 +445,17 @@ function addUrl(ctx, url) {
370
445
  ctx.parsedOrigin = parsed;
371
446
  ctx.sameOrigin = parsed.sameOrigin;
372
447
  }
448
+ function parseResponseHeaders(headerStr) {
449
+ const headers = {};
450
+ if (!headerStr) return headers;
451
+ headerStr.split('\r\n').forEach(function (line) {
452
+ const separatorIndex = line.indexOf(': ');
453
+ if (separatorIndex > 0) {
454
+ const name = line.substring(0, separatorIndex);
455
+ const value = line.substring(separatorIndex + 2);
456
+ headers[name.toLowerCase()] = value;
457
+ }
458
+ });
459
+ return headers;
460
+ }
373
461
  export const Ajax = Instrument;