Skip to content

Commit 58663ec

Browse files
fix: Get responseBodySize from body if content-length is missing (#1793)
1 parent 9121c6b commit 58663ec

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

src/common/util/data-size.js

Lines changed: 2 additions & 2 deletions
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

@@ -12,7 +12,7 @@ import { stringify } from './stringify'
1212
* @returns {(number|undefined)} - The size of the data or undefined if size cannot be determined.
1313
*/
1414
export function dataSize (data) {
15-
if (typeof data === 'string' && data.length) return data.length
15+
if (typeof data === 'string') return data.length
1616
if (typeof data !== 'object') return undefined
1717
// eslint-disable-next-line
1818
if (typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer && data.byteLength) return data.byteLength

src/features/ajax/instrument/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,10 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
393393
// Clone the response to read the body without consuming the original
394394
res.clone().text().then((text) => {
395395
this.responseBody = text
396+
// Use captured payload size as fallback if content-length header was missing
397+
if (!this.rxSize && text !== undefined) {
398+
this.rxSize = dataSize(text)
399+
}
396400
if (res?.headers) {
397401
this.responseHeaders = {}
398402
res.headers.forEach(function (value, key) {
@@ -437,6 +441,12 @@ function subscribeToEvents (agentRef, ee, handler, dt) {
437441
this.responseBody = xhr.response
438442
}
439443

444+
// Use captured payload size as fallback if not already determined
445+
if (!metrics.rxSize && this.responseBody !== undefined) {
446+
const size = dataSize(this.responseBody)
447+
if (size !== undefined) metrics.rxSize = size
448+
}
449+
440450
try {
441451
this.responseHeaders = parseResponseHeaders(xhr.getAllResponseHeaders())
442452
} catch (err) {

tests/components/ajax/instrument.test.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,144 @@ describe('payload capture configuration', () => {
227227
expect(CAPTURE_PAYLOAD_SETTINGS.FAILURES).toEqual('failures')
228228
})
229229
})
230+
231+
describe('response size fallback from payload capture', () => {
232+
describe('fetch', () => {
233+
// Note: fetch tests are integration tests in tests/specs/ajax/fetch.e2e.js
234+
// These unit tests verify the logic is correct
235+
test('verifies fallback logic exists in source code', () => {
236+
const fs = require('fs')
237+
const path = require('path')
238+
const instrumentPath = path.join(__dirname, '../../../src/features/ajax/instrument/index.js')
239+
const instrumentCode = fs.readFileSync(instrumentPath, 'utf8')
240+
241+
// Verify the fetch fallback logic exists
242+
expect(instrumentCode).toContain('if (!this.rxSize && text !== undefined)')
243+
expect(instrumentCode).toContain('this.rxSize = dataSize(text)')
244+
})
245+
})
246+
247+
describe('xhr', () => {
248+
test('uses captured payload size when content-length header is missing', done => {
249+
const responseBody = '<html><body>Test Response</body></html>'
250+
251+
const xhr = new XMLHttpRequest()
252+
xhr.open('GET', 'http://example.com/api/page')
253+
254+
// Mock response properties
255+
Object.defineProperty(xhr, 'status', { get: () => 200, configurable: true })
256+
Object.defineProperty(xhr, 'readyState', { get: () => 4, configurable: true })
257+
Object.defineProperty(xhr, 'responseType', { get: () => 'text', configurable: true })
258+
Object.defineProperty(xhr, 'responseText', { get: () => responseBody, configurable: true })
259+
260+
// Mock getAllResponseHeaders to return empty (no content-length)
261+
xhr.getAllResponseHeaders = jest.fn().mockReturnValue('')
262+
263+
xhr.send()
264+
265+
// Manually trigger the load event
266+
setTimeout(() => {
267+
const loadEvent = new Event('load')
268+
xhr.dispatchEvent(loadEvent)
269+
270+
// Give the async instrumentation time to process
271+
setTimeout(() => {
272+
// Find the xhr context from handle calls
273+
const handleCalls = jest.mocked(handleModule.handle).mock.calls
274+
.filter(call => call[0] === 'xhr')
275+
276+
expect(handleCalls.length).toBeGreaterThan(0)
277+
const lastCall = handleCalls[handleCalls.length - 1]
278+
const [, [, metrics]] = lastCall
279+
280+
// Verify rxSize was set from the payload fallback
281+
expect(metrics.rxSize).toEqual(responseBody.length)
282+
done()
283+
}, 50)
284+
}, 10)
285+
})
286+
287+
test('prefers initial size detection when available', done => {
288+
const responseBody = 'Test response'
289+
290+
const xhr = new XMLHttpRequest()
291+
xhr.open('GET', 'http://example.com/api/test')
292+
293+
Object.defineProperty(xhr, 'status', { get: () => 200, configurable: true })
294+
Object.defineProperty(xhr, 'readyState', { get: () => 4, configurable: true })
295+
Object.defineProperty(xhr, 'responseType', { get: () => '', configurable: true })
296+
Object.defineProperty(xhr, 'responseText', { get: () => responseBody, configurable: true })
297+
298+
// This time, return a content-length header
299+
xhr.getAllResponseHeaders = jest.fn().mockReturnValue('content-length: 999\r\n')
300+
xhr.getResponseHeader = jest.fn((name) => {
301+
if (name === 'content-length') return '999'
302+
return null
303+
})
304+
305+
xhr.send()
306+
307+
// Trigger load event
308+
setTimeout(() => {
309+
const loadEvent = new Event('load')
310+
xhr.dispatchEvent(loadEvent)
311+
312+
setTimeout(() => {
313+
const handleCalls = jest.mocked(handleModule.handle).mock.calls
314+
.filter(call => call[0] === 'xhr')
315+
const lastCall = handleCalls[handleCalls.length - 1]
316+
const [, [, metrics]] = lastCall
317+
318+
// Since responseSizeFromXhr gets the actual response text size,
319+
// it should be the responseBody length, not the header value
320+
// (The header value would only matter if we couldn't measure the body)
321+
expect(metrics.rxSize).toEqual(responseBody.length)
322+
done()
323+
}, 50)
324+
}, 10)
325+
})
326+
327+
test('handles empty xhr response body', done => {
328+
const responseBody = ''
329+
330+
const xhr = new XMLHttpRequest()
331+
xhr.open('GET', 'http://example.com/api/empty')
332+
333+
Object.defineProperty(xhr, 'status', { get: () => 200, configurable: true })
334+
Object.defineProperty(xhr, 'readyState', { get: () => 4, configurable: true })
335+
Object.defineProperty(xhr, 'responseType', { get: () => '', configurable: true })
336+
Object.defineProperty(xhr, 'responseText', { get: () => responseBody, configurable: true })
337+
338+
xhr.getAllResponseHeaders = jest.fn().mockReturnValue('')
339+
xhr.send()
340+
341+
setTimeout(() => {
342+
const loadEvent = new Event('load')
343+
xhr.dispatchEvent(loadEvent)
344+
345+
setTimeout(() => {
346+
const handleCalls = jest.mocked(handleModule.handle).mock.calls
347+
.filter(call => call[0] === 'xhr')
348+
const lastCall = handleCalls[handleCalls.length - 1]
349+
const [, [, metrics]] = lastCall
350+
351+
// Empty string should result in rxSize of 0
352+
expect(metrics.rxSize).toEqual(0)
353+
done()
354+
}, 50)
355+
}, 10)
356+
})
357+
358+
test('verifies fallback logic exists in source code', () => {
359+
const fs = require('fs')
360+
const path = require('path')
361+
const instrumentPath = path.join(__dirname, '../../../src/features/ajax/instrument/index.js')
362+
const instrumentCode = fs.readFileSync(instrumentPath, 'utf8')
363+
364+
// Verify the XHR fallback logic exists
365+
expect(instrumentCode).toContain('if (!metrics.rxSize && this.responseBody !== undefined)')
366+
expect(instrumentCode).toContain('const size = dataSize(this.responseBody)')
367+
expect(instrumentCode).toContain('if (size !== undefined) metrics.rxSize = size')
368+
})
369+
})
370+
})

tests/unit/common/util/data-size.test.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ describe('dataSize', () => {
1010
expect(dataSize(str)).toBe(str.length)
1111
})
1212

13-
test('returns undefined for non-object, number, or empty string', () => {
13+
test('returns undefined for non-object or number', () => {
1414
expect(dataSize(Infinity)).toBeUndefined()
1515
expect(dataSize(NaN)).toBeUndefined()
1616
expect(dataSize(12345)).toBeUndefined()
17-
expect(dataSize('')).toBeUndefined()
17+
})
18+
19+
test('returns 0 for empty string', () => {
20+
expect(dataSize('')).toBe(0)
1821
})
1922

2023
test('returns byte length of ArrayBuffer object', () => {

0 commit comments

Comments
 (0)