Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions packages/core/src/domain/telemetry/telemetryEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
* Whether GraphQL payload tracking is used for at least one GraphQL endpoint
*/
use_track_graph_ql_payload?: boolean
/**
* Whether GraphQL response errors tracking is used for at least one GraphQL endpoint
*/
use_track_graph_ql_response_errors?: boolean
/**
* A list of selected tracing propagators
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -524,8 +524,8 @@ describe('validateAndBuildRumConfiguration', () => {
allowedGraphQlUrls: ['https://api.example.com/graphql', '/graphql'],
})!
expect(configuration.allowedGraphQlUrls).toEqual([
{ match: 'https://api.example.com/graphql', trackPayload: false },
{ match: '/graphql', trackPayload: false },
{ match: 'https://api.example.com/graphql', trackPayload: false, trackResponseErrors: false },
{ match: '/graphql', trackPayload: false, trackResponseErrors: false },
])
})

Expand All @@ -535,8 +535,8 @@ describe('validateAndBuildRumConfiguration', () => {
allowedGraphQlUrls: [{ match: /\/graphql$/i }, { match: 'https://api.example.com/graphql' }],
})!
expect(configuration.allowedGraphQlUrls).toEqual([
{ match: /\/graphql$/i, trackPayload: false },
{ match: 'https://api.example.com/graphql', trackPayload: false },
{ match: /\/graphql$/i, trackPayload: false, trackResponseErrors: false },
{ match: 'https://api.example.com/graphql', trackPayload: false, trackResponseErrors: false },
])
})

Expand All @@ -546,15 +546,29 @@ describe('validateAndBuildRumConfiguration', () => {
...DEFAULT_INIT_CONFIGURATION,
allowedGraphQlUrls: [{ match: customMatcher }],
})!
expect(configuration.allowedGraphQlUrls).toEqual([{ match: customMatcher, trackPayload: false }])
expect(configuration.allowedGraphQlUrls).toEqual([
{ match: customMatcher, trackPayload: false, trackResponseErrors: false },
])
})

it('should accept GraphQL options with trackPayload', () => {
const configuration = validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
allowedGraphQlUrls: [{ match: '/graphql', trackPayload: true }],
})!
expect(configuration.allowedGraphQlUrls).toEqual([{ match: '/graphql', trackPayload: true }])
expect(configuration.allowedGraphQlUrls).toEqual([
{ match: '/graphql', trackPayload: true, trackResponseErrors: false },
])
})

it('should accept GraphQL options with trackResponseErrors', () => {
const configuration = validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
allowedGraphQlUrls: [{ match: '/graphql', trackResponseErrors: true }],
})!
expect(configuration.allowedGraphQlUrls).toEqual([
{ match: '/graphql', trackResponseErrors: true, trackPayload: false },
])
})

it('should reject invalid values', () => {
Expand Down Expand Up @@ -621,6 +635,7 @@ describe('serializeRumConfiguration', () => {
| MapRumInitConfigurationKey<keyof RumInitConfiguration>
| 'selected_tracing_propagators'
| 'use_track_graph_ql_payload'
| 'use_track_graph_ql_response_errors'
> = serializeRumConfiguration(exhaustiveRumInitConfiguration)

expect(serializedConfiguration).toEqual({
Expand All @@ -632,6 +647,7 @@ describe('serializeRumConfiguration', () => {
use_allowed_tracing_urls: true,
use_allowed_graph_ql_urls: true,
use_track_graph_ql_payload: false,
use_track_graph_ql_response_errors: false,
selected_tracing_propagators: ['tracecontext', 'datadog'],
use_excluded_activity_urls: true,
track_user_interactions: true,
Expand Down
17 changes: 16 additions & 1 deletion packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export type FeatureFlagsForEvents = 'vital' | 'action' | 'long_task' | 'resource
export interface GraphQlUrlOption {
match: MatchOption
trackPayload?: boolean
trackResponseErrors?: boolean
}

export interface RumConfiguration extends Configuration {
Expand Down Expand Up @@ -461,11 +462,12 @@ function validateAndBuildGraphQlOptions(initConfiguration: RumInitConfiguration)

initConfiguration.allowedGraphQlUrls.forEach((option) => {
if (isMatchOption(option)) {
graphQlOptions.push({ match: option, trackPayload: false })
graphQlOptions.push({ match: option, trackPayload: false, trackResponseErrors: false })
} else if (option && typeof option === 'object' && 'match' in option && isMatchOption(option.match)) {
graphQlOptions.push({
match: option.match,
trackPayload: !!option.trackPayload,
trackResponseErrors: !!option.trackResponseErrors,
})
}
})
Expand All @@ -485,6 +487,18 @@ function hasGraphQlPayloadTracking(allowedGraphQlUrls: RumInitConfiguration['all
)
}

function hasGraphQlResponseErrorsTracking(allowedGraphQlUrls: RumInitConfiguration['allowedGraphQlUrls']): boolean {
return (
isNonEmptyArray(allowedGraphQlUrls) &&
allowedGraphQlUrls.some((option) => {
if (typeof option === 'object' && 'trackResponseErrors' in option) {
return !!option.trackResponseErrors
}
return false
})
)
}

export function serializeRumConfiguration(configuration: RumInitConfiguration) {
const baseSerializedConfiguration = serializeConfiguration(configuration)

Expand All @@ -498,6 +512,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
use_allowed_tracing_urls: isNonEmptyArray(configuration.allowedTracingUrls),
use_allowed_graph_ql_urls: isNonEmptyArray(configuration.allowedGraphQlUrls),
use_track_graph_ql_payload: hasGraphQlPayloadTracking(configuration.allowedGraphQlUrls),
use_track_graph_ql_response_errors: hasGraphQlResponseErrorsTracking(configuration.allowedGraphQlUrls),
selected_tracing_propagators: getSelectedTracingPropagators(configuration),
default_privacy_level: configuration.defaultPrivacyLevel,
enable_privacy_for_action_name: configuration.enablePrivacyForActionName,
Expand Down
69 changes: 68 additions & 1 deletion packages/rum-core/src/domain/requestCollection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('collect fetch', () => {
context.spanId = createSpanIdentifier()
},
}
;({ stop: stopFetchTracking } = trackFetch(lifeCycle, tracerStub as Tracer))
;({ stop: stopFetchTracking } = trackFetch(lifeCycle, mockRumConfiguration(), tracerStub as Tracer))

fetch = window.fetch as MockFetch

Expand Down Expand Up @@ -335,3 +335,70 @@ describe('collect xhr', () => {
})
})
})

describe('GraphQL response errors tracking', () => {
const FAKE_GRAPHQL_URL = 'http://fake-url/graphql'

it('should extract GraphQL errors when trackResponseErrors is enabled', (done) => {
const mockFetchManager = mockFetch()
const completeSpy = jasmine.createSpy('requestComplete')
const lifeCycle = new LifeCycle()
lifeCycle.subscribe(LifeCycleEventType.REQUEST_COMPLETED, completeSpy)

const configuration = mockRumConfiguration({
allowedGraphQlUrls: [{ match: /\/graphql$/, trackResponseErrors: true }],
})
const tracerStub: Partial<Tracer> = { clearTracingIfNeeded, traceFetch: jasmine.createSpy() }
const { stop } = trackFetch(lifeCycle, configuration, tracerStub as Tracer)
registerCleanupTask(stop)

const fetch = window.fetch as MockFetch
fetch(FAKE_GRAPHQL_URL, {
method: 'POST',
body: JSON.stringify({ query: 'query Test { test }' }),
}).resolveWith({
status: 200,
responseText: JSON.stringify({
data: null,
errors: [{ message: 'Not found' }, { message: 'Unauthorized' }],
}),
})

mockFetchManager.whenAllComplete(() => {
const request = completeSpy.calls.argsFor(0)[0]
expect(request.graphqlErrorsCount).toBe(2)
expect(request.graphqlErrors).toEqual([{ message: 'Not found' }, { message: 'Unauthorized' }])
done()
})
})

it('should not extract GraphQL errors when trackResponseErrors is false', (done) => {
const mockFetchManager = mockFetch()
const completeSpy = jasmine.createSpy('requestComplete')
const lifeCycle = new LifeCycle()
lifeCycle.subscribe(LifeCycleEventType.REQUEST_COMPLETED, completeSpy)

const configuration = mockRumConfiguration({
allowedGraphQlUrls: [{ match: /\/graphql$/, trackResponseErrors: false }],
})
const tracerStub: Partial<Tracer> = { clearTracingIfNeeded, traceFetch: jasmine.createSpy() }
const { stop } = trackFetch(lifeCycle, configuration, tracerStub as Tracer)
registerCleanupTask(stop)

const fetch = window.fetch as MockFetch
fetch(FAKE_GRAPHQL_URL, {
method: 'POST',
body: JSON.stringify({ query: 'query Test { test }' }),
}).resolveWith({
status: 200,
responseText: JSON.stringify({ errors: [{ message: 'Error' }] }),
})

mockFetchManager.whenAllComplete(() => {
const request = completeSpy.calls.argsFor(0)[0]
expect(request.graphqlErrorsCount).toBeUndefined()
expect(request.graphqlErrors).toBeUndefined()
done()
})
})
})
98 changes: 62 additions & 36 deletions packages/rum-core/src/domain/requestCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { isAllowedRequestUrl } from './resource/resourceUtils'
import type { Tracer } from './tracing/tracer'
import { startTracer } from './tracing/tracer'
import type { SpanIdentifier, TraceIdentifier } from './tracing/identifier'
import type { GraphQlError } from './resource/graphql'
import { findGraphQlConfiguration, parseGraphQlResponse } from './resource/graphql'

export interface CustomContext {
requestIndex: number
Expand Down Expand Up @@ -60,6 +62,8 @@ export interface RequestCompleteEvent {
isAborted: boolean
handlingStack?: string
body?: unknown
graphqlErrorsCount?: number
graphqlErrors?: GraphQlError[]
}

let nextRequestIndex = 1
Expand All @@ -73,7 +77,7 @@ export function startRequestCollection(
) {
const tracer = startTracer(configuration, sessionManager, userContext, accountContext)
trackXhr(lifeCycle, configuration, tracer)
trackFetch(lifeCycle, tracer)
trackFetch(lifeCycle, configuration, tracer)
}

export function trackXhr(lifeCycle: LifeCycle, configuration: RumConfiguration, tracer: Tracer) {
Expand Down Expand Up @@ -118,7 +122,7 @@ export function trackXhr(lifeCycle: LifeCycle, configuration: RumConfiguration,
return { stop: () => subscription.unsubscribe() }
}

export function trackFetch(lifeCycle: LifeCycle, tracer: Tracer) {
export function trackFetch(lifeCycle: LifeCycle, configuration: RumConfiguration, tracer: Tracer) {
const subscription = initFetchObservable().subscribe((rawContext) => {
const context = rawContext as RumFetchResolveContext | RumFetchStartContext
if (!isAllowedRequestUrl(context.url)) {
Expand All @@ -136,28 +140,34 @@ export function trackFetch(lifeCycle: LifeCycle, tracer: Tracer) {
})
break
case 'resolve':
waitForResponseToComplete(context, (duration: Duration) => {
tracer.clearTracingIfNeeded(context)
lifeCycle.notify(LifeCycleEventType.REQUEST_COMPLETED, {
duration,
method: context.method,
requestIndex: context.requestIndex,
responseType: context.responseType,
spanId: context.spanId,
startClocks: context.startClocks,
status: context.status,
traceId: context.traceId,
traceSampled: context.traceSampled,
type: RequestType.FETCH,
url: context.url,
response: context.response,
init: context.init,
input: context.input,
isAborted: context.isAborted,
handlingStack: context.handlingStack,
body: context.init?.body,
})
})
waitForResponseToComplete(
context,
configuration,
(duration: Duration, graphqlErrorsCount?: number, graphqlErrors?: GraphQlError[]) => {
tracer.clearTracingIfNeeded(context)
lifeCycle.notify(LifeCycleEventType.REQUEST_COMPLETED, {
duration,
method: context.method,
requestIndex: context.requestIndex,
responseType: context.responseType,
spanId: context.spanId,
startClocks: context.startClocks,
status: context.status,
traceId: context.traceId,
traceSampled: context.traceSampled,
type: RequestType.FETCH,
url: context.url,
response: context.response,
init: context.init,
input: context.input,
isAborted: context.isAborted,
handlingStack: context.handlingStack,
body: context.init?.body,
graphqlErrorsCount,
graphqlErrors,
})
}
)
break
}
})
Expand All @@ -170,21 +180,37 @@ function getNextRequestIndex() {
return result
}

function waitForResponseToComplete(context: RumFetchResolveContext, callback: (duration: Duration) => void) {
function waitForResponseToComplete(
context: RumFetchResolveContext,
configuration: RumConfiguration,
callback: (duration: Duration, graphqlErrorsCount?: number, graphqlErrors?: GraphQlError[]) => void
) {
const clonedResponse = context.response && tryToClone(context.response)
if (!clonedResponse || !clonedResponse.body) {
// do not try to wait for the response if the clone failed, fetch error or null body
callback(elapsed(context.startClocks.timeStamp, timeStampNow()))
} else {
readBytesFromStream(
clonedResponse.body,
() => {
callback(elapsed(context.startClocks.timeStamp, timeStampNow()))
},
{
bytesLimit: Number.POSITIVE_INFINITY,
collectStreamBody: false,
}
)
return
}

const graphQlConfig = findGraphQlConfiguration(context.url, configuration)
const shouldExtractGraphQlErrors = graphQlConfig?.trackResponseErrors

readBytesFromStream(
clonedResponse.body,
(error?: Error, bytes?: Uint8Array) => {
const duration = elapsed(context.startClocks.timeStamp, timeStampNow())

if (shouldExtractGraphQlErrors && !error && bytes) {
parseGraphQlResponse(new TextDecoder().decode(bytes), (errorsCount, errors) => {
callback(duration, errorsCount, errors)
})
} else {
callback(duration)
}
},
{
bytesLimit: Number.POSITIVE_INFINITY,
collectStreamBody: shouldExtractGraphQlErrors,
}
)
}
Loading