diff --git a/packages/rum-core/src/domain/resource/graphql.spec.ts b/packages/rum-core/src/domain/resource/graphql.spec.ts index 2b55ea07f9..343b727ac5 100644 --- a/packages/rum-core/src/domain/resource/graphql.spec.ts +++ b/packages/rum-core/src/domain/resource/graphql.spec.ts @@ -267,6 +267,90 @@ describe('GraphQL detection and metadata extraction', () => { ) expect(result).toBeUndefined() }) + + it('should use operationType from body when query field is absent', () => { + const requestBody = JSON.stringify({ + operationName: 'CreateUser', + operationType: 'mutation', + variables: { name: 'Alice' }, + }) + + const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false) + + expect(result).toEqual({ + operationType: 'mutation', + operationName: 'CreateUser', + variables: '{"name":"Alice"}', + payload: undefined, + }) + }) + + it('should prefer operationType derived from query field over explicit operationType in body', () => { + const requestBody = JSON.stringify({ + query: 'query GetUser { user { id } }', + operationName: 'GetUser', + operationType: 'mutation', + }) + + const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false) + + expect(result?.operationType).toBe('query') + }) + + it('should fall back to operationType from body when query has no parseable operation type', () => { + const requestBody = JSON.stringify({ + query: '{ user { id } }', + operationName: 'GetUser', + operationType: 'query', + }) + + const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false) + + expect(result?.operationType).toBe('query') + }) + + it('should ignore unknown operationType value from body', () => { + const requestBody = JSON.stringify({ + operationName: 'DoSomething', + operationType: 'foobar', + }) + + const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false) + + expect(result?.operationType).toBeUndefined() + }) + + it('should ignore mixed-case operationType value from body', () => { + const requestBody = JSON.stringify({ + operationName: 'DoSomething', + operationType: 'Mutation', + }) + + const result = extractGraphQlRequestMetadata({ method: 'POST', url: '/graphql', requestBody }, false) + + expect(result?.operationType).toBeUndefined() + }) + + it('should use operationType from URL params for GET requests when query param is absent', () => { + const url = 'http://example.com/graphql?operationName=CreateUser&operationType=mutation' + + const result = extractGraphQlRequestMetadata({ method: 'GET', url, requestBody: undefined }, false) + + expect(result).toEqual({ + operationType: 'mutation', + operationName: 'CreateUser', + variables: undefined, + payload: undefined, + }) + }) + + it('should prefer operationType derived from query URL param over explicit operationType URL param for GET requests', () => { + const url = 'http://example.com/graphql?query=mutation%20CreateUser%20%7B%20createUser%20%7D&operationType=query' + + const result = extractGraphQlRequestMetadata({ method: 'GET', url, requestBody: undefined }, false) + + expect(result?.operationType).toBe('mutation') + }) }) describe('request payload truncation', () => { diff --git a/packages/rum-core/src/domain/resource/graphql.ts b/packages/rum-core/src/domain/resource/graphql.ts index 7227548ea7..c2200cae22 100644 --- a/packages/rum-core/src/domain/resource/graphql.ts +++ b/packages/rum-core/src/domain/resource/graphql.ts @@ -11,6 +11,7 @@ interface RawGraphQlMetadata { query?: string operationName?: string variables?: string + operationType?: string } export interface GraphQlError { @@ -108,6 +109,7 @@ function extractFromBody(requestBody: unknown): RawGraphQlMetadata | undefined { query?: string operationName?: string variables?: unknown + operationType?: string }>(requestBody) if (!graphqlBody) { @@ -118,6 +120,7 @@ function extractFromBody(requestBody: unknown): RawGraphQlMetadata | undefined { query: graphqlBody.query, operationName: graphqlBody.operationName, variables: graphqlBody.variables ? JSON.stringify(graphqlBody.variables) : undefined, + operationType: graphqlBody.operationType, } } @@ -130,6 +133,7 @@ function extractFromUrlQueryParams(url: string): RawGraphQlMetadata { query: searchParams.get('query') || undefined, operationName: searchParams.get('operationName') || undefined, variables, + operationType: searchParams.get('operationType') || undefined, } } @@ -146,6 +150,13 @@ function sanitizeGraphQlMetadata(rawMetadata: RawGraphQlMetadata, trackPayload: } } + if (!operationType) { + const bodyType = rawMetadata.operationType + if (bodyType === 'query' || bodyType === 'mutation' || bodyType === 'subscription') { + operationType = bodyType + } + } + if (rawMetadata.variables) { variables = rawMetadata.variables } diff --git a/test/e2e/scenario/rum/graphql.scenario.ts b/test/e2e/scenario/rum/graphql.scenario.ts index c574d07fc3..3fe56a4220 100644 --- a/test/e2e/scenario/rum/graphql.scenario.ts +++ b/test/e2e/scenario/rum/graphql.scenario.ts @@ -188,4 +188,85 @@ test.describe('GraphQL tracking', () => { payload: undefined, }) }) + + createTest('use operationType from POST body when query field is absent') + .withRum(buildGraphQlConfig()) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => + window.fetch('/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operationName: 'CreateUser', + operationType: 'mutation', + variables: { name: 'Alice' }, + }), + }) + ) + + await flushEvents() + const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))! + expect(resourceEvent).toBeDefined() + expect(resourceEvent.resource.graphql).toEqual({ + operationType: 'mutation', + operationName: 'CreateUser', + variables: '{"name":"Alice"}', + payload: undefined, + }) + }) + + createTest('query field operationType takes precedence over explicit operationType in POST body') + .withRum(buildGraphQlConfig()) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => + window.fetch('/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'query GetUser { user { id } }', + operationName: 'GetUser', + operationType: 'mutation', + }), + }) + ) + + await flushEvents() + const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))! + expect(resourceEvent).toBeDefined() + expect(resourceEvent.resource.graphql?.operationType).toBe('query') + }) + + createTest('use operationType from GET URL params when query param is absent') + .withRum(buildGraphQlConfig()) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + const url = `/graphql?operationName=CreateUser&operationType=mutation&variables=${encodeURIComponent(JSON.stringify({ name: 'Bob' }))}` + return window.fetch(url, { method: 'GET' }) + }) + + await flushEvents() + const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))! + expect(resourceEvent).toBeDefined() + expect(resourceEvent.resource.method).toBe('GET') + expect(resourceEvent.resource.graphql).toEqual({ + operationType: 'mutation', + operationName: 'CreateUser', + variables: '{"name":"Bob"}', + payload: undefined, + }) + }) + + createTest('query param operationType takes precedence over explicit operationType GET URL param') + .withRum(buildGraphQlConfig()) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + const url = `/graphql?query=${encodeURIComponent('query GetUser { user { id } }')}&operationName=GetUser&operationType=mutation` + return window.fetch(url, { method: 'GET' }) + }) + + await flushEvents() + const resourceEvent = intakeRegistry.rumResourceEvents.find((event) => event.resource.url.includes('/graphql'))! + expect(resourceEvent).toBeDefined() + expect(resourceEvent.resource.graphql?.operationType).toBe('query') + }) })