Skip to content
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6c9cbb6
basic changes in place to capture payload metadata
metal-messiah Dec 10, 2025
edb175f
shelving for now
metal-messiah Dec 10, 2025
15b440a
add tests
metal-messiah Dec 11, 2025
2489823
fix tests
metal-messiah Dec 11, 2025
3fe3d02
Merge branch 'main' into ajax-payloads-wrap
metal-messiah Dec 11, 2025
665920a
clean up gql
metal-messiah Dec 12, 2025
13e14ae
clean up
metal-messiah Dec 12, 2025
503f107
use constant
metal-messiah Dec 12, 2025
fa71019
fix jest tests
metal-messiah Dec 12, 2025
b9a2f6e
add basics of truncation
metal-messiah Dec 12, 2025
8407eb9
basics in place for truncation and size eval for non-ascii
metal-messiah Dec 15, 2025
1c531fa
cleanup
metal-messiah Dec 16, 2025
faeee5d
handle truncation and unicode text
metal-messiah Dec 17, 2025
3d83dab
resolve merge conflicts
metal-messiah Dec 17, 2025
fd64c2a
fix test
metal-messiah Dec 17, 2025
10641ec
add jest tests
metal-messiah Dec 18, 2025
2b6552c
focus on aggregate
metal-messiah Dec 19, 2025
37a4959
remove unused warning
metal-messiah Dec 19, 2025
80ff822
move file to correct directory
metal-messiah Dec 19, 2025
6f34f59
move file to correct directory
metal-messiah Dec 19, 2025
a026085
fix test paths
metal-messiah Dec 19, 2025
b3ea2f5
expose as experiment
metal-messiah Dec 19, 2025
3aa0a0d
add to params so that it shows up for old spa events
metal-messiah Dec 19, 2025
4b812b9
fix bad ee reference
metal-messiah Dec 30, 2025
0a02d7c
fix merge conflicts
metal-messiah Jan 14, 2026
14dc037
support new experiment pattern
metal-messiah Jan 14, 2026
ba4ba42
merge with main
metal-messiah Jun 8, 2026
ee4e978
update tests to check eventFilter
metal-messiah Jun 8, 2026
40f1be1
extend to include default rules
metal-messiah Jun 8, 2026
6367049
PR comment - truncate after scrubbing
metal-messiah Jun 12, 2026
7aec8a6
PR comment - test main AJAX methods
metal-messiah Jun 12, 2026
2e1d5f5
PR comment - use endTime
metal-messiah Jun 12, 2026
176fba1
PR comment - gate responseBody
metal-messiah Jun 12, 2026
3004409
PR comment - requestBody from Request instance
metal-messiah Jun 12, 2026
e41772d
PR comment - handle gql batched errors
metal-messiah Jun 12, 2026
75fe265
PR comment - elevate ajaxRequest.id to help prevent errant truncation
metal-messiah Jun 12, 2026
d5626ec
PR comment - ensure truncation logic applies to browser interaction too
metal-messiah Jun 12, 2026
1103907
PR comment - standardize canCapturePayload
metal-messiah Jun 12, 2026
e9e33c4
PR comment - jsdoc
metal-messiah Jun 12, 2026
706e1d1
fix tests
metal-messiah Jun 12, 2026
7fd5bfd
remove duplicated file
metal-messiah Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/build-ab/templates/latest.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ window.NREUM.init.feature_flags = ['ajax_metrics_deny_list', 'register', 'websoc
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
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
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'ariaLabel', 'alt', 'title']}
window.NREUM.init.ajax.capture_payloads = 'failures'

