-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(browser): Add graphqlClientIntegration
#13783
base: develop
Are you sure you want to change the base?
Changes from 2 commits
aa0b588
4614a55
77c9fbc
20b6516
a304218
55e6f1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
const query = `query Test{ | ||
people { | ||
name | ||
pet | ||
} | ||
}`; | ||
|
||
const requestBody = JSON.stringify({ query }); | ||
|
||
fetch('http://sentry-test.io/foo', { | ||
method: 'POST', | ||
headers: { | ||
Accept: 'application/json', | ||
'Content-Type': 'application/json', | ||
}, | ||
body: requestBody, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { expect } from '@playwright/test'; | ||
import type { Event } from '@sentry/types'; | ||
|
||
import { sentryTest } from '../../../../utils/fixtures'; | ||
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; | ||
|
||
sentryTest.only('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { | ||
if (shouldSkipTracingTest()) { | ||
sentryTest.skip(); | ||
} | ||
|
||
const url = await getLocalTestPath({ testDir: __dirname }); | ||
|
||
await page.route('**/foo', route => { | ||
return route.fulfill({ | ||
status: 200, | ||
body: JSON.stringify({ | ||
people: [ | ||
{ name: 'Amy', pet: 'dog' }, | ||
{ name: 'Jay', pet: 'cat' }, | ||
], | ||
}), | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}); | ||
}); | ||
|
||
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url); | ||
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client'); | ||
|
||
expect(requestSpans).toHaveLength(1); | ||
|
||
expect(requestSpans![0]).toMatchObject({ | ||
description: 'POST http://sentry-test.io/foo (query Test)', | ||
parent_span_id: eventData.contexts?.trace?.span_id, | ||
span_id: expect.any(String), | ||
start_timestamp: expect.any(Number), | ||
timestamp: expect.any(Number), | ||
trace_id: eventData.contexts?.trace?.trace_id, | ||
status: 'ok', | ||
data: expect.objectContaining({ | ||
type: 'fetch', | ||
'http.method': 'POST', | ||
'http.url': 'http://sentry-test.io/foo', | ||
url: 'http://sentry-test.io/foo', | ||
'server.address': 'sentry-test.io', | ||
'sentry.op': 'http.client', | ||
'sentry.origin': 'auto.http.browser', | ||
body: { | ||
query: expect.any(String), | ||
}, | ||
}), | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import * as Sentry from '@sentry/browser'; | ||
|
||
window.Sentry = Sentry; | ||
|
||
Sentry.init({ | ||
dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
integrations: [ | ||
Sentry.browserTracingIntegration(), | ||
Sentry.graphqlClientIntegration({ | ||
endpoints: ['http://sentry-test.io/foo'], | ||
}), | ||
], | ||
tracesSampleRate: 1, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
const xhr = new XMLHttpRequest(); | ||
|
||
xhr.open('POST', 'http://sentry-test.io/foo'); | ||
xhr.setRequestHeader('Accept', 'application/json'); | ||
xhr.setRequestHeader('Content-Type', 'application/json'); | ||
|
||
const query = `query Test{ | ||
|
||
people { | ||
name | ||
pet | ||
} | ||
}`; | ||
|
||
const requestBody = JSON.stringify({ query }); | ||
xhr.send(requestBody); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { expect } from '@playwright/test'; | ||
import type { Event } from '@sentry/types'; | ||
|
||
import { sentryTest } from '../../../../utils/fixtures'; | ||
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; | ||
|
||
sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { | ||
if (shouldSkipTracingTest()) { | ||
sentryTest.skip(); | ||
} | ||
|
||
const url = await getLocalTestPath({ testDir: __dirname }); | ||
|
||
await page.route('**/foo', route => { | ||
return route.fulfill({ | ||
status: 200, | ||
body: JSON.stringify({ | ||
people: [ | ||
{ name: 'Amy', pet: 'dog' }, | ||
{ name: 'Jay', pet: 'cat' }, | ||
], | ||
}), | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}); | ||
}); | ||
|
||
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url); | ||
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client'); | ||
|
||
expect(requestSpans).toHaveLength(1); | ||
|
||
expect(requestSpans![0]).toMatchObject({ | ||
description: 'POST http://sentry-test.io/foo (query Test)', | ||
parent_span_id: eventData.contexts?.trace?.span_id, | ||
span_id: expect.any(String), | ||
start_timestamp: expect.any(Number), | ||
timestamp: expect.any(Number), | ||
trace_id: eventData.contexts?.trace?.trace_id, | ||
status: 'ok', | ||
data: { | ||
type: 'xhr', | ||
'http.method': 'POST', | ||
'http.url': 'http://sentry-test.io/foo', | ||
url: 'http://sentry-test.io/foo', | ||
'server.address': 'sentry-test.io', | ||
'sentry.op': 'http.client', | ||
'sentry.origin': 'auto.http.browser', | ||
body: { | ||
query: expect.any(String), | ||
}, | ||
}, | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { | ||
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, | ||
SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
SEMANTIC_ATTRIBUTE_URL_FULL, | ||
defineIntegration, | ||
spanToJSON, | ||
} from '@sentry/core'; | ||
import type { IntegrationFn } from '@sentry/types'; | ||
import { parseGraphQLQuery } from '@sentry/utils'; | ||
|
||
interface GraphQLClientOptions { | ||
endpoints: Array<string>; | ||
} | ||
|
||
const INTEGRATION_NAME = 'GraphQLClient'; | ||
|
||
const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { | ||
return { | ||
name: INTEGRATION_NAME, | ||
setup(client) { | ||
client.on('spanStart', span => { | ||
client.emit('outgoingRequestSpanStart', span); | ||
}); | ||
|
||
client.on('outgoingRequestSpanStart', span => { | ||
const spanJSON = spanToJSON(span); | ||
|
||
const spanAttributes = spanJSON.data || {}; | ||
|
||
const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]; | ||
const isHttpClientSpan = spanOp === 'http.client'; | ||
|
||
if (isHttpClientSpan) { | ||
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; | ||
|
||
const { endpoints } = options; | ||
|
||
const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); | ||
|
||
if (isTracedGraphqlEndpoint) { | ||
const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; | ||
const graphqlBody = spanAttributes['body']; | ||
|
||
// Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request | ||
const graphqlQuery = graphqlBody && (graphqlBody['query'] as string); | ||
const graphqlOperationName = graphqlBody && (graphqlBody['operationName'] as string); | ||
|
||
const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); | ||
const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; | ||
|
||
span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`); | ||
} | ||
} | ||
}); | ||
}, | ||
}; | ||
}) satisfies IntegrationFn; | ||
|
||
/** | ||
* GraphQL Client integration for the browser. | ||
*/ | ||
export const graphqlClientIntegration = defineIntegration(_graphqlClientIntegration); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,6 +28,7 @@ import { | |
browserPerformanceTimeOrigin, | ||
dynamicSamplingContextToSentryBaggageHeader, | ||
generateSentryTraceHeader, | ||
getGraphQLRequestPayload, | ||
parseUrl, | ||
stringMatchesSomePattern, | ||
} from '@sentry/utils'; | ||
|
@@ -362,6 +363,8 @@ export function xhrCallback( | |
|
||
const hasParent = !!getActiveSpan(); | ||
|
||
const graphqlRequest = getGraphQLRequestPayload(sentryXhrData.body as string); | ||
|
||
const span = | ||
shouldCreateSpanResult && hasParent | ||
? startInactiveSpan({ | ||
|
@@ -374,6 +377,7 @@ export function xhrCallback( | |
'server.address': host, | ||
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', | ||
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', | ||
body: graphqlRequest, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of adding this here, after the span was started, in this file do: client.emit('outgoingRequestSpanStart', span, { body }); So the hook receives the body as second argument, and then in the integration we can use the body and add it to the span or use it otherwise. This way, this is not added to all spans, as we do not want to generally capture this for every span. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get what you mean now! Will implement |
||
}, | ||
}) | ||
: new SentryNonRecordingSpan(); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was maybe a bit confusingly explained by me - I would not do this, so I'd remove these lines here. I'll leave a comment further below where/how I would suggest to do this!