Skip to content

Commit 08394db

Browse files
feat: Detect and report AJAX payloads (#1651)
1 parent 65b2e3b commit 08394db

27 files changed

Lines changed: 1927 additions & 4110 deletions

File tree

.github/actions/build-ab/templates/latest.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ window.NREUM.init.feature_flags = ['ajax_metrics_deny_list', 'register', 'websoc
88
window.NREUM.init.session_replay.enabled = true // feature is enabled, but the app settings will have sampling at 0. We can proactively enable SR for certain test cases through app settings
99
window.NREUM.init.session_trace.enabled = true // feature is enabled, but the app settings will have sampling at 0. We can proactively enable SR for certain test cases through app settings
1010
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'ariaLabel', 'alt', 'title']}
11+
window.NREUM.init.ajax.capture_payloads = 'failures'
1112

1213
{{{latestScript}}}

.github/workflows/publish-experiment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
window.NREUM.init.session_trace.enabled = true
6666
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list', 'register']
6767
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'ariaLabel', 'alt', 'title']}
68+
window.NREUM.init.ajax.capture_payloads = 'failures'
6869
EOF
6970
- name: Upload experiment
7071
id: s3-upload

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ packages/browser-agent-core/cjs
2929
tests/assets/test-builds/**/*
3030
tests/assets/scripts/**/*
3131
/.lambdatest
32+
bam.json

package-lock.json