{{{latestScript}}}
1 change: 1 addition & 0 deletions .github/workflows/publish-experiment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ jobs:
window.NREUM.init.session_trace.enabled = true
window.NREUM.init.feature_flags = ['ajax_metrics_deny_list', 'register']
window.NREUM.init.user_actions = {elementAttributes: ['id', 'className', 'tagName', 'type', 'ariaLabel', 'alt', 'title']}
window.NREUM.init.ajax.capture_payloads = 'failures'
EOF
- name: Upload experiment
id: s3-upload
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ packages/browser-agent-core/cjs
tests/assets/test-builds/**/*
tests/assets/scripts/**/*
/.lambdatest
bam.json
4,167 changes: 102 additions & 4,065 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/common/config/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { FEATURE_FLAGS } from '../../features/generic_events/constants'
import { CAPTURE_PAYLOAD_SETTINGS } from '../../features/ajax/constants'
import { isValidSelector } from '../dom/query-selector'
import { DEFAULT_EXPIRES_MS, DEFAULT_INACTIVE_MS } from '../session/constants'
import { warn } from '../util/console'
Expand Down Expand Up @@ -47,7 +48,7 @@ const InitModelFn = () => {
}
}
return {
ajax: { deny_list: undefined, block_internal: true, enabled: true, autoStart: true },
ajax: { deny_list: undefined, block_internal: true, enabled: true, autoStart: true, capture_payloads: CAPTURE_PAYLOAD_SETTINGS.OFF },
api: {
register: {
get enabled () { return hiddenState.feature_flags.includes(FEATURE_FLAGS.REGISTER) || hiddenState.experimental.register },
Expand Down
135 changes: 135 additions & 0 deletions src/common/payloads/payloads.js
Comment thread
metal-messiah marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Copyright 2020-2026 New Relic, Inc. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { stringify } from '../util/stringify'
import { CAPTURE_PAYLOAD_SETTINGS } from '../../features/ajax/constants'

/**
* Determines whether payload data should be captured based on the capture mode setting,
* HTTP status code, and GraphQL error status.
* @param {string} captureMode - The capture mode setting ('off', 'all', or 'failures')
* @param {number} statusCode - The HTTP status code
* @param {boolean} hasGQLErrors - Whether the response contains GraphQL errors
* @returns {boolean} True if payload should be captured
*/
export function canCapturePayload (captureMode, statusCode, hasGQLErrors) {
if (captureMode === CAPTURE_PAYLOAD_SETTINGS.ALL) return true
if (!captureMode || captureMode === CAPTURE_PAYLOAD_SETTINGS.OFF) return false

// Default "failures" mode
return statusCode === 0 || statusCode >= 400 || hasGQLErrors === true
}

/**
* Parses a query string into an object of key-value pairs.
* @param {string} search a query string starting with "?" or without (ex. new URL(...).search)
* @returns {Object|undefined} Parsed query parameters as key-value pairs. Returns undefined if no valid parameters are found.
*/
export function parseQueryString (search) {
if (!search || search.length === 0) return

const queryParams = {}
try {
const searchParams = new URLSearchParams(search)
searchParams.forEach(function (value, key) {
queryParams[key] = value
})
} catch (e) {
// Fallback for environments without URLSearchParams
}

return queryParams
}

/**
* Determines if the given content type is likely to be human-readable (text-based).
* @param {Object} headers - The headers object containing content-type
* @param {*} data - The data to check
* @returns {boolean} True if the content type is human-readable (text-based)
*/
export function isLikelyHumanReadable (headers, data) {
if (!headers) return typeof data === 'string'
var contentType = headers['content-type']
if (!contentType) return typeof data === 'string'
// Normalize to lowercase and extract the mime type (ignore charset, etc.)
var mimeType = contentType.toLowerCase().split(';')[0].trim()

// Check for text/* types
if (mimeType.indexOf('text/') === 0) return true

// Check for specific application/* types
var readableAppTypes = [
'/json',
'/xml',
'/xhtml+xml',
'/ld+json',
'/yaml',
'/x-www-form-urlencoded'
]

for (var i = 0; i < readableAppTypes.length; i++) {
if (mimeType === 'application' + readableAppTypes[i]) return true
}

return false
}

/**
* Truncates a string to ensure its UTF-8 byte length does not exceed 4092 bytes. If truncation is necessary,
* the string is cut off at a character boundary to avoid breaking multi-byte characters and " ..." is appended to indicate truncation.
* If not a string, it is first converted to a string using JSON.stringify.
* @param {*} data The data to truncate.
* @returns {string}
*/
export function truncateAsString (data) {
if (!data) return data
try {
if (typeof data !== 'string') data = stringify(data)
let bytes = 0
let i = 0
let needsEllipsis = false

while (i < data.length) {
const c = data.charCodeAt(i)
const charBytes = c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0xD800 || c >= 0xE000 ? 3 : 4

if (bytes + charBytes > 4092) {
needsEllipsis = true
break
}

bytes += charBytes
i += charBytes === 4 ? 2 : 1
}

return data.slice(0, i) + (needsEllipsis ? ' ...' : '')
} catch (e) {
return data
}
}

/**
* Creates string adder functions for BEL serialization with obfuscation and optional truncation.
* This ensures a single string table is used while providing separate handling for regular vs payload attributes.
* @param {Function} getAddStringContext - Function that creates a new string table context
* @param {Object} obfuscator - Optional obfuscator instance for string obfuscation
* @returns {{addString: Function, addStringWithTruncation: Function}} Object containing both string adder functions
*/
export function createStringAdders (getAddStringContext, obfuscator) {
const addStringRaw = getAddStringContext()

const processString = (str, shouldTruncate) => {
if (typeof str === 'undefined' || str === '') return addStringRaw(str)
if (typeof str !== 'string') str = stringify(str)
const obfuscated = obfuscator?.obfuscateString(str) ?? str
const processed = shouldTruncate ? truncateAsString(obfuscated) : obfuscated
return addStringRaw(processed)
}

return {
addString: (str) => processString(str, false),
addStringWithTruncation: (str) => processString(str, true)
}
}
3 changes: 2 additions & 1 deletion src/common/serialize/bel-serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function numeric (n, noDefault) {
return (n === undefined || n === 0) ? '' : Math.floor(n).toString(36)
}

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

Expand All @@ -30,6 +30,7 @@ export function getAddStringContext (obfuscator) {
function addString (str) {
if (typeof str === 'undefined' || str === '') return ''
str = obfuscator?.obfuscateString(String(str)) ?? String(str)
str = truncator?.(str) ?? str
if (hasOwnProp.call(stringTable, str)) {
return numeric(stringTable[str], true)
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/common/wrap/wrap-xhr.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { warn } from '../util/console'
import { findTargetsFromStackTrace } from '../v2/utils'

const wrapped = {}
const XHR_PROPS = ['open', 'send'] // these are the specific funcs being wrapped on all XMLHttpRequests(.prototype)
const XHR_PROPS = ['open', 'send', 'setRequestHeader'] // these are the specific funcs being wrapped on all XMLHttpRequests(.prototype)

/**
* Wraps the native XMLHttpRequest (XHR) object to emit custom events to its readystatechange event and an assortment
Expand Down
43 changes: 42 additions & 1 deletion src/features/ajax/aggregate/gql.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2020-2025 New Relic, Inc. All rights reserved.
* Copyright 2020-2026 New Relic, Inc. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import { isPureObject } from '../../../common/util/type-check'
Expand Down Expand Up @@ -97,3 +97,44 @@ function parseGQLQueryString (gqlQueryString) {
function validateGQLObject (obj) {
return !(typeof obj !== 'object' || !obj.query || typeof obj.query !== 'string')
}

/**
* Checks if a response object has valid GraphQL errors.
* @param {object} response - A single GraphQL response object
* @returns {boolean} True if the response has valid errors
*/
function hasValidGQLErrors (response) {
return Array.isArray(response?.errors) &&
response.errors.some(err =>
err && typeof err === 'object' && typeof err.message === 'string'
)
}

/**
* Checks if a response body contains GraphQL errors according to the GraphQL spec.
* A valid GraphQL error response contains an "errors" array with at least one error object.
* Supports both single and batched GraphQL responses.
* @param {string|object|array} [responseBody] The response body to check
* @returns {boolean} True if the response contains GraphQL errors
*/
export function hasGQLErrors (responseBody) {
if (!responseBody) return false
try {
let parsed = responseBody

// Parse string to object if needed
if (typeof responseBody === 'string') {
parsed = JSON.parse(responseBody)
}

// Handle batched GraphQL responses (array of response objects)
if (Array.isArray(parsed)) {
return parsed.some(hasValidGQLErrors)
}

// Handle single GraphQL response
return hasValidGQLErrors(parsed)
} catch (err) {
return false
}
}
51 changes: 38 additions & 13 deletions src/features/ajax/aggregate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { registerHandler } from '../../../common/event-emitter/register-handler'
import { stringify } from '../../../common/util/stringify'
import { handle } from '../../../common/event-emitter/handle'
import { setDenyList, shouldCollectEvent } from '../../../common/deny-list/deny-list'
import { AJAX_ID, FEATURE_NAME } from '../constants'
import { FEATURE_NAME, AJAX_ID } from '../constants'
import { FEATURE_NAMES } from '../../../loaders/features/features'
import { AggregateBase } from '../../utils/aggregate-base'
import { parseGQL } from './gql'
import { nullable, numeric, getAddStringContext, addCustomAttributes } from '../../../common/serialize/bel-serializer'
import { gosNREUMOriginals } from '../../../common/window/nreum'
import { hasGQLErrors, parseGQL } from './gql'
import { canCapturePayload, isLikelyHumanReadable, parseQueryString, createStringAdders } from '../../../common/payloads/payloads'
import { Obfuscator } from '../../../common/util/obfuscate'
import { getVersion2Attributes, getVersion2DuplicationAttributes, shouldDuplicate } from '../../../common/v2/utils'
import { EVENT_TYPES } from '../../../common/constants/events'
Expand Down Expand Up @@ -101,6 +102,24 @@ export class Aggregate extends AggregateBase {
[AJAX_ID]: generateUuid() // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
}

event.gql = params.gql = parseGQL({
body: ctx.requestBody,
query: ctx.parsedOrigin?.search
})
if (event.gql) event.gql.operationHasErrors = params.gql.operationHasErrors = hasGQLErrors(ctx.responseBody)

const capturePayloadSetting = this.agentRef.init.ajax.capture_payloads
const shouldCapturePayload = canCapturePayload(capturePayloadSetting, params.status, event.gql?.operationHasErrors)

if (shouldCapturePayload) {
// Store raw data; obfuscation and truncation will happen in the serializer
params.requestQuery = event.requestQuery = parseQueryString(ctx.parsedOrigin?.search)
params.requestHeaders = event.requestHeaders = ctx.requestHeaders
params.responseHeaders = event.responseHeaders = ctx.responseHeaders
if (isLikelyHumanReadable(ctx.requestHeaders, ctx.requestBody)) params.requestBody = event.requestBody = ctx.requestBody
if (isLikelyHumanReadable(ctx.responseHeaders, ctx.responseBody)) params.responseBody = event.responseBody = ctx.responseBody
}

if (ctx.dt) {
event.spanId = ctx.dt.spanId
event.traceId = ctx.dt.traceId
Expand All @@ -109,11 +128,6 @@ export class Aggregate extends AggregateBase {
)
}

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

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

serializer (eventBuffer) {
if (!eventBuffer.length) return
const addString = getAddStringContext(this.obfuscator)

const { addString, addStringWithTruncation } = createStringAdders(getAddStringContext, this.obfuscator)

let payload = 'bel.7;'

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

// add custom attributes
// gql decorators are added as custom attributes to alleviate need for new BEL schema
const attrParts = addCustomAttributes({
// Regular attributes: obfuscate only
const regularAttrs = addCustomAttributes({
...(jsAttributes || {}),
...(event.gql || {}),
[AJAX_ID]: event[AJAX_ID], // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
...(event.targetAttributes || {}), // used to supply the version 2 attributes, either MFE target or duplication attributes for the main agent app
[AJAX_ID]: event[AJAX_ID] // all AjaxRequest events should have a unique identifier to allow for easier grouping and analysis in the UI
...(event.gql || {})
}, addString)

// Payload attributes: obfuscate then truncate
const payloadAttrs = addCustomAttributes({
...(event.requestBody ? { requestBody: event.requestBody } : {}),
...(event.requestHeaders ? { requestHeaders: event.requestHeaders } : {}),
...(event.requestQuery ? { requestQuery: event.requestQuery } : {}),
...(event.responseBody ? { responseBody: event.responseBody } : {}),
...(event.responseHeaders ? { responseHeaders: event.responseHeaders } : {})
}, addStringWithTruncation)

const attrParts = [...regularAttrs, ...payloadAttrs]
fields.unshift(numeric(attrParts.length))

insert += fields.join(',')
Expand Down
Loading
Loading