Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 92 additions & 20 deletions packages/browser/src/integrations/graphqlClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,27 @@ interface GraphQLClientOptions {
}

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

/** Persisted operation request */
interface GraphQLPersistedRequest {
operationName: string;
variables?: Record<string, unknown>;
extensions: {
persistedQuery: {
version: number;
sha256Hash: string;
};
} & Record<string, unknown>;
}

type GraphQLRequestPayload = GraphQLStandardRequest | GraphQLPersistedRequest;

interface GraphQLOperation {
operationType?: string;
operationName?: string;
Expand All @@ -33,7 +47,7 @@ const INTEGRATION_NAME = 'GraphQLClient';
const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
return {
name: INTEGRATION_NAME,
setup(client) {
setup(client: Client) {
_updateSpanWithGraphQLData(client, options);
_updateBreadcrumbWithGraphQLData(client, options);
},
Expand Down Expand Up @@ -70,7 +84,17 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption
if (graphqlBody) {
const operationInfo = _getGraphQLOperation(graphqlBody);
span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`);
span.setAttribute('graphql.document', payload);

// Handle standard requests - always capture the query document
if (isStandardRequest(graphqlBody)) {
span.setAttribute('graphql.document', graphqlBody.query);
}

// Handle persisted operations - capture hash for debugging
if (isPersistedRequest(graphqlBody)) {
span.setAttribute('graphql.persistedQuery.sha256Hash', graphqlBody.extensions.persistedQuery.sha256Hash);
span.setAttribute('graphql.persistedQuery.version', graphqlBody.extensions.persistedQuery.version);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: OpenTelemetry semantic convention are snake_case, I think we should go with graphql.persisted_query.hash.sha256 and graphql.persisted_query.version to be more in line with semconvs.

}
}
}
});
Expand All @@ -96,8 +120,17 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient

if (!data.graphql && graphqlBody) {
const operationInfo = _getGraphQLOperation(graphqlBody);
data['graphql.document'] = graphqlBody.query;

data['graphql.operation'] = operationInfo;

if (isStandardRequest(graphqlBody)) {
data['graphql.document'] = graphqlBody.query;
}

if (isPersistedRequest(graphqlBody)) {
data['graphql.persistedQuery.sha256Hash'] = graphqlBody.extensions.persistedQuery.sha256Hash;
data['graphql.persistedQuery.version'] = graphqlBody.extensions.persistedQuery.version;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: Same here, let's get snake_case going and prefix the hash

}
}
}
}
Expand All @@ -106,15 +139,24 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient

/**
* @param requestBody - GraphQL request
* @returns A formatted version of the request: 'TYPE NAME' or 'TYPE'
* @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' or 'persisted NAME'
*/
function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string {
const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody;
export function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string {
// Handle persisted operations
if (isPersistedRequest(requestBody)) {
return `persisted ${requestBody.operationName}`;
}

const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery);
const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`;
// Handle standard GraphQL requests
if (isStandardRequest(requestBody)) {
const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody;
const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery);
const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`;
return operationInfo;
}

return operationInfo;
// Fallback for unknown request types
return 'unknown';
}

/**
Expand Down Expand Up @@ -168,27 +210,57 @@ export function parseGraphQLQuery(query: string): GraphQLOperation {
};
}

/**
* Helper to safely check if a value is a non-null object
*/
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

/**
* Type guard to check if a request is a standard GraphQL request
*/
function isStandardRequest(payload: unknown): payload is GraphQLStandardRequest {
return isObject(payload) && typeof payload.query === 'string';
}

/**
* Type guard to check if a request is a persisted operation request
*/
function isPersistedRequest(payload: unknown): payload is GraphQLPersistedRequest {
return (
isObject(payload) &&
typeof payload.operationName === 'string' &&
isObject(payload.extensions) &&
isObject(payload.extensions.persistedQuery) &&
typeof payload.extensions.persistedQuery.sha256Hash === 'string' &&
typeof payload.extensions.persistedQuery.version === 'number'
);
}

/**
* Extract the payload of a request if it's GraphQL.
* Exported for tests only.
* @param payload - A valid JSON string
* @returns A POJO or undefined
*/
export function getGraphQLRequestPayload(payload: string): GraphQLRequestPayload | undefined {
let graphqlBody = undefined;
try {
const requestBody = JSON.parse(payload) satisfies GraphQLRequestPayload;
const requestBody = JSON.parse(payload);

if (isStandardRequest(requestBody)) {
return requestBody;
}

This comment was marked as outdated.


// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const isGraphQLRequest = !!requestBody['query'];
if (isGraphQLRequest) {
graphqlBody = requestBody;
if (isPersistedRequest(requestBody)) {
return requestBody;
}
} finally {
// Fallback to undefined if payload is an invalid JSON (SyntaxError)

/* eslint-disable no-unsafe-finally */
return graphqlBody;
// Not a GraphQL request
return undefined;
} catch {
// Invalid JSON
return undefined;
}
}

Expand Down
174 changes: 173 additions & 1 deletion packages/browser/test/integrations/graphqlClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils';
import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils';
import { describe, expect, test } from 'vitest';
import {
_getGraphQLOperation,
getGraphQLRequestPayload,
getRequestPayloadXhrOrFetch,
parseGraphQLQuery,
Expand Down Expand Up @@ -57,7 +58,8 @@ describe('GraphqlClient', () => {

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
});
test('should return the payload object for GraphQL request', () => {

test('should return the payload object for standard GraphQL request', () => {
const requestBody = {
query: 'query Test {\r\n items {\r\n id\r\n }\r\n }',
operationName: 'Test',
Expand All @@ -67,6 +69,89 @@ describe('GraphqlClient', () => {

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody);
});

test('should return the payload object for persisted operation request', () => {
const requestBody = {
operationName: 'GetUser',
variables: { id: '123' },
extensions: {
persistedQuery: {
version: 1,
sha256Hash: 'abc123def456...',
},
},
};

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody);
});

test('should return undefined for persisted operation without operationName', () => {
const requestBody = {
variables: { id: '123' },
extensions: {
persistedQuery: {
version: 1,
sha256Hash: 'abc123def456...',
},
},
};

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
});

test('should return undefined for request with extensions but no persistedQuery', () => {
const requestBody = {
operationName: 'GetUser',
variables: { id: '123' },
extensions: {
someOtherExtension: true,
},
};

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
});

test('should return undefined for persisted operation with incomplete persistedQuery object', () => {
const requestBody = {
operationName: 'GetUser',
variables: { id: '123' },
extensions: {
persistedQuery: {},
},
};

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
});

test('should return undefined for persisted operation missing sha256Hash', () => {
const requestBody = {
operationName: 'GetUser',
extensions: {
persistedQuery: {
version: 1,
},
},
};

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
});

test('should return undefined for persisted operation missing version', () => {
const requestBody = {
operationName: 'GetUser',
extensions: {
persistedQuery: {
sha256Hash: 'abc123',
},
},
};

expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
});

test('should return undefined for invalid JSON', () => {
expect(getGraphQLRequestPayload('not valid json {')).toBeUndefined();
});
});

describe('getRequestPayloadXhrOrFetch', () => {
Expand Down Expand Up @@ -136,4 +221,91 @@ describe('GraphqlClient', () => {
expect(result).toBeUndefined();
});
});

describe('_getGraphQLOperation', () => {
test('should format standard GraphQL query with operation name', () => {
const requestBody = {
query: 'query GetUser { user { id } }',
operationName: 'GetUser',
};

expect(_getGraphQLOperation(requestBody)).toBe('query GetUser');
});

test('should format standard GraphQL mutation with operation name', () => {
const requestBody = {
query: 'mutation CreateUser($input: UserInput!) { createUser(input: $input) { id } }',
operationName: 'CreateUser',
};

expect(_getGraphQLOperation(requestBody)).toBe('mutation CreateUser');
});

test('should format standard GraphQL subscription with operation name', () => {
const requestBody = {
query: 'subscription OnUserCreated { userCreated { id } }',
operationName: 'OnUserCreated',
};

expect(_getGraphQLOperation(requestBody)).toBe('subscription OnUserCreated');
});

test('should format standard GraphQL query without operation name', () => {
const requestBody = {
query: 'query { users { id } }',
};

expect(_getGraphQLOperation(requestBody)).toBe('query');
});

test('should use query operation name when provided in request body', () => {
const requestBody = {
query: 'query { users { id } }',
operationName: 'GetAllUsers',
};

expect(_getGraphQLOperation(requestBody)).toBe('query GetAllUsers');
});

test('should format persisted operation request', () => {
const requestBody = {
operationName: 'GetUser',
variables: { id: '123' },
extensions: {
persistedQuery: {
version: 1,
sha256Hash: 'abc123def456',
},
},
};

expect(_getGraphQLOperation(requestBody)).toBe('persisted GetUser');
});

test('should handle persisted operation with additional extensions', () => {
const requestBody = {
operationName: 'GetUser',
extensions: {
persistedQuery: {
version: 1,
sha256Hash: 'abc123def456',
},
tracing: true,
customExtension: 'value',
},
};

expect(_getGraphQLOperation(requestBody)).toBe('persisted GetUser');
});

test('should return "unknown" for unrecognized request format', () => {
const requestBody = {
variables: { id: '123' },
};

// This shouldn't happen in practice since getGraphQLRequestPayload filters,
// but test the fallback behavior
expect(_getGraphQLOperation(requestBody as any)).toBe('unknown');
});
});
});
Loading