Skip to content

Commit 6bc9ee5

Browse files
TBeerentbeerenclaudeandreiborza
authored andcommitted
feat(browser): Add support for GraphQL persisted operations (#18505)
Closes: #18499 --------- Co-authored-by: tbeeren <[email protected]> Co-authored-by: Claude Sonnet 4.5 <[email protected]> Co-authored-by: Andrei Borza <[email protected]>
1 parent 228a478 commit 6bc9ee5

File tree

10 files changed

+527
-26
lines changed

10 files changed

+527
-26
lines changed

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
## Unreleased
44

5+
### Important Changes
6+
7+
- **feat(browser): Add support for GraphQL persisted operations ([#18505](https://github.com/getsentry/sentry-javascript/pull/18505))**
8+
9+
The `graphqlClientIntegration` now supports GraphQL persisted operations (queries). When a persisted query is detected, the integration will capture the operation hash and version as span attributes:
10+
11+
- `graphql.persisted_query.hash.sha256` - The SHA-256 hash of the persisted query
12+
- `graphql.persisted_query.version` - The version of the persisted query protocol
13+
14+
Additionally, the `graphql.document` attribute format has changed to align with OpenTelemetry semantic conventions. It now contains only the GraphQL query string instead of the full JSON request payload.
15+
16+
**Before:**
17+
18+
```javascript
19+
"graphql.document": "{\"query\":\"query Test { user { id } }\"}"
20+
```
21+
22+
**After:**
23+
24+
```javascript
25+
"graphql.document": "query Test { user { id } }"
26+
```
27+
28+
### Other Changes
29+
530
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
631

732
Work in this release was contributed by @sebws. Thank you for your contribution!

dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const query = `query Test{
1010
pet
1111
}
1212
}`;
13-
const queryPayload = JSON.stringify({ query });
1413

1514
sentryTest('should update spans for GraphQL fetch requests', async ({ getLocalTestUrl, page }) => {
1615
if (shouldSkipTracingTest()) {
@@ -55,7 +54,7 @@ sentryTest('should update spans for GraphQL fetch requests', async ({ getLocalTe
5554
'server.address': 'sentry-test.io',
5655
'sentry.op': 'http.client',
5756
'sentry.origin': 'auto.http.browser',
58-
'graphql.document': queryPayload,
57+
'graphql.document': query,
5958
}),
6059
});
6160
});

dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Sentry.init({
99
integrations: [
1010
Sentry.browserTracingIntegration(),
1111
graphqlClientIntegration({
12-
endpoints: ['http://sentry-test.io/foo'],
12+
endpoints: ['http://sentry-test.io/foo', 'http://sentry-test.io/graphql'],
1313
}),
1414
],
1515
tracesSampleRate: 1,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const requestBody = JSON.stringify({
2+
operationName: 'GetUser',
3+
variables: { id: '123' },
4+
extensions: {
5+
persistedQuery: {
6+
version: 1,
7+
sha256Hash: 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
8+
},
9+
},
10+
});
11+
12+
fetch('http://sentry-test.io/graphql', {
13+
method: 'POST',
14+
headers: {
15+
Accept: 'application/json',
16+
'Content-Type': 'application/json',
17+
},
18+
body: requestBody,
19+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/core';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
5+
6+
sentryTest('should update spans for GraphQL persisted query fetch requests', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipTracingTest()) {
8+
return;
9+
}
10+
11+
const url = await getLocalTestUrl({ testDir: __dirname });
12+
13+
await page.route('**/graphql', route => {
14+
return route.fulfill({
15+
status: 200,
16+
body: JSON.stringify({
17+
data: {
18+
user: {
19+
id: '123',
20+
name: 'Test User',
21+
},
22+
},
23+
}),
24+
headers: {
25+
'Content-Type': 'application/json',
26+
},
27+
});
28+
});
29+
30+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
31+
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client');
32+
33+
expect(requestSpans).toHaveLength(1);
34+
35+
expect(requestSpans![0]).toMatchObject({
36+
description: 'POST http://sentry-test.io/graphql (persisted GetUser)',
37+
parent_span_id: eventData.contexts?.trace?.span_id,
38+
span_id: expect.any(String),
39+
start_timestamp: expect.any(Number),
40+
timestamp: expect.any(Number),
41+
trace_id: eventData.contexts?.trace?.trace_id,
42+
status: 'ok',
43+
data: expect.objectContaining({
44+
type: 'fetch',
45+
'http.method': 'POST',
46+
'http.url': 'http://sentry-test.io/graphql',
47+
url: 'http://sentry-test.io/graphql',
48+
'server.address': 'sentry-test.io',
49+
'sentry.op': 'http.client',
50+
'sentry.origin': 'auto.http.browser',
51+
'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
52+
'graphql.persisted_query.version': 1,
53+
}),
54+
});
55+
});
56+
57+
sentryTest(
58+
'should update breadcrumbs for GraphQL persisted query fetch requests',
59+
async ({ getLocalTestUrl, page }) => {
60+
if (shouldSkipTracingTest()) {
61+
return;
62+
}
63+
64+
const url = await getLocalTestUrl({ testDir: __dirname });
65+
66+
await page.route('**/graphql', route => {
67+
return route.fulfill({
68+
status: 200,
69+
body: JSON.stringify({
70+
data: {
71+
user: {
72+
id: '123',
73+
name: 'Test User',
74+
},
75+
},
76+
}),
77+
headers: {
78+
'Content-Type': 'application/json',
79+
},
80+
});
81+
});
82+
83+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
84+
85+
expect(eventData?.breadcrumbs?.length).toBe(1);
86+
87+
expect(eventData.breadcrumbs![0]).toEqual({
88+
timestamp: expect.any(Number),
89+
category: 'fetch',
90+
type: 'http',
91+
data: {
92+
method: 'POST',
93+
status_code: 200,
94+
url: 'http://sentry-test.io/graphql',
95+
__span: expect.any(String),
96+
'graphql.operation': 'persisted GetUser',
97+
'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
98+
'graphql.persisted_query.version': 1,
99+
},
100+
});
101+
},
102+
);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const xhr = new XMLHttpRequest();
2+
3+
xhr.open('POST', 'http://sentry-test.io/graphql');
4+
xhr.setRequestHeader('Accept', 'application/json');
5+
xhr.setRequestHeader('Content-Type', 'application/json');
6+
7+
const requestBody = JSON.stringify({
8+
operationName: 'GetUser',
9+
variables: { id: '123' },
10+
extensions: {
11+
persistedQuery: {
12+
version: 1,
13+
sha256Hash: 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
14+
},
15+
},
16+
});
17+
18+
xhr.send(requestBody);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/core';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
5+
6+
sentryTest('should update spans for GraphQL persisted query XHR requests', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipTracingTest()) {
8+
return;
9+
}
10+
11+
const url = await getLocalTestUrl({ testDir: __dirname });
12+
13+
await page.route('**/graphql', route => {
14+
return route.fulfill({
15+
status: 200,
16+
body: JSON.stringify({
17+
data: {
18+
user: {
19+
id: '123',
20+
name: 'Test User',
21+
},
22+
},
23+
}),
24+
headers: {
25+
'Content-Type': 'application/json',
26+
},
27+
});
28+
});
29+
30+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
31+
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client');
32+
33+
expect(requestSpans).toHaveLength(1);
34+
35+
expect(requestSpans![0]).toMatchObject({
36+
description: 'POST http://sentry-test.io/graphql (persisted GetUser)',
37+
parent_span_id: eventData.contexts?.trace?.span_id,
38+
span_id: expect.any(String),
39+
start_timestamp: expect.any(Number),
40+
timestamp: expect.any(Number),
41+
trace_id: eventData.contexts?.trace?.trace_id,
42+
status: 'ok',
43+
data: {
44+
type: 'xhr',
45+
'http.method': 'POST',
46+
'http.url': 'http://sentry-test.io/graphql',
47+
url: 'http://sentry-test.io/graphql',
48+
'server.address': 'sentry-test.io',
49+
'sentry.op': 'http.client',
50+
'sentry.origin': 'auto.http.browser',
51+
'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
52+
'graphql.persisted_query.version': 1,
53+
},
54+
});
55+
});
56+
57+
sentryTest('should update breadcrumbs for GraphQL persisted query XHR requests', async ({ getLocalTestUrl, page }) => {
58+
if (shouldSkipTracingTest()) {
59+
return;
60+
}
61+
62+
const url = await getLocalTestUrl({ testDir: __dirname });
63+
64+
await page.route('**/graphql', route => {
65+
return route.fulfill({
66+
status: 200,
67+
body: JSON.stringify({
68+
data: {
69+
user: {
70+
id: '123',
71+
name: 'Test User',
72+
},
73+
},
74+
}),
75+
headers: {
76+
'Content-Type': 'application/json',
77+
},
78+
});
79+
});
80+
81+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
82+
83+
expect(eventData?.breadcrumbs?.length).toBe(1);
84+
85+
expect(eventData.breadcrumbs![0]).toEqual({
86+
timestamp: expect.any(Number),
87+
category: 'xhr',
88+
type: 'http',
89+
data: {
90+
method: 'POST',
91+
status_code: 200,
92+
url: 'http://sentry-test.io/graphql',
93+
'graphql.operation': 'persisted GetUser',
94+
'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38',
95+
'graphql.persisted_query.version': 1,
96+
},
97+
});
98+
});

dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const query = `query Test{
1010
pet
1111
}
1212
}`;
13-
const queryPayload = JSON.stringify({ query });
1413

1514
sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => {
1615
if (shouldSkipTracingTest()) {
@@ -55,7 +54,7 @@ sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTest
5554
'server.address': 'sentry-test.io',
5655
'sentry.op': 'http.client',
5756
'sentry.origin': 'auto.http.browser',
58-
'graphql.document': queryPayload,
57+
'graphql.document': query,
5958
},
6059
});
6160
});

0 commit comments

Comments
 (0)