Skip to content

Commit 1a4b03f

Browse files
mortargrindrgaignault
authored andcommitted
feat(graphql): infer operationType from the req. body
1 parent 8539830 commit 1a4b03f

3 files changed

Lines changed: 178 additions & 0 deletions

File tree

packages/rum-core/src/domain/resource/graphql.spec.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,91 @@ describe('GraphQL detection and metadata extraction', () => {
267267
)
268268
expect(result).toBeUndefined()
269269
})
270+
271+
it('should use operationType from body when query field is absent', () => {
272+
const requestBody = JSON.stringify({
273+
operationName: 'CreateUser',
274+
operationType: 'mutation',
275+
variables: { name: 'Alice' },
276+
})
277+
278+
const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false)
279+
280+
expect(result).toEqual({
281+
operationType: 'mutation',
282+
operationName: 'CreateUser',
283+
variables: '{"name":"Alice"}',
284+
payload: undefined,
285+
})
286+
})
287+
288+
it('should prefer operationType derived from query field over explicit operationType in body', () => {
289+
const requestBody = JSON.stringify({
290+
query: 'query GetUser { user { id } }',
291+
operationName: 'GetUser',
292+
operationType: 'mutation',
293+
})
294+
295+
const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false)
296+
297+
expect(result?.operationType).toBe('query')
298+
})
299+
300+
it('should fall back to operationType from body when query has no parseable operation type', () => {
301+
const requestBody = JSON.stringify({
302+
query: '{ user { id } }',
303+
operationName: 'GetUser',
304+
operationType: 'query',
305+
})
306+
307+
const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false)
308+
309+
expect(result?.operationType).toBe('query')
310+
})
311+
312+
it('should ignore unknown operationType value from body', () => {
313+
const requestBody = JSON.stringify({
314+
operationName: 'DoSomething',
315+
operationType: 'foobar',
316+
})
317+
318+
const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false)
319+
320+
expect(result?.operationType).toBeUndefined()
321+
})
322+
323+
it('should ignore mixed-case operationType value from body', () => {
324+
const requestBody = JSON.stringify({
325+
operationName: 'DoSomething',
326+
operationType: 'Mutation',
327+
})
328+
329+
const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false)
330+
331+
expect(result?.operationType).toBeUndefined()
332+
})
333+
334+
it('should use operationType from URL params for GET requests when query param is absent', () => {
335+
const url = 'http://example.com/graphql?operationName=CreateUser&operationType=mutation'
336+
337+
const result = extractGraphQlRequestMetadata({ method: 'GET', url, requestBody: undefined }, false)
338+
339+
expect(result).toEqual({
340+
operationType: 'mutation',
341+
operationName: 'CreateUser',
342+
variables: undefined,
343+
payload: undefined,
344+
})
345+
})
346+
347+
it('should prefer operationType derived from query URL param over explicit operationType URL param for GET requests', () => {
348+
const url =
349+
'http://example.com/graphql?query=mutation%20CreateUser%20%7B%20createUser%20%7D&operationType=query'
350+
351+
const result = extractGraphQlRequestMetadata({ method: 'GET', url, requestBody: undefined }, false)
352+
353+
expect(result?.operationType).toBe('mutation')
354+
})
270355
})
271356

