Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
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', trackPayload: false, trackResponseErrors: true },
])
})

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
63 changes: 62 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,64 @@ describe('collect xhr', () => {
})
})
})

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

function setupGraphQlFetchTest(trackResponseErrors: boolean) {
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 }],
})
const tracerStub: Partial<Tracer> = { clearTracingIfNeeded, traceFetch: jasmine.createSpy() }
const { stop } = trackFetch(lifeCycle, configuration, tracerStub as Tracer)
registerCleanupTask(stop)

return { mockFetchManager, completeSpy, fetch: window.fetch as MockFetch }
}

it('should extract GraphQL errors when trackResponseErrors is enabled', (done) => {
const { mockFetchManager, completeSpy, fetch } = setupGraphQlFetchTest(true)

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, completeSpy, fetch } = setupGraphQlFetchTest(false)

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()
})
})
})
85 changes: 66 additions & 19 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 All @@ -32,9 +34,16 @@ export interface CustomContext {
traceSampled?: boolean
}
export interface RumFetchStartContext extends FetchStartContext, CustomContext {}
export interface RumFetchResolveContext extends FetchResolveContext, CustomContext {}
export interface RumFetchResolveContext extends FetchResolveContext, CustomContext {
duration?: Duration
graphqlErrorsCount?: number
graphqlErrors?: GraphQlError[]
}
export interface RumXhrStartContext extends XhrStartContext, CustomContext {}
export interface RumXhrCompleteContext extends XhrCompleteContext, CustomContext {}
export interface RumXhrCompleteContext extends XhrCompleteContext, CustomContext {
graphqlErrorsCount?: number
graphqlErrors?: GraphQlError[]
}

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

let nextRequestIndex = 1
Expand All @@ -73,7 +84,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 All @@ -94,6 +105,7 @@ export function trackXhr(lifeCycle: LifeCycle, configuration: RumConfiguration,
})
break
case 'complete':
extractGraphQlErrorsFromXhr(context, configuration)
tracer.clearTracingIfNeeded(context)
lifeCycle.notify(LifeCycleEventType.REQUEST_COMPLETED, {
duration: context.duration,
Expand All @@ -110,6 +122,8 @@ export function trackXhr(lifeCycle: LifeCycle, configuration: RumConfiguration,
isAborted: context.isAborted,
handlingStack: context.handlingStack,
body: context.body,
graphqlErrorsCount: context.graphqlErrorsCount,
graphqlErrors: context.graphqlErrors,
})
break
}
Expand All @@ -118,7 +132,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,10 +150,10 @@ export function trackFetch(lifeCycle: LifeCycle, tracer: Tracer) {
})
break
case 'resolve':
waitForResponseToComplete(context, (duration: Duration) => {
waitForFetchResponseAndExtractGraphQlErrors(context, configuration, () => {
tracer.clearTracingIfNeeded(context)
lifeCycle.notify(LifeCycleEventType.REQUEST_COMPLETED, {
duration,
duration: context.duration!,
method: context.method,
requestIndex: context.requestIndex,
responseType: context.responseType,
Expand All @@ -156,6 +170,8 @@ export function trackFetch(lifeCycle: LifeCycle, tracer: Tracer) {
isAborted: context.isAborted,
handlingStack: context.handlingStack,
body: context.init?.body,
graphqlErrorsCount: context.graphqlErrorsCount,
graphqlErrors: context.graphqlErrors,
})
})
break
Expand All @@ -170,21 +186,52 @@ function getNextRequestIndex() {
return result
}

function waitForResponseToComplete(context: RumFetchResolveContext, callback: (duration: Duration) => void) {
function extractGraphQlErrorsFromXhr(context: RumXhrCompleteContext, configuration: RumConfiguration) {
const graphQlConfig = findGraphQlConfiguration(context.url, configuration)
if (!graphQlConfig?.trackResponseErrors || !context.xhr || typeof context.xhr.response !== 'string') {
return
}

parseGraphQlResponse(context.xhr.response, (graphqlErrorsCount, graphqlErrors) => {
context.graphqlErrorsCount = graphqlErrorsCount
context.graphqlErrors = graphqlErrors
})
}

function waitForFetchResponseAndExtractGraphQlErrors(
context: RumFetchResolveContext,
configuration: RumConfiguration,
onComplete: () => 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,
}
)
context.duration = elapsed(context.startClocks.timeStamp, timeStampNow())
onComplete()
return
}

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

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

if (shouldExtractGraphQlErrors && !error && bytes) {
parseGraphQlResponse(new TextDecoder().decode(bytes), (graphqlErrorsCount, graphqlErrors) => {
context.graphqlErrorsCount = graphqlErrorsCount
context.graphqlErrors = graphqlErrors
onComplete()
})
} else {
onComplete()
}
},
{
bytesLimit: Number.POSITIVE_INFINITY,
collectStreamBody: shouldExtractGraphQlErrors,
}
)
}
Loading