Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dfd7920
Track GraphQl Request
rgaignault Oct 21, 2025
e204809
Merge branch 'main' into romanG/graphql-error-tracking
rgaignault Oct 21, 2025
a3a0039
format update
rgaignault Oct 21, 2025
bdab4c3
update rum-events-format
rgaignault Oct 22, 2025
0450dd8
cherry pick commit so view is not optional anymore
rgaignault Oct 22, 2025
1306118
🥜 nitpicks and format
rgaignault Oct 28, 2025
b8cdc26
Merge branch 'main' into romanG/graphql-error-tracking
rgaignault Oct 28, 2025
ef3b690
👌 Helper function for request collection test
rgaignault Oct 28, 2025
a8fdb57
👌 Use a mutable context and update relative test + move xhr extraction
rgaignault Oct 28, 2025
044902c
👌 Synchronous function / Cast simplification / Error Handling
rgaignault Oct 29, 2025
f803b7b
👌 move parseGraphQLResponse function
rgaignault Oct 29, 2025
84bfaf0
👌 Merge the two readReponseFunction
rgaignault Oct 29, 2025
f9496a1
👌 do not collect if not needed / remove useless check / simplify stri…
rgaignault Oct 29, 2025
887a807
🐛 Fix useless cast
rgaignault Oct 29, 2025
8f7e091
🥜 Modify comment
rgaignault Oct 29, 2025
d13df40
use concatBuffers in readBytesFromStream
BenoitZugmeyer Oct 29, 2025
bdfe79e
convert readBytesFromStream to an async function
BenoitZugmeyer Oct 29, 2025
682e903
change fetchObservable to use await
BenoitZugmeyer Oct 29, 2025
e701e47
fetch body directly in `fetchObservable`
BenoitZugmeyer Oct 29, 2025
b6c957b
remove bytes limit support from readByteFromStream
BenoitZugmeyer Oct 29, 2025
0cae7ad
use safeTruncate in logs error collection
BenoitZugmeyer Oct 29, 2025
e2912ea
Merge remote-tracking branch 'origin/benoit/graphql-error-tracking-wi…
rgaignault Oct 30, 2025
1b5dbdd
🐛 Fix Build & Lint
rgaignault Oct 30, 2025
8def063
👌 Remove some test / rename to requestBody / remove responseText for …
rgaignault Oct 30, 2025
011989c
Add new test after refactor
rgaignault Oct 30, 2025
d3473c8
👌 Reduce error check size
rgaignault Oct 30, 2025
9676fb1
Fix unit test related to errors
rgaignault Oct 30, 2025
17fc984
Prettier
rgaignault Oct 30, 2025
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
79 changes: 55 additions & 24 deletions packages/core/src/browser/fetchObservable.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { InstrumentedMethodCall } from '../tools/instrumentMethod'
import { instrumentMethod } from '../tools/instrumentMethod'
import { monitor } from '../tools/monitor'
import { monitorError } from '../tools/monitor'
import { Observable } from '../tools/observable'
import type { ClocksState } from '../tools/utils/timeUtils'
import { clocksNow } from '../tools/utils/timeUtils'
import { normalizeUrl } from '../tools/utils/urlPolyfill'
import type { GlobalObject } from '../tools/globalObject'
import { globalObject } from '../tools/globalObject'
import { readBytesFromStream } from '../tools/readBytesFromStream'
import { tryToClone } from '../tools/utils/responseUtils'

