Skip to content

Commit f472764

Browse files
author
tbeeren
committed
feat(browser) Added persisted ops integration in graphqlClient javascript
1 parent 2e7a29e commit f472764

File tree

2 files changed

+225
-21
lines changed

2 files changed

+225
-21
lines changed

packages/browser/src/integrations/graphqlClient.ts

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,27 @@ interface GraphQLClientOptions {
1616
}
1717

1818
/** Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body */
19-
interface GraphQLRequestPayload {
19+
interface GraphQLStandardRequest {
2020
query: string;
2121
operationName?: string;
2222
variables?: Record<string, unknown>;
2323
extensions?: Record<string, unknown>;
2424
}
2525

26+
/** Persisted operation request */
27+
interface GraphQLPersistedRequest {
28+
operationName: string;
29+
variables?: Record<string, unknown>;
30+
extensions: {
31+
persistedQuery: {
32+
version: number;
33+
sha256Hash: string;
34+
};
35+
} & Record<string, unknown>;
36+
}
37+
38+
type GraphQLRequestPayload = GraphQLStandardRequest | GraphQLPersistedRequest;
39+
2640
interface GraphQLOperation {
2741
operationType?: string;
2842
operationName?: string;
@@ -33,7 +47,7 @@ const INTEGRATION_NAME = 'GraphQLClient';
3347
const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
3448
return {
3549
name: INTEGRATION_NAME,
36-
setup(client) {
50+
setup(client: Client) {
3751
_updateSpanWithGraphQLData(client, options);
3852
_updateBreadcrumbWithGraphQLData(client, options);
3953
},
@@ -70,7 +84,17 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption
7084
if (graphqlBody) {
7185
const operationInfo = _getGraphQLOperation(graphqlBody);
7286
span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`);
73-
span.setAttribute('graphql.document', payload);
87+
88+
// Handle standard requests - always capture the query document
89+
if (isStandardRequest(graphqlBody)) {
90+
span.setAttribute('graphql.document', graphqlBody.query);
91+
}
92+
93+
// Handle persisted operations - capture hash for debugging
94+
if (isPersistedRequest(graphqlBody)) {
95+
span.setAttribute('graphql.persistedQuery.sha256Hash', graphqlBody.extensions.persistedQuery.sha256Hash);
96+
span.setAttribute('graphql.persistedQuery.version', graphqlBody.extensions.persistedQuery.version);
97+
}
7498
}
7599
}
76100
});
@@ -96,8 +120,17 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient
96120

97121
if (!data.graphql && graphqlBody) {
98122
const operationInfo = _getGraphQLOperation(graphqlBody);
99-
data['graphql.document'] = graphqlBody.query;
123+
100124
data['graphql.operation'] = operationInfo;
125+
126+
if (isStandardRequest(graphqlBody)) {
127+
data['graphql.document'] = graphqlBody.query;
128+
}
129+
130+
if (isPersistedRequest(graphqlBody)) {
131+
data['graphql.persistedQuery.sha256Hash'] = graphqlBody.extensions.persistedQuery.sha256Hash;
132+
data['graphql.persistedQuery.version'] = graphqlBody.extensions.persistedQuery.version;
133+
}
101134
}
102135
}
103136
}
@@ -106,15 +139,24 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient
106139

107140
/**
108141
* @param requestBody - GraphQL request
109-
* @returns A formatted version of the request: 'TYPE NAME' or 'TYPE'
142+
* @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' or 'persisted NAME'
110143
*/
111-
function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string {
112-
const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody;
144+
export function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string {
145+
// Handle persisted operations
146+
if (isPersistedRequest(requestBody)) {
147+
return `persisted ${requestBody.operationName}`;
148+
}
113149

114-
const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery);
115-
const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`;
150+
// Handle standard GraphQL requests
151+
if (isStandardRequest(requestBody)) {
152+
const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody;
153+
const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery);
154+
const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`;
155+
return operationInfo;
156+
}
116157

117-
return operationInfo;
158+
// Fallback for unknown request types
159+
return 'unknown';
118160
}
119161

120162
/**
@@ -168,27 +210,55 @@ export function parseGraphQLQuery(query: string): GraphQLOperation {
168210
};
169211
}
170212

213+
/**
214+
* Helper to safely check if a value is a non-null object
215+
*/
216+
function isObject(value: unknown): value is Record<string, unknown> {
217+
return typeof value === 'object' && value !== null;
218+
}
219+
220+
/**
221+
* Type guard to check if a request is a standard GraphQL request
222+
*/
223+
function isStandardRequest(payload: unknown): payload is GraphQLStandardRequest {
224+
return isObject(payload) && typeof payload.query === 'string';
225+
}
226+
227+
/**
228+
* Type guard to check if a request is a persisted operation request
229+
*/
230+
function isPersistedRequest(payload: unknown): payload is GraphQLPersistedRequest {
231+
return (
232+
isObject(payload) &&
233+
typeof payload.operationName === 'string' &&
234+
isObject(payload.extensions) &&
235+
isObject(payload.extensions.persistedQuery)
236+
);
237+
}
238+
171239
/**
172240
* Extract the payload of a request if it's GraphQL.
173241
* Exported for tests only.
174242
* @param payload - A valid JSON string
175243
* @returns A POJO or undefined
176244
*/
177245
export function getGraphQLRequestPayload(payload: string): GraphQLRequestPayload | undefined {
178-
let graphqlBody = undefined;
179246
try {
180-
const requestBody = JSON.parse(payload) satisfies GraphQLRequestPayload;
247+
const requestBody = JSON.parse(payload);
248+
249+
if (isStandardRequest(requestBody)) {
250+
return requestBody;
251+
}
181252

182-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
183-
const isGraphQLRequest = !!requestBody['query'];
184-
if (isGraphQLRequest) {
185-
graphqlBody = requestBody;
253+
if (isPersistedRequest(requestBody)) {
254+
return requestBody;
186255
}
187-
} finally {
188-
// Fallback to undefined if payload is an invalid JSON (SyntaxError)
189256

190-
/* eslint-disable no-unsafe-finally */
191-
return graphqlBody;
257+
// Not a GraphQL request
258+
return undefined;
259+
} catch {
260+
// Invalid JSON
261+
return undefined;
192262
}
193263
}
194264

packages/browser/test/integrations/graphqlClient.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils';
66
import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils';
77
import { describe, expect, test } from 'vitest';
88
import {
9+
_getGraphQLOperation,
910
getGraphQLRequestPayload,
1011
getRequestPayloadXhrOrFetch,
1112
parseGraphQLQuery,
@@ -57,7 +58,8 @@ describe('GraphqlClient', () => {
5758

5859
expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
5960
});
60-
test('should return the payload object for GraphQL request', () => {
61+
62+
test('should return the payload object for standard GraphQL request', () => {
6163
const requestBody = {
6264
query: 'query Test {\r\n items {\r\n id\r\n }\r\n }',
6365
operationName: 'Test',
@@ -67,6 +69,51 @@ describe('GraphqlClient', () => {
6769

6870
expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody);
6971
});
72+
73+
test('should return the payload object for persisted operation request', () => {
74+
const requestBody = {
75+
operationName: 'GetUser',
76+
variables: { id: '123' },
77+
extensions: {
78+
persistedQuery: {
79+
version: 1,
80+
sha256Hash: 'abc123def456...',
81+
},
82+
},
83+
};
84+
85+
expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody);
86+
});
87+
88+
test('should return undefined for persisted operation without operationName', () => {
89+
const requestBody = {
90+
variables: { id: '123' },
91+
extensions: {
92+
persistedQuery: {
93+
version: 1,
94+
sha256Hash: 'abc123def456...',
95+
},
96+
},
97+
};
98+
99+
expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
100+
});
101+
102+
test('should return undefined for request with extensions but no persistedQuery', () => {
103+
const requestBody = {
104+
operationName: 'GetUser',
105+
variables: { id: '123' },
106+
extensions: {
107+
someOtherExtension: true,
108+
},
109+
};
110+
111+
expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
112+
});
113+
114+
test('should return undefined for invalid JSON', () => {
115+
expect(getGraphQLRequestPayload('not valid json {')).toBeUndefined();
116+
});
70117
});
71118

72119
describe('getRequestPayloadXhrOrFetch', () => {
@@ -136,4 +183,91 @@ describe('GraphqlClient', () => {
136183
expect(result).toBeUndefined();
137184
});
138185
});
186+
187+
describe('_getGraphQLOperation', () => {
188+
test('should format standard GraphQL query with operation name', () => {
189+
const requestBody = {
190+
query: 'query GetUser { user { id } }',
191+
operationName: 'GetUser',
192+
};
193+
194+
expect(_getGraphQLOperation(requestBody)).toBe('query GetUser');
195+
});
196+
197+
test('should format standard GraphQL mutation with operation name', () => {
198+
const requestBody = {
199+
query: 'mutation CreateUser($input: UserInput!) { createUser(input: $input) { id } }',
200+
operationName: 'CreateUser',
201+
};
202+
203+
expect(_getGraphQLOperation(requestBody)).toBe('mutation CreateUser');
204+
});
205+
206+
test('should format standard GraphQL subscription with operation name', () => {
207+
const requestBody = {
208+
query: 'subscription OnUserCreated { userCreated { id } }',
209+
operationName: 'OnUserCreated',
210+
};
211+
212+
expect(_getGraphQLOperation(requestBody)).toBe('subscription OnUserCreated');
213+
});
214+
215+
test('should format standard GraphQL query without operation name', () => {
216+
const requestBody = {
217+
query: 'query { users { id } }',
218+
};
219+
220+
expect(_getGraphQLOperation(requestBody)).toBe('query');
221+
});
222+
223+
test('should use query operation name when provided in request body', () => {
224+
const requestBody = {
225+
query: 'query { users { id } }',
226+
operationName: 'GetAllUsers',
227+
};
228+
229+
expect(_getGraphQLOperation(requestBody)).toBe('query GetAllUsers');
230+
});
231+
232+
test('should format persisted operation request', () => {
233+
const requestBody = {
234+
operationName: 'GetUser',
235+
variables: { id: '123' },
236+
extensions: {
237+
persistedQuery: {
238+
version: 1,
239+
sha256Hash: 'abc123def456',
240+
},
241+
},
242+
};
243+
244+
expect(_getGraphQLOperation(requestBody)).toBe('persisted GetUser');
245+
});
246+
247+
test('should handle persisted operation with additional extensions', () => {
248+
const requestBody = {
249+
operationName: 'GetUser',
250+
extensions: {
251+
persistedQuery: {
252+
version: 1,
253+
sha256Hash: 'abc123def456',
254+
},
255+
tracing: true,
256+
customExtension: 'value',
257+
},
258+
};
259+
260+
expect(_getGraphQLOperation(requestBody)).toBe('persisted GetUser');
261+
});
262+
263+
test('should return "unknown" for unrecognized request format', () => {
264+
const requestBody = {
265+
variables: { id: '123' },
266+
};
267+
268+
// This shouldn't happen in practice since getGraphQLRequestPayload filters,
269+
// but test the fallback behavior
270+
expect(_getGraphQLOperation(requestBody as any)).toBe('unknown');
271+
});
272+
});
139273
});

0 commit comments

Comments
 (0)