Skip to content

Commit 2b96c83

Browse files
Integrate romanG/graph-ql-tracking (#3805) into staging-36
Integrated commit sha: f396a31 Co-authored-by: rgaignault <[email protected]>
2 parents 91ac92e + f396a31 commit 2b96c83

File tree

9 files changed

+493
-3
lines changed

9 files changed

+493
-3
lines changed

packages/core/src/domain/telemetry/telemetryEvent.types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
205205
* Whether the allowed tracing urls list is used
206206
*/
207207
use_allowed_tracing_urls?: boolean
208+
/**
209+
* Whether the allowed GraphQL urls list is used
210+
*/
211+
use_allowed_graph_ql_urls?: boolean
212+
/**
213+
* Whether GraphQL payload tracking is used for at least one GraphQL endpoint
214+
*/
215+
use_track_graph_ql_payload?: boolean
208216
/**
209217
* A list of selected tracing propagators
210218
*/
@@ -443,6 +451,18 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
443451
* The id of the remote configuration
444452
*/
445453
remote_configuration_id?: string
454+
/**
455+
* Whether a proxy is used for remote configuration
456+
*/
457+
use_remote_configuration_proxy?: boolean
458+
/**
459+
* The percentage of sessions with Profiling enabled
460+
*/
461+
profiling_sample_rate?: number
462+
/**
463+
* Whether trace baggage is propagated to child spans
464+
*/
465+
propagate_trace_baggage?: boolean
446466
[k: string]: unknown
447467
}
448468
[k: string]: unknown

packages/rum-core/src/domain/configuration/configuration.spec.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,60 @@ describe('validateAndBuildRumConfiguration', () => {
511511
expect(displayWarnSpy).toHaveBeenCalledOnceWith('trackFeatureFlagsForEvents should be an array')
512512
})
513513
})
514+
515+
describe('allowedGraphQlUrls', () => {
516+
it('defaults to an empty array', () => {
517+
const configuration = validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!
518+
expect(configuration.allowedGraphQlUrls).toEqual([])
519+
})
520+
521+
it('should accept string URLs', () => {
522+
const configuration = validateAndBuildRumConfiguration({
523+
...DEFAULT_INIT_CONFIGURATION,
524+
allowedGraphQlUrls: ['https://api.example.com/graphql', '/graphql'],
525+
})!
526+
expect(configuration.allowedGraphQlUrls).toEqual([
527+
{ match: 'https://api.example.com/graphql', trackPayload: false },
528+
{ match: '/graphql', trackPayload: false },
529+
])
530+
})
531+
532+
it('should accept MatchOption objects', () => {
533+
const configuration = validateAndBuildRumConfiguration({
534+
...DEFAULT_INIT_CONFIGURATION,
535+
allowedGraphQlUrls: [{ match: /\/graphql$/i }, { match: 'https://api.example.com/graphql' }],
536+
})!
537+
expect(configuration.allowedGraphQlUrls).toEqual([
538+
{ match: /\/graphql$/i, trackPayload: false },
539+
{ match: 'https://api.example.com/graphql', trackPayload: false },
540+
])
541+
})
542+
543+
it('should accept function matchers', () => {
544+
const customMatcher = (url: string) => url.includes('graphql')
545+
const configuration = validateAndBuildRumConfiguration({
546+
...DEFAULT_INIT_CONFIGURATION,
547+
allowedGraphQlUrls: [{ match: customMatcher }],
548+
})!
549+
expect(configuration.allowedGraphQlUrls).toEqual([{ match: customMatcher, trackPayload: false }])
550+
})
551+
552+
it('should accept GraphQL options with trackPayload', () => {
553+
const configuration = validateAndBuildRumConfiguration({
554+
...DEFAULT_INIT_CONFIGURATION,
555+
allowedGraphQlUrls: [{ match: '/graphql', trackPayload: true }],
556+
})!
557+
expect(configuration.allowedGraphQlUrls).toEqual([{ match: '/graphql', trackPayload: true }])
558+
})
559+
560+
it('should reject invalid values', () => {
561+
validateAndBuildRumConfiguration({
562+
...DEFAULT_INIT_CONFIGURATION,
563+
allowedGraphQlUrls: 'not-an-array' as any,
564+
})
565+
expect(displayWarnSpy).toHaveBeenCalledOnceWith('allowedGraphQlUrls should be an array')
566+
})
567+
})
514568
})
515569