272357
describe('request payload truncation', () => {

packages/rum-core/src/domain/resource/graphql.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import type { RequestCompleteEvent } from '../requestCollection'
77
*/
88
const GRAPHQL_PAYLOAD_LIMIT = 32 * ONE_KIBI_BYTE
99

10+
1011
interface RawGraphQlMetadata {
1112
query?: string
1213
operationName?: string
1314
variables?: string
15+
operationType?: string
1416
}
1517

1618
export interface GraphQlError {
@@ -108,6 +110,7 @@ function extractFromBody(requestBody: unknown): RawGraphQlMetadata | undefined {
108110
query?: string
109111
operationName?: string
110112
variables?: unknown
113+
operationType?: string
111114
}>(requestBody)
112115

113116
if (!graphqlBody) {
@@ -118,6 +121,7 @@ function extractFromBody(requestBody: unknown): RawGraphQlMetadata | undefined {
118121
query: graphqlBody.query,
119122
operationName: graphqlBody.operationName,
120123
variables: graphqlBody.variables ? JSON.stringify(graphqlBody.variables) : undefined,
124+
operationType: graphqlBody.operationType,
121125
}
122126
}
123127

@@ -130,6 +134,7 @@ function extractFromUrlQueryParams(url: string): RawGraphQlMetadata {
130134
query: searchParams.get('query') || undefined,
131135
operationName: searchParams.get('operationName') || undefined,
132136
variables,
137+
operationType: searchParams.get('operationType') || undefined,
133138
}
134139
}
135140

@@ -146,6 +151,13 @@ function sanitizeGraphQlMetadata(rawMetadata: RawGraphQlMetadata, trackPayload:
146151
}
147152
}
148153

154+
if (!operationType) {
155+
const bodyType = rawMetadata.operationType
156+
if (bodyType === 'query' || bodyType === 'mutation' || bodyType === 'subscription') {
157+
operationType = bodyType
158+
}
159+
}
160+
149161
if (rawMetadata.variables) {
150162
variables = rawMetadata.variables
151163
}

test/e2e/scenario/rum/graphql.scenario.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,85 @@ test.describe('GraphQL tracking', () => {
188188
payload: undefined,
189189
})
190190
})
191+
192+
createTest('use operationType from POST body when query field is absent')
193+
.withRum(buildGraphQlConfig())
194+
.run(async ({ intakeRegistry, flushEvents, page }) => {
195+
await page.evaluate(() =>
196+
window.fetch('/graphql', {
197+
method: 'POST',
198+
headers: { 'Content-Type': 'application/json' },
199+
body: JSON.stringify({
200+
operationName: 'CreateUser',
201+
operationType: 'mutation',
202+
variables: { name: 'Alice' },
203+
}),
204+
})
205+
)
206+
207+
await flushEvents()
208+
const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))!
209+
expect(resourceEvent).toBeDefined()
210+
expect(resourceEvent.resource.graphql).toEqual({
211+
operationType: 'mutation',
212+
operationName: 'CreateUser',
213+
variables: '{"name":"Alice"}',
214+
payload: undefined,
215+
})
216+
})
217+
218+
createTest('query field operationType takes precedence over explicit operationType in POST body')
219+
.withRum(buildGraphQlConfig())
220+
.run(async ({ intakeRegistry, flushEvents, page }) => {
221+
await page.evaluate(() =>
222+
window.fetch('/graphql', {
223+
method: 'POST',
224+
headers: { 'Content-Type': 'application/json' },
225+
body: JSON.stringify({
226+
query: 'query GetUser { user { id } }',
227+
operationName: 'GetUser',
228+
operationType: 'mutation',
229+
}),
230+
})
231+
)
232+
233+
await flushEvents()
234+
const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))!
235+
expect(resourceEvent).toBeDefined()
236+
expect(resourceEvent.resource.graphql?.operationType).toBe('query')
237+
})
238+
239+
createTest('use operationType from GET URL params when query param is absent')
240+
.withRum(buildGraphQlConfig())
241+
.run(async ({ intakeRegistry, flushEvents, page }) => {
242+
await page.evaluate(() => {
243+
const url = `/graphql?operationName=CreateUser&operationType=mutation&variables=${encodeURIComponent(JSON.stringify({ name: 'Bob' }))}`
244+
return window.fetch(url, { method: 'GET' })
245+
})
246+
247+
await flushEvents()
248+
const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))!
249+
expect(resourceEvent).toBeDefined()
250+
expect(resourceEvent.resource.method).toBe('GET')
251+
expect(resourceEvent.resource.graphql).toEqual({
252+
operationType: 'mutation',
253+
operationName: 'CreateUser',
254+
variables: '{"name":"Bob"}',
255+
payload: undefined,
256+
})
257+
})
258+
259+
createTest('query param operationType takes precedence over explicit operationType GET URL param')
260+
.withRum(buildGraphQlConfig())
261+
.run(async ({ intakeRegistry, flushEvents, page }) => {
262+
await page.evaluate(() => {
263+
const url = `/graphql?query=${encodeURIComponent('query GetUser { user { id } }')}&operationName=GetUser&operationType=mutation`
264+
return window.fetch(url, { method: 'GET' })
265+
})
266+
267+
await flushEvents()
268+
const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))!
269+
expect(resourceEvent).toBeDefined()
270+
expect(resourceEvent.resource.graphql?.operationType).toBe('query')
271+
})
191272
})

0 commit comments

Comments
 (0)