interface FetchContextBase {
method: string
Expand All @@ -25,16 +27,31 @@ export interface FetchResolveContext extends FetchContextBase {
state: 'resolve'
status: number
response?: Response
responseBody?: string
responseType?: string
isAborted: boolean
error?: Error
}

export type FetchContext = FetchStartContext | FetchResolveContext

type ResponseBodyActionGetter = (context: FetchResolveContext) => ResponseBodyAction

export const enum ResponseBodyAction {
IGNORE = 0,
// TODO(next-major): Remove the "WAIT" action when `trackEarlyRequests` is removed, as the
// duratiorn of fetch requests will always come from PerformanceResourceTiming
WAIT = 1,
COLLECT = 2,
}

let fetchObservable: Observable<FetchContext> | undefined
const responseBodyActionGetters: ResponseBodyActionGetter[] = []

export function initFetchObservable() {
export function initFetchObservable({ responseBodyAction }: { responseBodyAction?: ResponseBodyActionGetter } = {}) {
if (responseBodyAction) {
responseBodyActionGetters.push(responseBodyAction)
}
if (!fetchObservable) {
fetchObservable = createFetchObservable()
}
Expand All @@ -43,6 +60,7 @@ export function initFetchObservable() {

export function resetFetchObservable() {
fetchObservable = undefined
responseBodyActionGetters.length = 0
}

function createFetchObservable() {
Expand Down Expand Up @@ -90,38 +108,51 @@ function beforeSend(
parameters[0] = context.input as RequestInfo | URL
parameters[1] = context.init

onPostCall((responsePromise) => afterSend(observable, responsePromise, context))
onPostCall((responsePromise) => {
afterSend(observable, responsePromise, context).catch(monitorError)
})
}

function afterSend(
async function afterSend(
observable: Observable<FetchContext>,
responsePromise: Promise<Response>,
startContext: FetchStartContext
) {
const context = startContext as unknown as FetchResolveContext
context.state = 'resolve'

let response: Response

function reportFetch(partialContext: Partial<FetchResolveContext>) {
context.state = 'resolve'
Object.assign(context, partialContext)
try {
response = await responsePromise
} catch (error) {
context.status = 0
context.isAborted =
context.init?.signal?.aborted || (error instanceof DOMException && error.code === DOMException.ABORT_ERR)
context.error = error as Error
observable.notify(context)
return
}

responsePromise.then(
monitor((response) => {
reportFetch({
response,
responseType: response.type,
status: response.status,
isAborted: false,
context.response = response
context.status = response.status
context.responseType = response.type
context.isAborted = false

const responseBodyCondition = responseBodyActionGetters.reduce(
(action, getter) => Math.max(action, getter(context)),
ResponseBodyAction.IGNORE
) as ResponseBodyAction

if (responseBodyCondition !== ResponseBodyAction.IGNORE) {
const clonedResponse = tryToClone(response)
if (clonedResponse && clonedResponse.body) {
const bytes = await readBytesFromStream(clonedResponse.body, {
collectStreamBody: responseBodyCondition === ResponseBodyAction.COLLECT,
})
}),
monitor((error: Error) => {
reportFetch({
status: 0,
isAborted:
context.init?.signal?.aborted || (error instanceof DOMException && error.code === DOMException.ABORT_ERR),
error,
})
})
)
context.responseBody = bytes && new TextDecoder().decode(bytes)
}
}

observable.notify(context)
}
8 changes: 6 additions & 2 deletions packages/core/src/browser/xhrObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ export interface XhrStartContext extends Omit<XhrOpenContext, 'state'> {
isAborted: boolean
xhr: XMLHttpRequest
handlingStack?: string
body?: unknown
requestBody?: unknown
}

export interface XhrCompleteContext extends Omit<XhrStartContext, 'state'> {
state: 'complete'
duration: Duration
status: number
responseBody?: string
}

export type XhrContext = XhrOpenContext | XhrStartContext | XhrCompleteContext
Expand Down Expand Up @@ -88,7 +89,7 @@ function sendXhr(
startContext.isAborted = false
startContext.xhr = xhr
startContext.handlingStack = handlingStack
startContext.body = body
startContext.requestBody = body

let hasBeenReported = false

Expand All @@ -114,6 +115,9 @@ function sendXhr(
completeContext.state = 'complete'
completeContext.duration = elapsed(startContext.startClocks.timeStamp, timeStampNow())
completeContext.status = xhr.status
if (typeof xhr.response === 'string') {
completeContext.responseBody = xhr.response
}
observable.notify(shallowClone(completeContext))
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export type { CookieStore, WeakRef, WeakRefConstructor } from './browser/browser
export type { XhrCompleteContext, XhrStartContext } from './browser/xhrObservable'
export { initXhrObservable } from './browser/xhrObservable'
export type { FetchResolveContext, FetchStartContext, FetchContext } from './browser/fetchObservable'
export { initFetchObservable, resetFetchObservable } from './browser/fetchObservable'
export { initFetchObservable, resetFetchObservable, ResponseBodyAction } from './browser/fetchObservable'
export type { PageMayExitEvent } from './browser/pageMayExitObservable'
export { createPageMayExitObservable, PageExitReason, isPageExitReason } from './browser/pageMayExitObservable'
export * from './browser/addEventListener'
Expand Down
142 changes: 32 additions & 110 deletions packages/core/src/tools/readBytesFromStream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,139 +2,61 @@ import { readBytesFromStream } from './readBytesFromStream'

describe('readBytesFromStream', () => {
const str = 'Lorem ipsum dolor sit amet.'
let beenCalled = false
let stream: ReadableStream

beforeEach(() => {
beenCalled = false
stream = new ReadableStream({
pull: (controller) => {
if (!beenCalled) {
controller.enqueue(new TextEncoder().encode(str))
beenCalled = true
} else {
controller.close()
}
start: (controller) => {
controller.enqueue(new TextEncoder().encode(str))
controller.close()
},
})
})

it('should read full stream', (done) => {
readBytesFromStream(
stream,
(error, bytes, limitExceeded) => {
expect(error).toBeUndefined()
expect(bytes?.length).toBe(27)
expect(limitExceeded).toBe(false)
done()
},
{
bytesLimit: Number.POSITIVE_INFINITY,
collectStreamBody: true,
}
)
})
it('should read full stream', async () => {
const bytes = await readBytesFromStream(stream, {
collectStreamBody: true,
})

it('should read full stream without body', (done) => {
readBytesFromStream(
stream,
(error, bytes, limitExceeded) => {
expect(error).toBeUndefined()
expect(bytes).toBeUndefined()
expect(limitExceeded).toBeUndefined()
done()
},
{
bytesLimit: Number.POSITIVE_INFINITY,
collectStreamBody: false,
}
)
expect(bytes?.length).toBe(27)
})

it('should read stream up to limit', (done) => {
readBytesFromStream(
stream,
(error, bytes, limitExceeded) => {
expect(error).toBeUndefined()
expect(bytes?.length).toBe(10)
expect(limitExceeded).toBe(true)
done()
},
{
bytesLimit: 10,
collectStreamBody: true,
}
)
it('should read full stream without body', async () => {
const bytes = await readBytesFromStream(stream, {
collectStreamBody: false,
})
expect(bytes).toBeUndefined()
})

it('should handle rejection error on pull', (done) => {
it('should handle rejection error on read', async () => {
const stream = new ReadableStream({
pull: () => Promise.reject(new Error('foo')),
start: (controller) => {
controller.error(new Error('foo'))
},
})

readBytesFromStream(
stream,
(error, bytes, limitExceeded) => {
expect(error).toBeDefined()
expect(bytes).toBeUndefined()
expect(limitExceeded).toBeUndefined()
done()
},
{
bytesLimit: Number.POSITIVE_INFINITY,
try {
await readBytesFromStream(stream, {
collectStreamBody: true,
}
)
})
fail('Should have thrown an error')
} catch (error) {
expect(error).toEqual(jasmine.any(Error))
}
})

it('should handle rejection error on cancel', (done) => {
it('should handle rejection error on cancel', async () => {
const stream = new ReadableStream({
pull: (controller) => controller.enqueue(new TextEncoder().encode('f')),
cancel: () => Promise.reject(new Error('foo')),
})

readBytesFromStream(
stream,
(error, bytes) => {
expect(error).toBeUndefined()
expect(bytes).toBeDefined()
done()
start: (controller) => {
controller.enqueue(new TextEncoder().encode('f'))
controller.close()
},
{
bytesLimit: 64,
collectStreamBody: true,
}
)
})

it('reads a limited amount of bytes from the response', (done) => {
// Creates a response that stream "f" indefinitely, one byte at a time
const cancelSpy = jasmine.createSpy()
const pullSpy = jasmine.createSpy().and.callFake((controller: ReadableStreamDefaultController<Uint8Array>) => {
controller.enqueue(new TextEncoder().encode('f'))
cancel: () => Promise.reject(new Error('foo')),
})

const bytesLimit = 64

const stream = new ReadableStream({
pull: pullSpy,
cancel: cancelSpy,
const bytes = await readBytesFromStream(stream, {
collectStreamBody: true,
})

readBytesFromStream(
stream,
() => {
expect(pullSpy).toHaveBeenCalledTimes(
// readBytesFromStream may read one more byte than necessary to make sure it exceeds the limit
bytesLimit + 1
)
expect(cancelSpy).toHaveBeenCalledTimes(1)
done()
},
{
bytesLimit,
collectStreamBody: true,
}
)
expect(bytes).toBeDefined()
})
})
Loading