516570
describe('serializeRumConfiguration', () => {
@@ -523,6 +577,7 @@ describe('serializeRumConfiguration', () => {
523577
workerUrl: './worker.js',
524578
compressIntakeRequests: true,
525579
allowedTracingUrls: ['foo'],
580+
allowedGraphQlUrls: ['bar'],
526581
traceSampleRate: 50,
527582
traceContextInjection: TraceContextInjection.ALL,
528583
defaultPrivacyLevel: 'allow',
@@ -546,7 +601,7 @@ describe('serializeRumConfiguration', () => {
546601

547602
type MapRumInitConfigurationKey<Key extends string> = Key extends keyof InitConfiguration
548603
? MapInitConfigurationKey<Key>
549-
: Key extends 'workerUrl' | 'allowedTracingUrls' | 'excludedActivityUrls'
604+
: Key extends 'workerUrl' | 'allowedTracingUrls' | 'excludedActivityUrls' | 'allowedGraphQlUrls'
550605
? `use_${CamelToSnakeCase<Key>}`
551606
: Key extends 'trackLongTasks'
552607
? 'track_long_task' // oops
@@ -561,7 +616,9 @@ describe('serializeRumConfiguration', () => {
561616
// By specifying the type here, we can ensure that serializeConfiguration is returning an
562617
// object containing all expected properties.
563618
const serializedConfiguration: ExtractTelemetryConfiguration<
564-
MapRumInitConfigurationKey<keyof RumInitConfiguration> | 'selected_tracing_propagators'
619+
| MapRumInitConfigurationKey<keyof RumInitConfiguration>
620+
| 'selected_tracing_propagators'
621+
| 'use_track_graph_ql_payload'
565622
> = serializeRumConfiguration(exhaustiveRumInitConfiguration)
566623

567624
expect(serializedConfiguration).toEqual({
@@ -570,6 +627,8 @@ describe('serializeRumConfiguration', () => {
570627
trace_sample_rate: 50,
571628
trace_context_injection: TraceContextInjection.ALL,
572629
use_allowed_tracing_urls: true,
630+
use_allowed_graph_ql_urls: true,
631+
use_track_graph_ql_payload: false,
573632
selected_tracing_propagators: ['tracecontext', 'datadog'],
574633
use_excluded_activity_urls: true,
575634
track_user_interactions: true,

packages/rum-core/src/domain/configuration/configuration.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,24 @@ export interface RumInitConfiguration extends InitConfiguration {
220220
* @defaultValue 0
221221
*/
222222
profilingSampleRate?: number | undefined
223+
224+
/**
225+
* A list of GraphQL endpoint URLs to track and enrich with GraphQL-specific metadata.
226+
*
227+
* @category Data Collection
228+
*/
229+
allowedGraphQlUrls?: Array<MatchOption | GraphQlUrlOption> | undefined
223230
}
224231

225232
export type HybridInitConfiguration = Omit<RumInitConfiguration, 'applicationId' | 'clientToken'>
226233

227234
export type FeatureFlagsForEvents = 'vital' | 'action' | 'long_task' | 'resource'
228235

236+
export interface GraphQlUrlOption {
237+
match: MatchOption
238+
trackPayload?: boolean
239+
}
240+
229241
export interface RumConfiguration extends Configuration {
230242
// Built from init configuration
231243
actionNameAttribute: string | undefined
@@ -254,6 +266,7 @@ export interface RumConfiguration extends Configuration {
254266
trackFeatureFlagsForEvents: FeatureFlagsForEvents[]
255267
profilingSampleRate: number
256268
propagateTraceBaggage: boolean
269+
allowedGraphQlUrls: GraphQlUrlOption[]
257270
}
258271

259272
export function validateAndBuildRumConfiguration(
@@ -288,6 +301,8 @@ export function validateAndBuildRumConfiguration(
288301
return
289302
}
290303

304+
const allowedGraphQlUrls = validateAndBuildGraphQlOptions(initConfiguration)
305+
291306
const baseConfiguration = validateAndBuildConfiguration(initConfiguration)
292307
if (!baseConfiguration) {
293308
return
@@ -329,6 +344,7 @@ export function validateAndBuildRumConfiguration(
329344
trackFeatureFlagsForEvents: initConfiguration.trackFeatureFlagsForEvents || [],
330345
profilingSampleRate: initConfiguration.profilingSampleRate ?? 0,
331346
propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage,
347+
allowedGraphQlUrls,
332348
...baseConfiguration,
333349
}
334350
}
@@ -386,6 +402,35 @@ function getSelectedTracingPropagators(configuration: RumInitConfiguration): Pro
386402
return Array.from(usedTracingPropagators)
387403
}
388404

405+
/**
406+
* Build GraphQL options from configuration
407+
*/
408+
function validateAndBuildGraphQlOptions(initConfiguration: RumInitConfiguration): GraphQlUrlOption[] {
409+
if (!initConfiguration.allowedGraphQlUrls) {
410+
return []
411+
}
412+
413+
if (!Array.isArray(initConfiguration.allowedGraphQlUrls)) {
414+
display.warn('allowedGraphQlUrls should be an array')
415+
return []
416+
}
417+
418+
const graphQlOptions: GraphQlUrlOption[] = []
419+
420+
initConfiguration.allowedGraphQlUrls.forEach((option) => {
421+
if (isMatchOption(option)) {
422+
graphQlOptions.push({ match: option, trackPayload: false })
423+
} else if (option && typeof option === 'object' && 'match' in option && isMatchOption(option.match)) {
424+
graphQlOptions.push({
425+
match: option.match,
426+
trackPayload: !!option.trackPayload,
427+
})
428+
}
429+
})
430+
431+
return graphQlOptions
432+
}
433+
389434
export function serializeRumConfiguration(configuration: RumInitConfiguration) {
390435
const baseSerializedConfiguration = serializeConfiguration(configuration)
391436

@@ -397,6 +442,16 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
397442
action_name_attribute: configuration.actionNameAttribute,
398443
use_allowed_tracing_urls:
399444
Array.isArray(configuration.allowedTracingUrls) && configuration.allowedTracingUrls.length > 0,
445+
use_allowed_graph_ql_urls:
446+
Array.isArray(configuration.allowedGraphQlUrls) && configuration.allowedGraphQlUrls.length > 0,
447+
use_track_graph_ql_payload:
448+
Array.isArray(configuration.allowedGraphQlUrls) &&
449+
configuration.allowedGraphQlUrls.some((option) => {
450+
if (typeof option === 'object' && 'trackPayload' in option) {
451+
return option.trackPayload === true
452+
}
453+
return false
454+
}),
400455
selected_tracing_propagators: getSelectedTracingPropagators(configuration),
401456
default_privacy_level: configuration.defaultPrivacyLevel,
402457
enable_privacy_for_action_name: configuration.enablePrivacyForActionName,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { mockRumConfiguration } from '../../../test'
2+
import { extractGraphQlMetadata, findGraphQlConfiguration } from './graphql'
3+
4+
describe('GraphQL detection and metadata extraction', () => {
5+
describe('findGraphQlConfiguration', () => {
6+
it('should detect GraphQL requests matching string URLs', () => {
7+
const configuration = mockRumConfiguration({
8+
allowedGraphQlUrls: [
9+
{ match: 'http://localhost/graphql', trackPayload: false },
10+
{ match: 'https://api.example.com/graphql', trackPayload: false },
11+
],
12+
})
13+
14+
expect(findGraphQlConfiguration('https://api.example.com/graphql', configuration)).toBeTruthy()
15+
expect(findGraphQlConfiguration('http://localhost/api', configuration)).toBeUndefined()
16+
})
17+
18+
it('should detect GraphQL requests matching regex patterns', () => {
19+
const configuration = mockRumConfiguration({
20+
allowedGraphQlUrls: [{ match: /\/graphql$/i, trackPayload: false }],
21+
})
22+
23+
expect(findGraphQlConfiguration('/api/graphql', configuration)).toBeTruthy()
24+
expect(findGraphQlConfiguration('/graphql/admin', configuration)).toBeUndefined()
25+
})
26+
27+
it('should detect GraphQL requests matching function matchers', () => {
28+
const configuration = mockRumConfiguration({
29+
allowedGraphQlUrls: [{ match: (url: string) => url.includes('gql'), trackPayload: false }],
30+
})
31+
32+
expect(findGraphQlConfiguration('/api/gql', configuration)).toBeTruthy()
33+
expect(findGraphQlConfiguration('/gql-endpoint', configuration)).toBeTruthy()
34+
expect(findGraphQlConfiguration('/api/rest', configuration)).toBeUndefined()
35+
})
36+
})
37+
38+
describe('extractGraphQlMetadata', () => {
39+
it('should extract query operation type and name', () => {
40+
const requestBody = JSON.stringify({
41+
query: 'query GetUser { user { id name } }',
42+
operationName: 'GetUser',
43+
variables: { id: '123' },
44+
})
45+
46+
const result = extractGraphQlMetadata(requestBody, true)
47+
48+
expect(result).toEqual({
49+
operationType: 'query',
50+
operationName: 'GetUser',
51+
variables: '{"id":"123"}',
52+
payload: 'query GetUser { user { id name } }',
53+
})
54+
})
55+
56+
it('should handle empty variables object', () => {
57+
const requestBody = JSON.stringify({
58+
query: 'query GetUser { user { id name } }',
59+
operationName: 'GetUser',
60+
variables: {},
61+
})
62+
63+
const result = extractGraphQlMetadata(requestBody, true)
64+
65+
expect(result).toEqual({
66+
operationType: 'query',
67+
operationName: 'GetUser',
68+
variables: '{}',
69+
payload: 'query GetUser { user { id name } }',
70+
})
71+
})
72+
73+
it('should handle null variables', () => {
74+
const requestBody = JSON.stringify({
75+
query: 'query GetUser { user { id name } }',
76+
operationName: 'GetUser',
77+
variables: null,
78+
})
79+
80+
const result = extractGraphQlMetadata(requestBody, true)
81+
82+
expect(result).toEqual({
83+
operationType: 'query',
84+
operationName: 'GetUser',
85+
variables: undefined,
86+
payload: 'query GetUser { user { id name } }',
87+
})
88+
})
89+
90+
it('should return undefined for invalid JSON', () => {
91+
const result = extractGraphQlMetadata('not valid json', true)
92+
expect(result).toBeUndefined()
93+
})
94+
95+
it('should return undefined for non-GraphQL request body', () => {
96+
const requestBody = JSON.stringify({ data: 'some data' })
97+
const result = extractGraphQlMetadata(requestBody, true)
98+
expect(result).toBeUndefined()
99+
})
100+
})
101+
102+
describe('payload truncation', () => {
103+
it('should not truncate payload under 32KB', () => {
104+
const shortQuery = 'query GetUser { user { id } }'
105+
const requestBody = JSON.stringify({
106+
query: shortQuery,
107+
})
108+
109+
const result = extractGraphQlMetadata(requestBody, true)
110+
111+
expect(result?.payload).toBe(shortQuery)
112+
})
113+
114+
it('should truncate payload over 32KB', () => {
115+
const longQuery = `query LongQuery { ${'a'.repeat(33000)} }`
116+
const requestBody = JSON.stringify({
117+
query: longQuery,
118+
})
119+
120+
const result = extractGraphQlMetadata(requestBody, true)
121+
122+
expect(result?.payload?.length).toBe(32768 + 3)
123+
expect(result?.payload?.endsWith('...')).toBe(true)
124+
expect(result?.payload?.startsWith('query LongQuery {')).toBe(true)
125+
})
126+
127+
it('should not include payload when trackPayload is false', () => {
128+
const requestBody = JSON.stringify({
129+
query: 'query GetUser { user { id name } }',
130+
operationName: 'GetUser',
131+
variables: { id: '123' },
132+
})
133+
134+
const result = extractGraphQlMetadata(requestBody, false)
135+
136+
expect(result).toEqual({
137+
operationType: 'query',
138+
operationName: 'GetUser',
139+
variables: '{"id":"123"}',
140+
payload: undefined,
141+
})
142+
})
143+
})
144+
})

0 commit comments

Comments
 (0)