Lines changed: 102 additions & 4065 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/common/config/init.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import { FEATURE_FLAGS } from '../../features/generic_events/constants'
6+
import { CAPTURE_PAYLOAD_SETTINGS } from '../../features/ajax/constants'
67
import { isValidSelector } from '../dom/query-selector'
78
import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS } from '../session/constants'
89
import { warn } from '../util/console'
@@ -47,7 +48,7 @@ const InitModelFn = () => {
4748
}
4849
}
4950
return {
50-
ajax: { deny_list: undefined, block_internal: true, enabled: true, autoStart: true },
51+
ajax: { deny_list: undefined, block_internal: true, enabled: true, autoStart: true, capture_payloads: CAPTURE_PAYLOAD_SETTINGS.OFF },
5152
api: {
5253
register: {
5354
get enabled () { return hiddenState.feature_flags.includes(FEATURE_FLAGS.REGISTER) || hiddenState.experimental.register },

src/common/payloads/payloads.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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 ('off', '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.OFF) 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+
33+
const queryParams = {}
34+
try {
35+
const searchParams = new URLSearchParams(search)
36+
searchParams.forEach(function (value, key) {
37+
queryParams[key] = value
38+
})
39+
} catch (e) {
40+
// Fallback for environments without URLSearchParams
41+
}
42+
43+
return queryParams
44+
}
45+
46+
/**
47+
* Determines if the given content type is likely to be human-readable (text-based).
48+
* @param {Object} headers - The headers object containing content-type
49+
* @param {*} data - The data to check
50+
* @returns {boolean} True if the content type is human-readable (text-based)
51+
*/
52+
export function isLikelyHumanReadable (headers, data) {
53+
if (!headers) return typeof data === 'string'
54+
var contentType = headers['content-type']
55+
if (!contentType) return typeof data === 'string'
56+
// Normalize to lowercase and extract the mime type (ignore charset, etc.)
57+
var mimeType = contentType.toLowerCase().split(';')[0].trim()
58+
59+
// Check for text/* types
60+
if (mimeType.indexOf('text/') === 0) return true
61+
62+
// Check for specific application/* types
63+
var readableAppTypes = [
64+
'/json',
65+
'/xml',
66+
'/xhtml+xml',
67+
'/ld+json',
68+
'/yaml',
69+
'/x-www-form-urlencoded'
70+
]
71+
72+
for (var i = 0; i < readableAppTypes.length; i++) {
73+
if (mimeType === 'application' + readableAppTypes[i]) return true
74+
}
75+
76+
return false
77+
}
78+
79+
/**
80+
* Truncates a string to ensure its UTF-8 byte length does not exceed 4092 bytes. If truncation is necessary,
81+
* the string is cut off at a character boundary to avoid breaking multi-byte characters and " ..." is appended to indicate truncation.
82+
* If not a string, it is first converted to a string using JSON.stringify.
83+
* @param {*} data The data to truncate.
84+
* @returns {string}
85+
*/
86+
export function truncateAsString (data) {
87+
if (!data) return data
88+
try {
89+
if (typeof data !== 'string') data = stringify(data)
90+
let bytes = 0
91+
let i = 0
92+
let needsEllipsis = false
93+
94+
while (i < data.length) {
95+
const c = data.charCodeAt(i)
96+
const charBytes = c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0xD800 || c >= 0xE000 ? 3 : 4
97+
98+
if (bytes + charBytes > 4092) {
99+
needsEllipsis = true
100+
break
101+
}
102+
103+
bytes += charBytes
104+
i += charBytes === 4 ? 2 : 1
105+
}
106+
107+
return data.slice(0, i) + (needsEllipsis ? ' ...' : '')
108+
} catch (e) {
109+
return data
110+
}
111+
}
112+
113+
/**
114+
* Creates string adder functions for BEL serialization with obfuscation and optional truncation.
115+
* This ensures a single string table is used while providing separate handling for regular vs payload attributes.
116+
* @param {Function} getAddStringContext - Function that creates a new string table context
117+
* @param {Object} obfuscator - Optional obfuscator instance for string obfuscation
118+
* @returns {{addString: Function, addStringWithTruncation: Function}} Object containing both string adder functions
119+
*/
120+
export function createStringAdders (getAddStringContext, obfuscator) {
121+
const addStringRaw = getAddStringContext()
122+
123+
const processString = (str, shouldTruncate) => {
124+
if (typeof str === 'undefined' || str === '') return addStringRaw(str)
125+
if (typeof str !== 'string') str = stringify(str)
126+
const obfuscated = obfuscator?.obfuscateString(str) ?? str
127+
const processed = shouldTruncate ? truncateAsString(obfuscated) : obfuscated
128+
return addStringRaw(processed)
129+
}
130+
131+
return {
132+
addString: (str) => processString(str, false),
133+
addStringWithTruncation: (str) => processString(str, true)
134+
}
135+
}

src/common/serialize/bel-serializer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function numeric (n, noDefault) {
2121
return (n === undefined || n === 0) ? '' : Math.floor(n).toString(36)
2222
}
2323

24-
export function getAddStringContext (obfuscator) {
24+
export function getAddStringContext (obfuscator, truncator) {
2525
let stringTableIdx = 0
2626
const stringTable = Object.prototype.hasOwnProperty.call(Object, 'create') ? Object.create(null) : {}
2727

@@ -30,6 +30,7 @@ export function getAddStringContext (obfuscator) {
3030
function addString (str) {
3131
if (typeof str === 'undefined' || str === '') return ''
3232
str = obfuscator?.obfuscateString(String(str)) ?? String(str)
33+
str = truncator?.(str) ?? str
3334
if (hasOwnProp.call(stringTable, str)) {
3435
return numeric(stringTable[str], true)
3536
} else {

src/common/wrap/wrap-xhr.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { warn } from '../util/console'
1717
import { findTargetsFromStackTrace } from '../v2/utils'
1818

1919
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)
2121

2222
/**
2323
* Wraps the native XMLHttpRequest (XHR) object to emit custom events to its readystatechange event and an assortment

src/features/ajax/aggregate/gql.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2020-2025 New Relic, Inc. All rights reserved.
2+
* Copyright 2020-2026 New Relic, Inc. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import { isPureObject } from '../../../common/util/type-check'
@@ -97,3 +97,44 @@ function parseGQLQueryString (gqlQueryString) {
9797
function validateGQLObject (obj) {
9898
return !(typeof obj !== 'object' || !obj.query || typeof obj.query !== 'string')
9999
}
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+
}

src/features/ajax/aggregate/index.js

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { registerHandler } from '../../../common/event-emitter/register-handler'
66
import { stringify } from '../../../common/util/stringify'
77
import { handle } from '../../../common/event-emitter/handle'
88
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'
1010
import { FEATURE_NAMES } from '../../../loaders/features/features'
1111
import { AggregateBase } from '../../utils/aggregate-base'
12-
import { parseGQL } from './gql'
1312
import { nullable, numeric, getAddStringContext, addCustomAttributes } from '../../../common/serialize/bel-serializer'
1413
import { gosNREUMOriginals } from '../../../common/window/nreum'
14+
import { hasGQLErrors, parseGQL } from './gql'
15+
import { canCapturePayload, isLikelyHumanReadable, parseQueryString, createStringAdders } from '../../../common/payloads/payloads'
1516
import { Obfuscator } from '../../../common/util/obfuscate'
1617
import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/v2/utils'
1718
import { EVENT_TYPES } from '../../../common/constants/events'
@@ -101,6 +102,24 @@ export class Aggregate extends AggregateBase {
101102
[AJAX_ID]: generateUuid() // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
102103
}
103104

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+
104123
if (ctx.dt) {
105124
event.spanId = ctx.dt.spanId
106125
event.traceId = ctx.dt.traceId
@@ -109,11 +128,6 @@ export class Aggregate extends AggregateBase {
109128
)
110129
}
111130

112-
// parsed from the AJAX body, looking for operationName param & parsing query for operationType
113-
event.gql = params.gql = parseGQL({
114-
body: ctx.body,
115-
query: ctx.parsedOrigin?.search
116-
})
117131
if (event.gql) this.reportSupportabilityMetric('Ajax/Events/GraphQL/Bytes-Added', stringify(event.gql).length)
118132

119133
/** make a copy of the event for the MFE target if it exists */
@@ -136,7 +150,9 @@ export class Aggregate extends AggregateBase {
136150

137151
serializer (eventBuffer) {
138152
if (!eventBuffer.length) return
139-
const addString = getAddStringContext(this.obfuscator)
153+
154+
const { addString, addStringWithTruncation } = createStringAdders(getAddStringContext, this.obfuscator)
155+
140156
let payload = 'bel.7;'
141157

142158
let firstTimestamp = 0
@@ -170,15 +186,24 @@ export class Aggregate extends AggregateBase {
170186
// 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.
171187
const jsAttributes = this.agentRef.info.jsAttributes
172188

173-
// add custom attributes
174-
// gql decorators are added as custom attributes to alleviate need for new BEL schema
175-
const attrParts = addCustomAttributes({
189+
// Regular attributes: obfuscate only
190+
const regularAttrs = addCustomAttributes({
176191
...(jsAttributes || {}),
177-
...(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
178193
...(event.targetAttributes || {}), // used to supply the version 2 attributes, either MFE target or duplication attributes for the main agent app
179-
[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 || {})
180195
}, addString)
181196

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]
182207
fields.unshift(numeric(attrParts.length))
183208

184209
insert += fields.join(',')

0 commit comments

Comments
 (0)