From b88b4f996b5b290c4d1dc58502b9ca49fd28fdfc Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 24 Sep 2024 22:33:49 -0400 Subject: [PATCH 01/65] feat(browser): Add `graphqlClientIntegration` Added support for graphql query with `xhr` with tests. Signed-off-by: Kaung Zin Hein --- .../suites/integrations/graphqlClient/init.js | 14 +++++ .../integrations/graphqlClient/xhr/subject.js | 16 ++++++ .../integrations/graphqlClient/xhr/test.ts | 51 +++++++++++++++++++ packages/browser/src/index.ts | 3 ++ .../browser/src/integrations/graphqlClient.ts | 48 +++++++++++++++++ packages/browser/src/tracing/request.ts | 3 ++ packages/core/src/utils-hoist/graphql.ts | 26 ++++++++++ .../core/test/utils-hoist/graphql.test.ts | 41 +++++++++++++++ packages/utils/src/index.ts | 43 ++++++++++++++++ 9 files changed, 245 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts create mode 100644 packages/browser/src/integrations/graphqlClient.ts create mode 100644 packages/core/src/utils-hoist/graphql.ts create mode 100644 packages/core/test/utils-hoist/graphql.test.ts create mode 100644 packages/utils/src/index.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js new file mode 100644 index 000000000000..7ca9df70b6c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js @@ -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, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js new file mode 100644 index 000000000000..d95cceeb8b7f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js @@ -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); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts new file mode 100644 index 000000000000..09dd02e3862b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -0,0 +1,51 @@ +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(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, + 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', + }, + }); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 42c388d73547..b7b77d773bd7 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -3,6 +3,7 @@ export * from './exports'; export { reportingObserverIntegration } from './integrations/reportingobserver'; export { httpClientIntegration } from './integrations/httpclient'; export { contextLinesIntegration } from './integrations/contextlines'; +export { graphqlClientIntegration } from './integrations/graphqlClient'; export { captureConsoleIntegration, @@ -31,6 +32,8 @@ import { feedbackSyncIntegration } from './feedbackSync'; export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration }; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; +export * from './metrics'; + export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request'; export { browserTracingIntegration, diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts new file mode 100644 index 000000000000..fbe7caabd6cb --- /dev/null +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -0,0 +1,48 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, defineIntegration, spanToJSON } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; +import { parseGraphQLQuery } from '@sentry/utils'; + +interface GraphQLClientOptions { + endpoints: Array; +} + +const INTEGRATION_NAME = 'GraphQLClient'; + +const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { + return { + name: INTEGRATION_NAME, + setup(client) { + client.on('spanStart', 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['http.url']; + + const { endpoints } = options; + + const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); + + if (isTracedGraphqlEndpoint) { + const httpMethod = spanAttributes['http.method']; + const graphqlQuery = spanAttributes['body']?.query as string; + + const { operationName, 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); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 92a8f2924084..26da03ae9e3c 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -370,6 +370,8 @@ export function xhrCallback( return undefined; } + const requestBody = JSON.parse(sentryXhrData.body as string); + const fullUrl = getFullURL(sentryXhrData.url); const host = fullUrl ? parseUrl(fullUrl).host : undefined; @@ -387,6 +389,7 @@ export function xhrCallback( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + body: requestBody, }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts new file mode 100644 index 000000000000..2062643c7d00 --- /dev/null +++ b/packages/core/src/utils-hoist/graphql.ts @@ -0,0 +1,26 @@ +interface GraphQLOperation { + operationType: string | undefined; + operationName: string | undefined; +} + +/** + * Extract the name and type of the operation from the GraphQL query. + * @param query + * @returns + */ +export function parseGraphQLQuery(query: string): GraphQLOperation { + const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[\{\(]/; + + const matched = query.match(queryRe); + + if (matched) { + return { + operationType: matched[1], + operationName: matched[2], + }; + } + return { + operationType: undefined, + operationName: undefined, + }; +} diff --git a/packages/core/test/utils-hoist/graphql.test.ts b/packages/core/test/utils-hoist/graphql.test.ts new file mode 100644 index 000000000000..59d5c0fadda8 --- /dev/null +++ b/packages/core/test/utils-hoist/graphql.test.ts @@ -0,0 +1,41 @@ +import { parseGraphQLQuery } from '../src'; + +describe('parseGraphQLQuery', () => { + const queryOne = `query Test { + items { + id + } + }`; + + const queryTwo = `mutation AddTestItem($input: TestItem!) { + addItem(input: $input) { + name + } + }`; + + const queryThree = `subscription OnTestItemAdded($itemID: ID!) { + itemAdded(itemID: $itemID) { + id + } + }`; + + // TODO: support name-less queries + // const queryFour = ` query { + // items { + // id + // } + // }`; + + test.each([ + ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], + ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], + [ + 'should handle subscription type', + queryThree, + { operationName: 'OnTestItemAdded', operationType: 'subscription' }, + ], + // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ])('%s', (_, input, output) => { + expect(parseGraphQLQuery(input)).toEqual(output); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000000..9aa1740f28c6 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,43 @@ +export * from './aggregate-errors'; +export * from './array'; +export * from './breadcrumb-log-level'; +export * from './browser'; +export * from './dsn'; +export * from './error'; +export * from './worldwide'; +export * from './instrument'; +export * from './is'; +export * from './isBrowser'; +export * from './logger'; +export * from './memo'; +export * from './misc'; +export * from './node'; +export * from './normalize'; +export * from './object'; +export * from './path'; +export * from './promisebuffer'; +// TODO: Remove requestdata export once equivalent integration is used everywhere +export * from './requestdata'; +export * from './severity'; +export * from './stacktrace'; +export * from './node-stack-trace'; +export * from './string'; +export * from './supports'; +export * from './syncpromise'; +export * from './time'; +export * from './tracing'; +export * from './env'; +export * from './envelope'; +export * from './clientreport'; +export * from './ratelimit'; +export * from './baggage'; +export * from './url'; +export * from './cache'; +export * from './eventbuilder'; +export * from './anr'; +export * from './lru'; +export * from './buildPolyfills'; +export * from './propagationContext'; +export * from './version'; +export * from './graphql'; + From 17e89b14374fd474e4fc2a9d69add986132cab3a Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Thu, 26 Sep 2024 10:44:44 -0400 Subject: [PATCH 02/65] feat(browser): Add support for fetch graphql request Added test for fetch graphql. Created new utility functions and added tests. Updated `instrumentFetch` to collect fetch request payload. Signed-off-by: Kaung Zin Hein --- .../graphqlClient/fetch/subject.js | 17 ++++ .../integrations/graphqlClient/fetch/test.ts | 55 ++++++++++++ .../integrations/graphqlClient/xhr/test.ts | 4 + .../browser/src/integrations/graphqlClient.ts | 24 ++++-- packages/browser/src/tracing/request.ts | 6 +- packages/core/src/fetch.ts | 3 + packages/core/src/types-hoist/instrument.ts | 3 + packages/core/src/utils-hoist/graphql.ts | 23 ++++- .../core/src/utils-hoist/instrument/fetch.ts | 9 +- .../core/test/utils-hoist/graphql.test.ts | 86 +++++++++++-------- .../test/utils-hoist/instrument/fetch.test.ts | 24 ++++-- packages/types/src/client.ts | 0 12 files changed, 198 insertions(+), 56 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts create mode 100644 packages/types/src/client.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js new file mode 100644 index 000000000000..6a9398578b8b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js @@ -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, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts new file mode 100644 index 000000000000..ce8cbce4f8ce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -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(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), + }, + }), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 09dd02e3862b..0e8323f5ae17 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -38,6 +38,7 @@ sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLoca start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: eventData.contexts?.trace?.trace_id, + status: 'ok', data: { type: 'xhr', 'http.method': 'POST', @@ -46,6 +47,9 @@ sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLoca 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', + body: { + query: expect.any(String), + }, }, }); }); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index fbe7caabd6cb..9f922854486d 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,4 +1,10 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, defineIntegration, spanToJSON } from '@sentry/core'; +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'; @@ -13,6 +19,10 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { 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 || {}; @@ -21,17 +31,21 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { const isHttpClientSpan = spanOp === 'http.client'; if (isHttpClientSpan) { - const httpUrl = spanAttributes['http.url']; + const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; const { endpoints } = options; const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); if (isTracedGraphqlEndpoint) { - const httpMethod = spanAttributes['http.method']; - const graphqlQuery = spanAttributes['body']?.query as string; + 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, operationType } = parseGraphQLQuery(graphqlQuery); + const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 26da03ae9e3c..270f7ae10229 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -370,13 +370,13 @@ export function xhrCallback( return undefined; } - const requestBody = JSON.parse(sentryXhrData.body as string); - const fullUrl = getFullURL(sentryXhrData.url); const host = fullUrl ? parseUrl(fullUrl).host : undefined; const hasParent = !!getActiveSpan(); + const graphqlRequest = getGraphQLRequestPayload(sentryXhrData.body as string); + const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -389,7 +389,7 @@ export function xhrCallback( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - body: requestBody, + body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 8998eb45fce0..dfa585a76ef9 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -57,6 +57,8 @@ export function instrumentFetchRequest( const hasParent = !!getActiveSpan(); + const graphqlRequest = getGraphQLRequestPayload(body as string); + const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -69,6 +71,7 @@ export function instrumentFetchRequest( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts index 420482579dd9..b35e6290652f 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -6,6 +6,8 @@ import type { WebFetchHeaders } from './webfetchapi'; // Make sure to cast it where needed! type XHRSendInput = unknown; +type FetchInput = unknown; + export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' | 'assert' | 'trace'; export interface SentryWrappedXMLHttpRequest { @@ -40,6 +42,7 @@ export interface HandlerDataXhr { interface SentryFetchData { method: string; url: string; + body?: FetchInput; request_body_size?: number; response_body_size?: number; // span_id for the fetch request diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 2062643c7d00..5ffc2640ffb1 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -6,10 +6,9 @@ interface GraphQLOperation { /** * Extract the name and type of the operation from the GraphQL query. * @param query - * @returns */ export function parseGraphQLQuery(query: string): GraphQLOperation { - const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[\{\(]/; + const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; const matched = query.match(queryRe); @@ -24,3 +23,23 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { operationName: undefined, }; } + +/** + * Extract the payload of a request ONLY if it's GraphQL. + * @param payload - A valid JSON string + */ +export function getGraphQLRequestPayload(payload: string): any | undefined { + let graphqlBody = undefined; + try { + const requestBody = JSON.parse(payload); + const isGraphQLRequest = !!requestBody['query']; + if (isGraphQLRequest) { + graphqlBody = requestBody; + } + } finally { + // Fallback to undefined if payload is an invalid JSON (SyntaxError) + + /* eslint-disable no-unsafe-finally */ + return graphqlBody; + } +} diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index f3eee711d26d..1eefb1a15082 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -63,6 +63,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat fetchData: { method, url, + body, }, startTimestamp: timestampInSeconds() * 1000, // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation @@ -211,12 +212,12 @@ function getUrlFromResource(resource: FetchResource): string { } /** - * Parses the fetch arguments to find the used Http method and the url of the request. + * Parses the fetch arguments to find the used Http method, the url, and the payload of the request. * Exported for tests only. */ -export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } { +export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string; body: string | null } { if (fetchArgs.length === 0) { - return { method: 'GET', url: '' }; + return { method: 'GET', url: '', body: null }; } if (fetchArgs.length === 2) { @@ -225,6 +226,7 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(url), method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET', + body: hasProp(options, 'body') ? String(options.body) : null, }; } @@ -232,5 +234,6 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(arg as FetchResource), method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', + body: hasProp(arg, 'body') ? String(arg.body) : null, }; } diff --git a/packages/core/test/utils-hoist/graphql.test.ts b/packages/core/test/utils-hoist/graphql.test.ts index 59d5c0fadda8..a325e9c94bcc 100644 --- a/packages/core/test/utils-hoist/graphql.test.ts +++ b/packages/core/test/utils-hoist/graphql.test.ts @@ -1,41 +1,59 @@ -import { parseGraphQLQuery } from '../src'; +import { getGraphQLRequestPayload, parseGraphQLQuery } from '../src'; -describe('parseGraphQLQuery', () => { - const queryOne = `query Test { - items { - id - } - }`; +describe('graphql', () => { + describe('parseGraphQLQuery', () => { + const queryOne = `query Test { + items { + id + } + }`; - const queryTwo = `mutation AddTestItem($input: TestItem!) { - addItem(input: $input) { - name - } - }`; + const queryTwo = `mutation AddTestItem($input: TestItem!) { + addItem(input: $input) { + name + } + }`; - const queryThree = `subscription OnTestItemAdded($itemID: ID!) { - itemAdded(itemID: $itemID) { - id - } - }`; + const queryThree = `subscription OnTestItemAdded($itemID: ID!) { + itemAdded(itemID: $itemID) { + id + } + }`; - // TODO: support name-less queries - // const queryFour = ` query { - // items { - // id - // } - // }`; + // TODO: support name-less queries + // const queryFour = ` query { + // items { + // id + // } + // }`; - test.each([ - ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], - ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], - [ - 'should handle subscription type', - queryThree, - { operationName: 'OnTestItemAdded', operationType: 'subscription' }, - ], - // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], - ])('%s', (_, input, output) => { - expect(parseGraphQLQuery(input)).toEqual(output); + test.each([ + ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], + ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], + [ + 'should handle subscription type', + queryThree, + { operationName: 'OnTestItemAdded', operationType: 'subscription' }, + ], + // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ])('%s', (_, input, output) => { + expect(parseGraphQLQuery(input)).toEqual(output); + }); + }); + describe('getGraphQLRequestPayload', () => { + test('should return undefined for non-GraphQL request', () => { + const requestBody = { data: [1, 2, 3] }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + test('should return the payload object for GraphQL request', () => { + const requestBody = { + query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', + operationName: 'Test', + variables: {}, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); + }); }); }); diff --git a/packages/core/test/utils-hoist/instrument/fetch.test.ts b/packages/core/test/utils-hoist/instrument/fetch.test.ts index fc6102d6b617..f89e795dd0bd 100644 --- a/packages/core/test/utils-hoist/instrument/fetch.test.ts +++ b/packages/core/test/utils-hoist/instrument/fetch.test.ts @@ -1,25 +1,31 @@ import { parseFetchArgs } from '../../../src/utils-hoist/instrument/fetch'; describe('instrument > parseFetchArgs', () => { + const data = { name: 'Test' }; + it.each([ - ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com' }], - ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/' }], - ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com' }], + ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com', body: null }], + ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/', body: null }], + ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com', body: null }], [ 'Request URL & method only', [{ url: 'http://example.com', method: 'post' }], - { method: 'POST', url: 'http://example.com' }, + { method: 'POST', url: 'http://example.com', body: null }, + ], + [ + 'string URL & options', + ['http://example.com', { method: 'post', body: JSON.stringify(data) }], + { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, ], - ['string URL & options', ['http://example.com', { method: 'post' }], { method: 'POST', url: 'http://example.com' }], [ 'URL object & options', - [new URL('http://example.com'), { method: 'post' }], - { method: 'POST', url: 'http://example.com/' }, + [new URL('http://example.com'), { method: 'post', body: JSON.stringify(data) }], + { method: 'POST', url: 'http://example.com/', body: '{"name":"Test"}' }, ], [ 'Request URL & options', - [{ url: 'http://example.com' }, { method: 'post' }], - { method: 'POST', url: 'http://example.com' }, + [{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify(data) }], + { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, ], ])('%s', (_name, args, expected) => { const actual = parseFetchArgs(args as unknown[]); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts new file mode 100644 index 000000000000..e69de29bb2d1 From be96da7147b0e5f122f3f06781840d67a2143677 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Thu, 26 Sep 2024 11:24:38 -0400 Subject: [PATCH 03/65] test(browser): Remove skip test Signed-off-by: Kaung Zin Hein --- .../suites/integrations/graphqlClient/fetch/test.ts | 8 ++------ .../suites/integrations/graphqlClient/xhr/test.ts | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index ce8cbce4f8ce..dc7277989f82 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -2,13 +2,9 @@ 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(); - } +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 0e8323f5ae17..1efa45598a26 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -2,13 +2,9 @@ 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(); - } +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { From b985d6a6ae64807aa67e36d5cbfca83ea6a6bd06 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Thu, 26 Sep 2024 21:57:27 -0400 Subject: [PATCH 04/65] fix(browser): Attach request payload to fetch instrumentation only for graphql requests Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/graphqlClient.ts | 4 +++- packages/core/src/utils-hoist/graphql.ts | 4 ++++ .../core/src/utils-hoist/instrument/fetch.ts | 24 ++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 9f922854486d..37bb159bea0a 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -34,7 +34,6 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); if (isTracedGraphqlEndpoint) { @@ -42,7 +41,10 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { const graphqlBody = spanAttributes['body']; // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const graphqlQuery = graphqlBody && (graphqlBody['query'] as string); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const graphqlOperationName = graphqlBody && (graphqlBody['operationName'] as string); const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 5ffc2640ffb1..8b4265f4307c 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -27,12 +27,16 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { /** * Extract the payload of a request ONLY if it's GraphQL. * @param payload - A valid JSON string + * @returns A POJO or undefined */ export function getGraphQLRequestPayload(payload: string): any | undefined { let graphqlBody = undefined; try { const requestBody = JSON.parse(payload); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const isGraphQLRequest = !!requestBody['query']; + if (isGraphQLRequest) { graphqlBody = requestBody; } diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index 1eefb1a15082..63832e9ee1fd 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { HandlerDataFetch } from '../../types-hoist'; +import { getGraphQLRequestPayload } from '../graphql'; import { isError } from '../is'; import { addNonEnumerableProperty, fill } from '../object'; import { supportsNativeFetch } from '../supports'; @@ -63,7 +64,6 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat fetchData: { method, url, - body, }, startTimestamp: timestampInSeconds() * 1000, // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation @@ -212,12 +212,12 @@ function getUrlFromResource(resource: FetchResource): string { } /** - * Parses the fetch arguments to find the used Http method, the url, and the payload of the request. + * Parses the fetch arguments to find the used Http method and the url of the request. * Exported for tests only. */ -export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string; body: string | null } { +export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } { if (fetchArgs.length === 0) { - return { method: 'GET', url: '', body: null }; + return { method: 'GET', url: '' }; } if (fetchArgs.length === 2) { @@ -226,7 +226,6 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(url), method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET', - body: hasProp(options, 'body') ? String(options.body) : null, }; } @@ -234,6 +233,19 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(arg as FetchResource), method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', - body: hasProp(arg, 'body') ? String(arg.body) : null, }; } + +/** + * Parses the fetch arguments to extract the request payload. + * Exported for tests only. + */ +export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { + if (fetchArgs.length === 2) { + const options = fetchArgs[1]; + return hasProp(options, 'body') ? String(options.body) : undefined; + } + + const arg = fetchArgs[0]; + return hasProp(arg, 'body') ? String(arg.body) : undefined; +} From f7a021b229e36e9132b9b9d42ad1e0c2ef982e4b Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Fri, 27 Sep 2024 11:57:27 -0400 Subject: [PATCH 05/65] fix(browser): Emit the `outgoingRequestSpanStart` hook after the span has started Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 13 +- .../integrations/graphqlClient/xhr/subject.js | 9 +- .../integrations/graphqlClient/xhr/test.ts | 13 +- .../browser/src/integrations/graphqlClient.ts | 22 +- packages/browser/src/tracing/request.ts | 7 +- packages/core/src/client.ts | 14 +- packages/core/src/fetch.ts | 7 +- packages/types/src/client.ts | 412 ++++++++++++++++++ 8 files changed, 462 insertions(+), 35 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index dc7277989f82..17bdfa4b9215 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -4,6 +4,15 @@ import type { Event } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +// Duplicate from subject.js +const query = `query Test{ + people { + name + pet + } +}`; +const queryPayload = JSON.stringify({ query }); + sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); @@ -43,9 +52,7 @@ sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTe 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: { - query: expect.any(String), - }, + body: queryPayload, }), }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js index d95cceeb8b7f..85645f645635 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js @@ -5,11 +5,10 @@ xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json'); const query = `query Test{ - - people { - name - pet - } + people { + name + pet + } }`; const requestBody = JSON.stringify({ query }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 1efa45598a26..d1c78626d6c3 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -4,6 +4,15 @@ import type { Event } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +// Duplicate from subject.js +const query = `query Test{ + people { + name + pet + } +}`; +const queryPayload = JSON.stringify({ query }); + sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); @@ -43,9 +52,7 @@ sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTest 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: { - query: expect.any(String), - }, + body: queryPayload, }, }); }); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 37bb159bea0a..ee6b4cd1f8b8 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -12,17 +12,19 @@ interface GraphQLClientOptions { endpoints: Array; } +interface GraphQLRequestPayload { + query: string; + operationName?: string; + variables?: Record; +} + 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 => { + client.on('outgoingRequestSpanStart', (span, { body }) => { const spanJSON = spanToJSON(span); const spanAttributes = spanJSON.data || {}; @@ -38,19 +40,17 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { if (isTracedGraphqlEndpoint) { const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const graphqlBody = spanAttributes['body']; + const graphqlBody = body as GraphQLRequestPayload; // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const graphqlQuery = graphqlBody && (graphqlBody['query'] as string); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const graphqlOperationName = graphqlBody && (graphqlBody['operationName'] as string); + const graphqlQuery = graphqlBody.query; + const graphqlOperationName = graphqlBody.operationName; const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`); + span.setAttribute('body', JSON.stringify(graphqlBody)); } } }); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 270f7ae10229..de9058fc1140 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -375,8 +375,6 @@ export function xhrCallback( const hasParent = !!getActiveSpan(); - const graphqlRequest = getGraphQLRequestPayload(sentryXhrData.body as string); - const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -389,7 +387,6 @@ export function xhrCallback( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); @@ -407,6 +404,10 @@ export function xhrCallback( ); } + if (client) { + client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(sentryXhrData.body as string) }); + } + return span; } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7334b2d294ed..c7eef9eb6e00 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -579,10 +579,9 @@ export abstract class Client { */ public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; - /** - * A hook that is called when the client is flushing - * @returns {() => void} A function that, when executed, removes the registered callback. - */ + /** @inheritdoc */ + public on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + public on(hook: 'flush', callback: () => void): () => void; /** @@ -709,9 +708,10 @@ export abstract class Client { */ public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; - /** - * Emit a hook event for client flush - */ + /** @inheritdoc */ + public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + + /** @inheritdoc */ public emit(hook: 'flush'): void; /** diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index dfa585a76ef9..d3c8dcf4a6d7 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -57,8 +57,6 @@ export function instrumentFetchRequest( const hasParent = !!getActiveSpan(); - const graphqlRequest = getGraphQLRequestPayload(body as string); - const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -71,7 +69,6 @@ export function instrumentFetchRequest( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); @@ -99,6 +96,10 @@ export function instrumentFetchRequest( } } + if (client) { + client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(body as string) }); + } + return span; } diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index e69de29bb2d1..aa2fd7485ad0 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -0,0 +1,412 @@ +import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; +import type { CheckIn, MonitorConfig } from './checkin'; +import type { EventDropReason } from './clientreport'; +import type { DataCategory } from './datacategory'; +import type { DsnComponents } from './dsn'; +import type { DynamicSamplingContext, Envelope } from './envelope'; +import type { Event, EventHint } from './event'; +import type { EventProcessor } from './eventprocessor'; +import type { FeedbackEvent } from './feedback'; +import type { Integration } from './integration'; +import type { ClientOptions } from './options'; +import type { ParameterizedString } from './parameterize'; +import type { Scope } from './scope'; +import type { SdkMetadata } from './sdkmetadata'; +import type { Session, SessionAggregates } from './session'; +import type { SeverityLevel } from './severity'; +import type { Span, SpanAttributes, SpanContextData } from './span'; +import type { StartSpanOptions } from './startSpanOptions'; +import type { Transport, TransportMakeRequestResponse } from './transport'; + +/** + * User-Facing Sentry SDK Client. + * + * This interface contains all methods to interface with the SDK once it has + * been installed. It allows to send events to Sentry, record breadcrumbs and + * set a context included in every event. Since the SDK mutates its environment, + * there will only be one instance during runtime. + * + */ +export interface Client { + /** + * Captures an exception event and sends it to Sentry. + * + * Unlike `captureException` exported from every SDK, this method requires that you pass it the current scope. + * + * @param exception An exception-like object. + * @param hint May contain additional information about the original exception. + * @param currentScope An optional scope containing event metadata. + * @returns The event id + */ + captureException(exception: any, hint?: EventHint, currentScope?: Scope): string; + + /** + * Captures a message event and sends it to Sentry. + * + * Unlike `captureMessage` exported from every SDK, this method requires that you pass it the current scope. + * + * @param message The message to send to Sentry. + * @param level Define the level of the message. + * @param hint May contain additional information about the original exception. + * @param currentScope An optional scope containing event metadata. + * @returns The event id + */ + captureMessage(message: string, level?: SeverityLevel, hint?: EventHint, currentScope?: Scope): string; + + /** + * Captures a manually created event and sends it to Sentry. + * + * Unlike `captureEvent` exported from every SDK, this method requires that you pass it the current scope. + * + * @param event The event to send to Sentry. + * @param hint May contain additional information about the original exception. + * @param currentScope An optional scope containing event metadata. + * @returns The event id + */ + captureEvent(event: Event, hint?: EventHint, currentScope?: Scope): string; + + /** + * Captures a session + * + * @param session Session to be delivered + */ + captureSession(session: Session): void; + + /** + * Create a cron monitor check in and send it to Sentry. This method is not available on all clients. + * + * @param checkIn An object that describes a check in. + * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want + * to create a monitor automatically when sending a check in. + * @param scope An optional scope containing event metadata. + * @returns A string representing the id of the check in. + */ + captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string; + + /** Returns the current Dsn. */ + getDsn(): DsnComponents | undefined; + + /** Returns the current options. */ + getOptions(): O; + + /** + * @inheritdoc + * + */ + getSdkMetadata(): SdkMetadata | undefined; + + /** + * Returns the transport that is used by the client. + * Please note that the transport gets lazy initialized so it will only be there once the first event has been sent. + * + * @returns The transport. + */ + getTransport(): Transport | undefined; + + /** + * Flush the event queue and set the client to `enabled = false`. See {@link Client.flush}. + * + * @param timeout Maximum time in ms the client should wait before shutting down. Omitting this parameter will cause + * the client to wait until all events are sent before disabling itself. + * @returns A promise which resolves to `true` if the flush completes successfully before the timeout, or `false` if + * it doesn't. + */ + close(timeout?: number): PromiseLike; + + /** + * Wait for all events to be sent or the timeout to expire, whichever comes first. + * + * @param timeout Maximum time in ms the client should wait for events to be flushed. Omitting this parameter will + * cause the client to wait until all events are sent before resolving the promise. + * @returns A promise that will resolve with `true` if all events are sent before the timeout, or `false` if there are + * still events in the queue when the timeout is reached. + */ + flush(timeout?: number): PromiseLike; + + /** + * Adds an event processor that applies to any event processed by this client. + */ + addEventProcessor(eventProcessor: EventProcessor): void; + + /** + * Get all added event processors for this client. + */ + getEventProcessors(): EventProcessor[]; + + /** Get the instance of the integration with the given name on the client, if it was added. */ + getIntegrationByName(name: string): T | undefined; + + /** + * Add an integration to the client. + * This can be used to e.g. lazy load integrations. + * In most cases, this should not be necessary, and you're better off just passing the integrations via `integrations: []` at initialization time. + * However, if you find the need to conditionally load & add an integration, you can use `addIntegration` to do so. + * + * */ + addIntegration(integration: Integration): void; + + /** + * Initialize this client. + * Call this after the client was set on a scope. + */ + init(): void; + + /** Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`. */ + eventFromException(exception: any, hint?: EventHint): PromiseLike; + + /** Creates an {@link Event} from primitive inputs to `captureMessage`. */ + eventFromMessage(message: ParameterizedString, level?: SeverityLevel, hint?: EventHint): PromiseLike; + + /** Submits the event to Sentry */ + sendEvent(event: Event, hint?: EventHint): void; + + /** Submits the session to Sentry */ + sendSession(session: Session | SessionAggregates): void; + + /** Sends an envelope to Sentry */ + sendEnvelope(envelope: Envelope): PromiseLike; + + /** + * Record on the client that an event got dropped (ie, an event that will not be sent to sentry). + * + * @param reason The reason why the event got dropped. + * @param category The data category of the dropped event. + * @param event The dropped event. + */ + recordDroppedEvent(reason: EventDropReason, dataCategory: DataCategory, event?: Event): void; + + // HOOKS + /* eslint-disable @typescript-eslint/unified-signatures */ + + /** + * Register a callback for whenever a span is started. + * Receives the span as argument. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'spanStart', callback: (span: Span) => void): () => void; + + /** + * Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision` + * property that can be used to make a sampling decision that will be enforced, before any span sampling runs. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'beforeSampling', + callback: ( + samplingData: { + spanAttributes: SpanAttributes; + spanName: string; + parentSampled?: boolean; + parentContext?: SpanContextData; + }, + samplingDecision: { decision: boolean }, + ) => void, + ): void; + + /** + * Register a callback for whenever a span is ended. + * Receives the span as argument. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + + /** + * Register a callback for when an idle span is allowed to auto-finish. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): () => void; + + /** + * Register a callback for transaction start and finish. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): () => void; + + /** + * Register a callback that runs when stack frame metadata should be applied to an event. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'applyFrameMetadata', callback: (event: Event) => void): () => void; + + /** + * Register a callback for before sending an event. + * This is called right before an event is sent and should not be used to mutate the event. + * Receives an Event & EventHint as arguments. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; + + /** + * Register a callback for preprocessing an event, + * before it is passed to (global) event processors. + * Receives an Event & EventHint as arguments. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; + + /** + * Register a callback for when an event has been sent. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): () => void; + + /** + * Register a callback before a breadcrumb is added. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): () => void; + + /** + * Register a callback when a DSC (Dynamic Sampling Context) is created. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext, rootSpan?: Span) => void): () => void; + + /** + * Register a callback when a Feedback event has been prepared. + * This should be used to mutate the event. The options argument can hint + * about what kind of mutation it expects. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'beforeSendFeedback', + callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void, + ): () => void; + + /** + * A hook for the browser tracing integrations to trigger a span start for a page load. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'startPageLoadSpan', + callback: ( + options: StartSpanOptions, + traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, + ) => void, + ): () => void; + + /** + * A hook for browser tracing integrations to trigger a span for a navigation. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; + + /** + * A hook for GraphQL client integration to enhance a span and breadcrumbs with request data. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + + /** + * A hook that is called when the client is flushing + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'flush', callback: () => void): () => void; + + /** + * A hook that is called when the client is closing + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'close', callback: () => void): () => void; + + /** Fire a hook whener a span starts. */ + emit(hook: 'spanStart', span: Span): void; + + /** A hook that is called every time before a span is sampled. */ + emit( + hook: 'beforeSampling', + samplingData: { + spanAttributes: SpanAttributes; + spanName: string; + parentSampled?: boolean; + parentContext?: SpanContextData; + }, + samplingDecision: { decision: boolean }, + ): void; + + /** Fire a hook whener a span ends. */ + emit(hook: 'spanEnd', span: Span): void; + + /** + * Fire a hook indicating that an idle span is allowed to auto finish. + */ + emit(hook: 'idleSpanEnableAutoFinish', span: Span): void; + + /* + * Fire a hook event for envelope creation and sending. Expects to be given an envelope as the + * second argument. + */ + emit(hook: 'beforeEnvelope', envelope: Envelope): void; + + /* + * Fire a hook indicating that stack frame metadata should be applied to the event passed to the hook. + */ + emit(hook: 'applyFrameMetadata', event: Event): void; + + /** + * Fire a hook event before sending an event. + * This is called right before an event is sent and should not be used to mutate the event. + * Expects to be given an Event & EventHint as the second/third argument. + */ + emit(hook: 'beforeSendEvent', event: Event, hint?: EventHint): void; + + /** + * Fire a hook event to process events before they are passed to (global) event processors. + * Expects to be given an Event & EventHint as the second/third argument. + */ + emit(hook: 'preprocessEvent', event: Event, hint?: EventHint): void; + + /* + * Fire a hook event after sending an event. Expects to be given an Event as the + * second argument. + */ + emit(hook: 'afterSendEvent', event: Event, sendResponse: TransportMakeRequestResponse): void; + + /** + * Fire a hook for when a breadcrumb is added. Expects the breadcrumb as second argument. + */ + emit(hook: 'beforeAddBreadcrumb', breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void; + + /** + * Fire a hook for when a DSC (Dynamic Sampling Context) is created. Expects the DSC as second argument. + */ + emit(hook: 'createDsc', dsc: DynamicSamplingContext, rootSpan?: Span): void; + + /** + * Fire a hook event for after preparing a feedback event. Events to be given + * a feedback event as the second argument, and an optional options object as + * third argument. + */ + emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void; + + /** + * Emit a hook event for browser tracing integrations to trigger a span start for a page load. + */ + emit( + hook: 'startPageLoadSpan', + options: StartSpanOptions, + traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, + ): void; + + /** + * Emit a hook event for browser tracing integrations to trigger a span for a navigation. + */ + emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; + + /** + * Emit a hook event for GraphQL client integration to enhance a span and breadcrumbs with request data. + */ + emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + + /** + * Emit a hook event for client flush + */ + emit(hook: 'flush'): void; + + /** + * Emit a hook event for client close + */ + emit(hook: 'close'): void; + + /* eslint-enable @typescript-eslint/unified-signatures */ +} From 285e61979775caa79a69eb58228654634a9f0b25 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Sun, 29 Sep 2024 11:25:08 -0400 Subject: [PATCH 06/65] feat(browser): Update breadcrumbs with graphql request data Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 41 +++++++- .../integrations/graphqlClient/xhr/test.ts | 40 +++++++- .../browser/src/integrations/breadcrumbs.ts | 66 +++++++------ .../browser/src/integrations/graphqlClient.ts | 93 ++++++++++++++----- packages/core/src/client.ts | 9 ++ packages/types/src/client.ts | 18 +++- 6 files changed, 208 insertions(+), 59 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 17bdfa4b9215..c6d12cb1f17f 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -13,7 +13,7 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { +sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { @@ -56,3 +56,42 @@ sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTe }), }); }); + +sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { + 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(page, url); + + expect(eventData?.breadcrumbs?.length).toBe(1); + + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://sentry-test.io/foo', + __span: expect.any(String), + graphql: { + query: query, + operationName: 'query Test', + }, + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index d1c78626d6c3..983c22905478 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -13,7 +13,7 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { +sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { @@ -56,3 +56,41 @@ sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTest }, }); }); + +sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { + 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(page, url); + + expect(eventData?.breadcrumbs?.length).toBe(1); + + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://sentry-test.io/foo', + graphql: { + query: query, + operationName: 'query Test', + }, + }, + }); +}); diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index a45048ce2640..6d4f0be850d6 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -18,6 +18,7 @@ import type { HandlerDataHistory, HandlerDataXhr, IntegrationFn, + SeverityLevel, XhrBreadcrumbData, XhrBreadcrumbHint, } from '@sentry/core'; @@ -30,6 +31,7 @@ import { getClient, getComponentName, getEventDescription, + getGraphQLRequestPayload, htmlTreeAsString, logger, parseUrl, @@ -251,17 +253,16 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) endTimestamp, }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(status_code); + const breadcrumb = { + category: 'xhr', + data, + type: 'http', + level: getBreadcrumbLogLevelFromHttpStatusCode(status_code), + }; - addBreadcrumb( - { - category: 'xhr', - data, - type: 'http', - level, - }, - hint, - ); + client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { body: getGraphQLRequestPayload(body as string) }); + + addBreadcrumb(breadcrumb, hint); }; } @@ -299,15 +300,18 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe endTimestamp, }; - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); + const breadcrumb = { + category: 'fetch', + data, + level: 'error' as SeverityLevel, + type: 'http', + }; + + client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { + body: getGraphQLRequestPayload(handlerData.fetchData.body as string), + }); + + addBreadcrumb(breadcrumb, hint); } else { const response = handlerData.response as Response | undefined; @@ -321,17 +325,19 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe startTimestamp, endTimestamp, }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); + + const breadcrumb = { + category: 'fetch', + data, + type: 'http', + level: getBreadcrumbLogLevelFromHttpStatusCode(data.status_code), + }; + + client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { + body: getGraphQLRequestPayload(handlerData.fetchData.body as string), + }); + + addBreadcrumb(breadcrumb, hint); } }; } diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index ee6b4cd1f8b8..2e0843d91af4 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -5,7 +5,7 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { IntegrationFn } from '@sentry/types'; +import type { Client, IntegrationFn } from '@sentry/types'; import { parseGraphQLQuery } from '@sentry/utils'; interface GraphQLClientOptions { @@ -24,39 +24,82 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { return { name: INTEGRATION_NAME, setup(client) { - client.on('outgoingRequestSpanStart', (span, { body }) => { - const spanJSON = spanToJSON(span); + _updateSpanWithGraphQLData(client, options); + _updateBreadcrumbWithGraphQLData(client, options); + }, + }; +}) satisfies IntegrationFn; + +function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void { + client.on('outgoingRequestSpanStart', (span, { body }) => { + const spanJSON = spanToJSON(span); + + const spanAttributes = spanJSON.data || {}; + const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]; - const spanAttributes = spanJSON.data || {}; + const isHttpClientSpan = spanOp === 'http.client'; - const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]; - const isHttpClientSpan = spanOp === 'http.client'; + if (isHttpClientSpan) { + const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; - if (isHttpClientSpan) { - const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; + const { endpoints } = options; + const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); - const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); + if (isTracedGraphqlEndpoint) { + const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - if (isTracedGraphqlEndpoint) { - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const graphqlBody = body as GraphQLRequestPayload; + const operationInfo = _getGraphQLOperation(body); + span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + span.setAttribute('body', JSON.stringify(body)); + } + } + }); +} + +function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClientOptions): void { + client.on('outgoingRequestBreadcrumbStart', (breadcrumb, { body }) => { + const { category, type, data } = breadcrumb; + + const isFetch = category === 'fetch'; + const isXhr = category === 'xhr'; + const isHttpBreadcrumb = type === 'http'; - // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request - const graphqlQuery = graphqlBody.query; - const graphqlOperationName = graphqlBody.operationName; + if (isHttpBreadcrumb && (isFetch || isXhr)) { + const httpUrl = data && data.url; + const { endpoints } = options; - const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); - const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; + const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); - span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`); - span.setAttribute('body', JSON.stringify(graphqlBody)); - } + if (isTracedGraphqlEndpoint && data) { + if (!data.graphql) { + const operationInfo = _getGraphQLOperation(body); + + data.graphql = { + query: (body as GraphQLRequestPayload).query, + operationName: operationInfo, + }; } - }); - }, - }; -}) satisfies IntegrationFn; + + // The body prop attached to HandlerDataFetch for the span should be removed. + if (isFetch && data.body) { + delete data.body; + } + } + } + }); +} + +function _getGraphQLOperation(requestBody: unknown): string { + // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request + const graphqlBody = requestBody as GraphQLRequestPayload; + const graphqlQuery = graphqlBody.query; + const graphqlOperationName = graphqlBody.operationName; + + const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); + const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; + + return operationInfo; +} /** * GraphQL Client integration for the browser. diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c7eef9eb6e00..74e733c5b578 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -582,6 +582,12 @@ export abstract class Client { /** @inheritdoc */ public on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + /** @inheritdoc */ + public on( + hook: 'outgoingRequestBreadcrumbStart', + callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + ): () => void; + public on(hook: 'flush', callback: () => void): () => void; /** @@ -711,6 +717,9 @@ export abstract class Client { /** @inheritdoc */ public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + /** @inheritdoc */ + public emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index aa2fd7485ad0..b5b3c7dc0dff 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -292,11 +292,20 @@ export interface Client { on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** - * A hook for GraphQL client integration to enhance a span and breadcrumbs with request data. + * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + /** + * A hook for GraphQL client integration to enhance a breadcrumb with request data. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'outgoingRequestBreadcrumbStart', + callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + ): () => void; + /** * A hook that is called when the client is flushing * @returns A function that, when executed, removes the registered callback. @@ -394,10 +403,15 @@ export interface Client { emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; /** - * Emit a hook event for GraphQL client integration to enhance a span and breadcrumbs with request data. + * Emit a hook event for GraphQL client integration to enhance a span with request data. */ emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + /** + * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. + */ + emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + /** * Emit a hook event for client flush */ From 0095a88499464dd08817912dd2d8a88fae0b1dc2 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 10:51:00 -0500 Subject: [PATCH 07/65] fix(browser): Change breadcrumb hook signature to not be graphql-specific - Updated `outgoingRequestBreadcrumbStart` hook name to `beforeOutgoingRequestBreadcrumb`. - Updated standard graphql request payload structure Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/breadcrumbs.ts | 10 ++--- .../browser/src/integrations/graphqlClient.ts | 40 +++++++++++++------ packages/core/src/client.ts | 8 ++-- packages/core/src/utils-hoist/graphql.ts | 4 +- packages/replay-internal/src/index.ts | 1 + packages/types/src/client.ts | 7 ++-- 6 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 6d4f0be850d6..e0802d3ea861 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -260,7 +260,7 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) level: getBreadcrumbLogLevelFromHttpStatusCode(status_code), }; - client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { body: getGraphQLRequestPayload(body as string) }); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); addBreadcrumb(breadcrumb, hint); }; @@ -307,9 +307,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe type: 'http', }; - client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { - body: getGraphQLRequestPayload(handlerData.fetchData.body as string), - }); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); addBreadcrumb(breadcrumb, hint); } else { @@ -333,9 +331,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe level: getBreadcrumbLogLevelFromHttpStatusCode(data.status_code), }; - client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { - body: getGraphQLRequestPayload(handlerData.fetchData.body as string), - }); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); addBreadcrumb(breadcrumb, hint); } diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 2e0843d91af4..e85572c29986 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,3 +1,5 @@ +import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { getBodyString } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -5,17 +7,19 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { Client, IntegrationFn } from '@sentry/types'; -import { parseGraphQLQuery } from '@sentry/utils'; +import type { Client, HandlerDataFetch, HandlerDataXhr, IntegrationFn } from '@sentry/types'; +import { getGraphQLRequestPayload, parseGraphQLQuery } from '@sentry/utils'; interface GraphQLClientOptions { endpoints: Array; } +// Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body interface GraphQLRequestPayload { query: string; operationName?: string; - variables?: Record; + variables?: Record; + extensions?: Record; } const INTEGRATION_NAME = 'GraphQLClient'; @@ -48,7 +52,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isTracedGraphqlEndpoint) { const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const operationInfo = _getGraphQLOperation(body); + const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(body as string) as GraphQLRequestPayload); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); span.setAttribute('body', JSON.stringify(body)); } @@ -57,7 +61,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption } function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClientOptions): void { - client.on('outgoingRequestBreadcrumbStart', (breadcrumb, { body }) => { + client.on('beforeOutgoingRequestBreadcrumb', (breadcrumb, handlerData) => { const { category, type, data } = breadcrumb; const isFetch = category === 'fetch'; @@ -71,11 +75,24 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); if (isTracedGraphqlEndpoint && data) { - if (!data.graphql) { - const operationInfo = _getGraphQLOperation(body); + + let body: string | undefined; + + if(isXhr){ + const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + body = getBodyString(sentryXhrData?.body)[0] + + } else if(isFetch){ + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData + body = getBodyString(sentryFetchData.body)[0] + } + + const graphqlBody = getGraphQLRequestPayload(body as string) + if (!data.graphql && graphqlBody) { + const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); data.graphql = { - query: (body as GraphQLRequestPayload).query, + query: (graphqlBody as GraphQLRequestPayload).query, operationName: operationInfo, }; } @@ -89,11 +106,8 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient }); } -function _getGraphQLOperation(requestBody: unknown): string { - // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request - const graphqlBody = requestBody as GraphQLRequestPayload; - const graphqlQuery = graphqlBody.query; - const graphqlOperationName = graphqlBody.operationName; +function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { + const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 74e733c5b578..5e2aac3560eb 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -14,6 +14,8 @@ import type { EventHint, EventProcessor, FeedbackEvent, + HandlerDataFetch, + HandlerDataXhr, Integration, MonitorConfig, Outcome, @@ -584,8 +586,8 @@ export abstract class Client { /** @inheritdoc */ public on( - hook: 'outgoingRequestBreadcrumbStart', - callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + hook: 'beforeOutgoingRequestBreadcrumb', + callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, ): () => void; public on(hook: 'flush', callback: () => void): () => void; @@ -718,7 +720,7 @@ export abstract class Client { public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; /** @inheritdoc */ - public emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 8b4265f4307c..0abc7796ca0a 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -25,11 +25,11 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { } /** - * Extract the payload of a request ONLY if it's GraphQL. + * Extract the payload of a request if it's GraphQL. * @param payload - A valid JSON string * @returns A POJO or undefined */ -export function getGraphQLRequestPayload(payload: string): any | undefined { +export function getGraphQLRequestPayload(payload: string): unknown | undefined { let graphqlBody = undefined; try { const requestBody = JSON.parse(payload); diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index c10beb30228c..c94bf837244a 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -16,3 +16,4 @@ export type { } from './types'; export { getReplay } from './util/getReplay'; +export { getBodyString } from './coreHandlers/util/networkUtils'; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index b5b3c7dc0dff..2976d164e1fa 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -7,6 +7,7 @@ import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; import type { FeedbackEvent } from './feedback'; +import type { HandlerDataFetch, HandlerDataXhr } from './instrument'; import type { Integration } from './integration'; import type { ClientOptions } from './options'; import type { ParameterizedString } from './parameterize'; @@ -302,8 +303,8 @@ export interface Client { * @returns A function that, when executed, removes the registered callback. */ on( - hook: 'outgoingRequestBreadcrumbStart', - callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + hook: 'beforeOutgoingRequestBreadcrumb', + callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, ): () => void; /** @@ -410,7 +411,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** * Emit a hook event for client flush From 47618d89bc04dfaa681f39b1316c0ecbf822ab49 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 12:32:14 -0500 Subject: [PATCH 08/65] fix(browser): Change span hook signature to not be graphql-specific - Renamed `outgoingRequestSpanStart` hook to `beforeOutgoingRequestSpan`. - Followed Otel semantic for GraphQL Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 2 +- .../integrations/graphqlClient/xhr/test.ts | 2 +- .../browser/src/integrations/breadcrumbs.ts | 6 ++---- .../browser/src/integrations/graphqlClient.ts | 18 ++++++++++++++++-- packages/browser/src/tracing/request.ts | 2 +- packages/core/src/client.ts | 4 ++-- packages/core/src/fetch.ts | 2 +- packages/types/src/client.ts | 4 ++-- 8 files changed, 26 insertions(+), 14 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index c6d12cb1f17f..6de03670040d 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -52,7 +52,7 @@ sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTe 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: queryPayload, + 'graphql.document': queryPayload, }), }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 983c22905478..337f032f3746 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -52,7 +52,7 @@ sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTest 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: queryPayload, + 'graphql.document': queryPayload, }, }); }); diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index e0802d3ea861..6339769c0b91 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -18,7 +18,6 @@ import type { HandlerDataHistory, HandlerDataXhr, IntegrationFn, - SeverityLevel, XhrBreadcrumbData, XhrBreadcrumbHint, } from '@sentry/core'; @@ -31,7 +30,6 @@ import { getClient, getComponentName, getEventDescription, - getGraphQLRequestPayload, htmlTreeAsString, logger, parseUrl, @@ -303,9 +301,9 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const breadcrumb = { category: 'fetch', data, - level: 'error' as SeverityLevel, + level: 'error', type: 'http', - }; + } satisfies Breadcrumb; client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index e85572c29986..cae7426eb2ad 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -35,7 +35,7 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { }) satisfies IntegrationFn; function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void { - client.on('outgoingRequestSpanStart', (span, { body }) => { + client.on('beforeOutgoingRequestSpan', (span, handlerData) => { const spanJSON = spanToJSON(span); const spanAttributes = spanJSON.data || {}; @@ -51,10 +51,24 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isTracedGraphqlEndpoint) { const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; + + const isXhr = 'xhr' in handlerData; + const isFetch = 'fetchData' in handlerData; + + let body: string | undefined; + + if(isXhr){ + const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + body = getBodyString(sentryXhrData?.body)[0] + + } else if(isFetch){ + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData + body = getBodyString(sentryFetchData.body)[0] + } const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(body as string) as GraphQLRequestPayload); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('body', JSON.stringify(body)); + span.setAttribute('graphql.document', body); } } }); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index de9058fc1140..b6b3505f945e 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -405,7 +405,7 @@ export function xhrCallback( } if (client) { - client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(sentryXhrData.body as string) }); + client.emit('beforeOutgoingRequestSpan', span, handlerData); } return span; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5e2aac3560eb..fffd01794384 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -582,7 +582,7 @@ export abstract class Client { public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** @inheritdoc */ - public on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; /** @inheritdoc */ public on( @@ -717,7 +717,7 @@ export abstract class Client { public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; /** @inheritdoc */ - public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** @inheritdoc */ public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index d3c8dcf4a6d7..67a08e84f17a 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -97,7 +97,7 @@ export function instrumentFetchRequest( } if (client) { - client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(body as string) }); + client.emit('beforeOutgoingRequestSpan', span, handlerData); } return span; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 2976d164e1fa..297457df33ef 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -296,7 +296,7 @@ export interface Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -406,7 +406,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. From 581374b6a1542004c9946573f04fea5b9943589d Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 13:05:51 -0500 Subject: [PATCH 09/65] fix(browser): Add more requested fixes - Added guard for missing `httpMethod`. - Refactored getting body based on xhr or fetch logic into a function. - Added Otel semantic in breadcrumb data. Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 6 +- .../integrations/graphqlClient/xhr/test.ts | 6 +- .../browser/src/integrations/graphqlClient.ts | 76 ++++++++++--------- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 6de03670040d..9a5a953901aa 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -88,10 +88,8 @@ sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getL status_code: 200, url: 'http://sentry-test.io/foo', __span: expect.any(String), - graphql: { - query: query, - operationName: 'query Test', - }, + 'graphql.document': query, + 'graphql.operation': 'query Test', }, }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 337f032f3746..00357c0acf43 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -87,10 +87,8 @@ sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLoc method: 'POST', status_code: 200, url: 'http://sentry-test.io/foo', - graphql: { - query: query, - operationName: 'query Test', - }, + 'graphql.document': query, + 'graphql.operation': 'query Test', }, }); }); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index cae7426eb2ad..aa187d14670a 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -8,10 +8,10 @@ import { spanToJSON, } from '@sentry/core'; import type { Client, HandlerDataFetch, HandlerDataXhr, IntegrationFn } from '@sentry/types'; -import { getGraphQLRequestPayload, parseGraphQLQuery } from '@sentry/utils'; +import { getGraphQLRequestPayload, isString, parseGraphQLQuery, stringMatchesSomePattern } from '@sentry/utils'; interface GraphQLClientOptions { - endpoints: Array; + endpoints: Array; } // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body @@ -45,30 +45,21 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isHttpClientSpan) { const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; + const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; + + if (!isString(httpUrl) || !isString(httpMethod)){ + return + } const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); - - if (isTracedGraphqlEndpoint) { - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - - const isXhr = 'xhr' in handlerData; - const isFetch = 'fetchData' in handlerData; + const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - let body: string | undefined; + if (isTracedGraphqlEndpoint) { + const payload = _getRequestPayloadXhrOrFetch(handlerData) + const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(payload as string) as GraphQLRequestPayload); - if(isXhr){ - const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; - body = getBodyString(sentryXhrData?.body)[0] - - } else if(isFetch){ - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData - body = getBodyString(sentryFetchData.body)[0] - } - - const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(body as string) as GraphQLRequestPayload); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('graphql.document', body); + span.setAttribute('graphql.document', payload); } } }); @@ -86,29 +77,18 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const httpUrl = data && data.url; const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); + const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); if (isTracedGraphqlEndpoint && data) { - let body: string | undefined; - - if(isXhr){ - const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; - body = getBodyString(sentryXhrData?.body)[0] + const payload = _getRequestPayloadXhrOrFetch(handlerData) + const graphqlBody = getGraphQLRequestPayload(payload as string) - } else if(isFetch){ - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData - body = getBodyString(sentryFetchData.body)[0] - } - - const graphqlBody = getGraphQLRequestPayload(body as string) if (!data.graphql && graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data.graphql = { - query: (graphqlBody as GraphQLRequestPayload).query, - operationName: operationInfo, - }; + data["graphql.document"] = (graphqlBody as GraphQLRequestPayload).query + data["graphql.operation"] = operationInfo; } // The body prop attached to HandlerDataFetch for the span should be removed. @@ -129,6 +109,28 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { return operationInfo; } +/** + * Get the request body/payload based on the shape of the HandlerData + * @param handlerData - Xhr or Fetch HandlerData + */ +function _getRequestPayloadXhrOrFetch(handlerData: HandlerDataXhr | HandlerDataFetch): string | undefined { + const isXhr = 'xhr' in handlerData; + const isFetch = 'fetchData' in handlerData; + + let body: string | undefined; + + if(isXhr){ + const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + body = getBodyString(sentryXhrData?.body)[0] + + } else if(isFetch){ + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData + body = getBodyString(sentryFetchData.body)[0] + } + + return body +} + /** * GraphQL Client integration for the browser. */ From 6ef6953aef3703a22e6fb1a960b304044bca898d Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 13:28:41 -0500 Subject: [PATCH 10/65] fix(browser): Refactor to reduce type assertions Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/graphqlClient.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index aa187d14670a..d0fe5fcd73d7 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -53,10 +53,11 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); + const payload = _getRequestPayloadXhrOrFetch(handlerData) - if (isTracedGraphqlEndpoint) { - const payload = _getRequestPayloadXhrOrFetch(handlerData) - const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(payload as string) as GraphQLRequestPayload); + if (isTracedGraphqlEndpoint && payload) { + const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload + const operationInfo = _getGraphQLOperation(graphqlBody); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); span.setAttribute('graphql.document', payload); @@ -78,15 +79,14 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); + const payload = _getRequestPayloadXhrOrFetch(handlerData) - if (isTracedGraphqlEndpoint && data) { + if (isTracedGraphqlEndpoint && data && payload) { - const payload = _getRequestPayloadXhrOrFetch(handlerData) - const graphqlBody = getGraphQLRequestPayload(payload as string) + const graphqlBody = getGraphQLRequestPayload(payload) if (!data.graphql && graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data["graphql.document"] = (graphqlBody as GraphQLRequestPayload).query data["graphql.operation"] = operationInfo; } @@ -100,6 +100,10 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient }); } +/** + * @param requestBody - GraphQL request + * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' + */ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody From 1cbe78674e8c7a78a1431097ec375f7a82401a26 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 13:40:55 -0500 Subject: [PATCH 11/65] chore(browser): Fix lint errors Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/graphqlClient.ts | 38 +++++++++---------- packages/core/src/client.ts | 11 +++++- packages/types/src/client.ts | 11 +++++- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index d0fe5fcd73d7..075a4ab60f72 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -46,17 +46,17 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isHttpClientSpan) { const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - - if (!isString(httpUrl) || !isString(httpMethod)){ - return + + if (!isString(httpUrl) || !isString(httpMethod)) { + return; } const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData) + const payload = _getRequestPayloadXhrOrFetch(handlerData); - if (isTracedGraphqlEndpoint && payload) { - const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload + if (isTracedGraphqlEndpoint && payload) { + const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; const operationInfo = _getGraphQLOperation(graphqlBody); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); @@ -79,16 +79,15 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData) + const payload = _getRequestPayloadXhrOrFetch(handlerData); if (isTracedGraphqlEndpoint && data && payload) { - - const graphqlBody = getGraphQLRequestPayload(payload) + const graphqlBody = getGraphQLRequestPayload(payload); if (!data.graphql && graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data["graphql.document"] = (graphqlBody as GraphQLRequestPayload).query - data["graphql.operation"] = operationInfo; + data['graphql.document'] = (graphqlBody as GraphQLRequestPayload).query; + data['graphql.operation'] = operationInfo; } // The body prop attached to HandlerDataFetch for the span should be removed. @@ -101,11 +100,11 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient } /** - * @param requestBody - GraphQL request + * @param requestBody - GraphQL request * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' */ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { - const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody + const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; @@ -123,16 +122,15 @@ function _getRequestPayloadXhrOrFetch(handlerData: HandlerDataXhr | HandlerDataF let body: string | undefined; - if(isXhr){ + if (isXhr) { const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; - body = getBodyString(sentryXhrData?.body)[0] - - } else if(isFetch){ - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData - body = getBodyString(sentryFetchData.body)[0] + body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; + } else if (isFetch) { + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData; + body = getBodyString(sentryFetchData.body)[0]; } - return body + return body; } /** diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index fffd01794384..e3c433fb615d 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -582,7 +582,10 @@ export abstract class Client { public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** @inheritdoc */ - public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; + public on( + hook: 'beforeOutgoingRequestSpan', + callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + ): () => void; /** @inheritdoc */ public on( @@ -720,7 +723,11 @@ export abstract class Client { public emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; + public emit( + hook: 'beforeOutgoingRequestBreadcrumb', + breadcrumb: Breadcrumb, + handlerData: HandlerDataXhr | HandlerDataFetch, + ): void; /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 297457df33ef..2a829725364b 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -296,7 +296,10 @@ export interface Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; + on( + hook: 'beforeOutgoingRequestSpan', + callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + ): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -411,7 +414,11 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; + emit( + hook: 'beforeOutgoingRequestBreadcrumb', + breadcrumb: Breadcrumb, + handlerData: HandlerDataXhr | HandlerDataFetch, + ): void; /** * Emit a hook event for client flush From 4f64feb4e030b05b50e230b6d20c839ac331af9f Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:19:12 -0500 Subject: [PATCH 12/65] fix(browser): Refactor span handler to use hint - Moved fetch-related operations into the grahqlClient integration. - Used Hint types for `beforeOutgoingRequestSpan` hooks. Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 126 ++++++++++++++---- packages/browser/src/tracing/request.ts | 3 +- .../test/integrations/graphqlClient.test.ts | 94 +++++++++++++ packages/core/src/client.ts | 6 +- packages/core/src/fetch.ts | 3 +- packages/core/src/utils-hoist/graphql.ts | 49 ------- .../core/src/utils-hoist/instrument/fetch.ts | 14 -- packages/replay-internal/src/index.ts | 2 + packages/types/src/client.ts | 21 ++- packages/utils/test/graphql.test.ts | 0 packages/utils/test/instrument/fetch.test.ts | 0 11 files changed, 220 insertions(+), 98 deletions(-) create mode 100644 packages/browser/test/integrations/graphqlClient.test.ts create mode 100644 packages/utils/test/graphql.test.ts create mode 100644 packages/utils/test/instrument/fetch.test.ts diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 075a4ab60f72..2b3755d6d66e 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,5 @@ import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; -import { getBodyString } from '@sentry-internal/replay'; +import { FetchHint, getBodyString, XhrHint } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -7,8 +7,8 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { Client, HandlerDataFetch, HandlerDataXhr, IntegrationFn } from '@sentry/types'; -import { getGraphQLRequestPayload, isString, parseGraphQLQuery, stringMatchesSomePattern } from '@sentry/utils'; +import type { Client, IntegrationFn } from '@sentry/types'; +import { isString, stringMatchesSomePattern } from '@sentry/utils'; interface GraphQLClientOptions { endpoints: Array; @@ -35,7 +35,7 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { }) satisfies IntegrationFn; function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void { - client.on('beforeOutgoingRequestSpan', (span, handlerData) => { + client.on('beforeOutgoingRequestSpan', (span, hint) => { const spanJSON = spanToJSON(span); const spanAttributes = spanJSON.data || {}; @@ -43,26 +43,29 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const isHttpClientSpan = spanOp === 'http.client'; - if (isHttpClientSpan) { - const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; + if (!isHttpClientSpan) { + return; + } - if (!isString(httpUrl) || !isString(httpMethod)) { - return; - } + const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; + const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const { endpoints } = options; - const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData); + if (!isString(httpUrl) || !isString(httpMethod)) { + return; + } - if (isTracedGraphqlEndpoint && payload) { - const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; - const operationInfo = _getGraphQLOperation(graphqlBody); + const { endpoints } = options; + const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); + const payload = _getRequestPayloadXhrOrFetch(hint); - span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('graphql.document', payload); - } + if (isTracedGraphqlEndpoint && payload) { + const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; + const operationInfo = _getGraphQLOperation(graphqlBody); + + span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + span.setAttribute('graphql.document', payload); } + }); } @@ -113,26 +116,95 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { } /** - * Get the request body/payload based on the shape of the HandlerData - * @param handlerData - Xhr or Fetch HandlerData + * Get the request body/payload based on the shape of the hint + * TODO: export for test? */ -function _getRequestPayloadXhrOrFetch(handlerData: HandlerDataXhr | HandlerDataFetch): string | undefined { - const isXhr = 'xhr' in handlerData; - const isFetch = 'fetchData' in handlerData; +function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { + const isXhr = 'xhr' in hint; + const isFetch = !isXhr let body: string | undefined; if (isXhr) { - const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + const sentryXhrData = hint.xhr[SENTRY_XHR_DATA_KEY]; body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; } else if (isFetch) { - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData; - body = getBodyString(sentryFetchData.body)[0]; + const sentryFetchData = parseFetchPayload(hint.input); + body = getBodyString(sentryFetchData)[0]; } return body; } +function hasProp(obj: unknown, prop: T): obj is Record { + return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; +} + +/** + * Parses the fetch arguments to extract the request payload. + * Exported for tests only. + */ +export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { + if (fetchArgs.length === 2) { + const options = fetchArgs[1]; + return hasProp(options, 'body') ? String(options.body) : undefined; + } + + const arg = fetchArgs[0]; + return hasProp(arg, 'body') ? String(arg.body) : undefined; +} + +interface GraphQLOperation { + operationType: string | undefined; + operationName: string | undefined; +} + +/** + * Extract the name and type of the operation from the GraphQL query. + * @param query + */ +export function parseGraphQLQuery(query: string): GraphQLOperation { + const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; + + const matched = query.match(queryRe); + + if (matched) { + return { + operationType: matched[1], + operationName: matched[2], + }; + } + return { + operationType: undefined, + operationName: undefined, + }; +} + +/** + * 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): unknown | undefined { + let graphqlBody = undefined; + try { + const requestBody = JSON.parse(payload); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const isGraphQLRequest = !!requestBody['query']; + + if (isGraphQLRequest) { + graphqlBody = requestBody; + } + } finally { + // Fallback to undefined if payload is an invalid JSON (SyntaxError) + + /* eslint-disable no-unsafe-finally */ + return graphqlBody; + } +} + /** * GraphQL Client integration for the browser. */ diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index b6b3505f945e..3d0f5b13c848 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -23,6 +23,7 @@ import { stringMatchesSomePattern, } from '@sentry/core'; import { WINDOW } from '../helpers'; +import { XhrHint } from '@sentry-internal/replay'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { @@ -405,7 +406,7 @@ export function xhrCallback( } if (client) { - client.emit('beforeOutgoingRequestSpan', span, handlerData); + client.emit('beforeOutgoingRequestSpan', span, handlerData as XhrHint); } return span; diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts new file mode 100644 index 000000000000..db42ed3f888c --- /dev/null +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -0,0 +1,94 @@ +import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from "../../src/integrations/graphqlClient"; + +describe('GraphqlClient', () => { + describe('parseFetchPayload', () => { + + const data = [1, 2, 3]; + const jsonData = '{"data":[1,2,3]}'; + + it.each([ + ['string URL only', ['http://example.com'], undefined], + ['URL object only', [new URL('http://example.com')], undefined], + ['Request URL only', [{ url: 'http://example.com' }], undefined], + [ + 'Request URL & method only', + [{ url: 'http://example.com', method: 'post', body: JSON.stringify({ data }) }], + jsonData, + ], + ['string URL & options', ['http://example.com', { method: 'post', body: JSON.stringify({ data }) }], jsonData], + [ + 'URL object & options', + [new URL('http://example.com'), { method: 'post', body: JSON.stringify({ data }) }], + jsonData, + ], + [ + 'Request URL & options', + [{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify({ data }) }], + jsonData, + ], + ])('%s', (_name, args, expected) => { + const actual = parseFetchPayload(args as unknown[]); + + expect(actual).toEqual(expected); + }); + }); + + describe('parseGraphQLQuery', () => { + const queryOne = `query Test { + items { + id + } + }`; + + const queryTwo = `mutation AddTestItem($input: TestItem!) { + addItem(input: $input) { + name + } + }`; + + const queryThree = `subscription OnTestItemAdded($itemID: ID!) { + itemAdded(itemID: $itemID) { + id + } + }`; + + // TODO: support name-less queries + // const queryFour = ` query { + // items { + // id + // } + // }`; + + test.each([ + ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], + ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], + [ + 'should handle subscription type', + queryThree, + { operationName: 'OnTestItemAdded', operationType: 'subscription' }, + ], + // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ])('%s', (_, input, output) => { + expect(parseGraphQLQuery(input)).toEqual(output); + }); + }); + + describe('getGraphQLRequestPayload', () => { + test('should return undefined for non-GraphQL request', () => { + const requestBody = { data: [1, 2, 3] }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + test('should return the payload object for GraphQL request', () => { + const requestBody = { + query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', + operationName: 'Test', + variables: {}, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); + }); + }); +}); + + diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index e3c433fb615d..bb6cb083ab7c 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -14,6 +14,7 @@ import type { EventHint, EventProcessor, FeedbackEvent, + FetchBreadcrumbHint, HandlerDataFetch, HandlerDataXhr, Integration, @@ -21,6 +22,7 @@ import type { Outcome, ParameterizedString, SdkMetadata, + SentryWrappedXMLHttpRequest, Session, SessionAggregates, SeverityLevel, @@ -584,7 +586,7 @@ export abstract class Client { /** @inheritdoc */ public on( hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (span: Span, hint: XhrHint | FetchHint ) => void, ): () => void; /** @inheritdoc */ @@ -720,7 +722,7 @@ export abstract class Client { public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** @inheritdoc */ public emit( diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 67a08e84f17a..7b4acc18eb3f 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -97,7 +97,8 @@ export function instrumentFetchRequest( } if (client) { - client.emit('beforeOutgoingRequestSpan', span, handlerData); + const fetchHint = { input: handlerData.args , response: handlerData.response, startTimestamp: handlerData.startTimestamp, endTimestamp: handlerData.endTimestamp } satisfies FetchHint + client.emit('beforeOutgoingRequestSpan', span, fetchHint); } return span; diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 0abc7796ca0a..e69de29bb2d1 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -1,49 +0,0 @@ -interface GraphQLOperation { - operationType: string | undefined; - operationName: string | undefined; -} - -/** - * Extract the name and type of the operation from the GraphQL query. - * @param query - */ -export function parseGraphQLQuery(query: string): GraphQLOperation { - const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; - - const matched = query.match(queryRe); - - if (matched) { - return { - operationType: matched[1], - operationName: matched[2], - }; - } - return { - operationType: undefined, - operationName: undefined, - }; -} - -/** - * Extract the payload of a request if it's GraphQL. - * @param payload - A valid JSON string - * @returns A POJO or undefined - */ -export function getGraphQLRequestPayload(payload: string): unknown | undefined { - let graphqlBody = undefined; - try { - const requestBody = JSON.parse(payload); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const isGraphQLRequest = !!requestBody['query']; - - if (isGraphQLRequest) { - graphqlBody = requestBody; - } - } finally { - // Fallback to undefined if payload is an invalid JSON (SyntaxError) - - /* eslint-disable no-unsafe-finally */ - return graphqlBody; - } -} diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index 63832e9ee1fd..c352f67df3b3 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { HandlerDataFetch } from '../../types-hoist'; -import { getGraphQLRequestPayload } from '../graphql'; import { isError } from '../is'; import { addNonEnumerableProperty, fill } from '../object'; import { supportsNativeFetch } from '../supports'; @@ -236,16 +235,3 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str }; } -/** - * Parses the fetch arguments to extract the request payload. - * Exported for tests only. - */ -export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { - if (fetchArgs.length === 2) { - const options = fetchArgs[1]; - return hasProp(options, 'body') ? String(options.body) : undefined; - } - - const arg = fetchArgs[0]; - return hasProp(arg, 'body') ? String(arg.body) : undefined; -} diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index c94bf837244a..723ef811862f 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -13,6 +13,8 @@ export type { ReplaySpanFrameEvent, CanvasManagerInterface, CanvasManagerOptions, + FetchHint, + XhrHint, } from './types'; export { getReplay } from './util/getReplay'; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 2a829725364b..2053189019eb 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -1,4 +1,6 @@ -import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; +// if imported, circular dep +// import type { FetchHint, XhrHint } from '@sentry-internal/replay'; +import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './breadcrumb'; import type { CheckIn, MonitorConfig } from './checkin'; import type { EventDropReason } from './clientreport'; import type { DataCategory } from './datacategory'; @@ -7,7 +9,7 @@ import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; import type { FeedbackEvent } from './feedback'; -import type { HandlerDataFetch, HandlerDataXhr } from './instrument'; +import type { HandlerDataFetch, HandlerDataXhr, SentryWrappedXMLHttpRequest } from './instrument'; import type { Integration } from './integration'; import type { ClientOptions } from './options'; import type { ParameterizedString } from './parameterize'; @@ -19,6 +21,17 @@ import type { Span, SpanAttributes, SpanContextData } from './span'; import type { StartSpanOptions } from './startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './transport'; +type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; + +export type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; +export type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; + /** * User-Facing Sentry SDK Client. * @@ -298,7 +311,7 @@ export interface Client { */ on( hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (span: Span, hint: XhrHint | FetchHint) => void, ): () => void; /** @@ -409,7 +422,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; + emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. diff --git a/packages/utils/test/graphql.test.ts b/packages/utils/test/graphql.test.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/utils/test/instrument/fetch.test.ts b/packages/utils/test/instrument/fetch.test.ts new file mode 100644 index 000000000000..e69de29bb2d1 From 7517a93c0158a7a9e8d44156ff5a2cedcd12e3ce Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 14:03:13 -0500 Subject: [PATCH 13/65] fix(browser): Refactor breadcrumb handlers to use hint Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/breadcrumbs.ts | 10 +++++--- .../browser/src/integrations/graphqlClient.ts | 23 ++++++++--------- packages/browser/src/tracing/request.ts | 1 - .../test/integrations/graphqlClient.test.ts | 15 +++++------ packages/core/src/client.ts | 25 +++++++++++-------- packages/core/src/fetch.ts | 10 ++++++-- .../core/src/utils-hoist/instrument/fetch.ts | 1 - packages/types/src/client.ts | 15 +++-------- 8 files changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 6339769c0b91..52eabb32a558 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -35,7 +35,9 @@ import { parseUrl, safeJoin, severityLevelFromString, -} from '@sentry/core'; +} from '@sentry/utils'; + +import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; @@ -258,7 +260,7 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) level: getBreadcrumbLogLevelFromHttpStatusCode(status_code), }; - client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as XhrHint); addBreadcrumb(breadcrumb, hint); }; @@ -305,7 +307,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe type: 'http', } satisfies Breadcrumb; - client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as FetchHint); addBreadcrumb(breadcrumb, hint); } else { @@ -329,7 +331,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe level: getBreadcrumbLogLevelFromHttpStatusCode(data.status_code), }; - client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as FetchHint); addBreadcrumb(breadcrumb, hint); } diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 2b3755d6d66e..a82cea3da809 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,6 @@ import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; -import { FetchHint, getBodyString, XhrHint } from '@sentry-internal/replay'; +import { getBodyString } from '@sentry-internal/replay'; +import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -22,6 +23,11 @@ interface GraphQLRequestPayload { extensions?: Record; } +interface GraphQLOperation { + operationType: string | undefined; + operationName: string | undefined; +} + const INTEGRATION_NAME = 'GraphQLClient'; const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { @@ -65,7 +71,6 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); span.setAttribute('graphql.document', payload); } - }); } @@ -92,11 +97,6 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient data['graphql.document'] = (graphqlBody as GraphQLRequestPayload).query; data['graphql.operation'] = operationInfo; } - - // The body prop attached to HandlerDataFetch for the span should be removed. - if (isFetch && data.body) { - delete data.body; - } } } }); @@ -121,7 +121,7 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { */ function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { const isXhr = 'xhr' in hint; - const isFetch = !isXhr + const isFetch = !isXhr; let body: string | undefined; @@ -136,6 +136,7 @@ function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undef return body; } +// Duplicate from deprecated @sentry-utils/src/instrument/fetch.ts function hasProp(obj: unknown, prop: T): obj is Record { return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; } @@ -154,13 +155,9 @@ export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { return hasProp(arg, 'body') ? String(arg.body) : undefined; } -interface GraphQLOperation { - operationType: string | undefined; - operationName: string | undefined; -} - /** * Extract the name and type of the operation from the GraphQL query. + * Exported for tests only. * @param query */ export function parseGraphQLQuery(query: string): GraphQLOperation { diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 3d0f5b13c848..4bdcf4cd35cd 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -23,7 +23,6 @@ import { stringMatchesSomePattern, } from '@sentry/core'; import { WINDOW } from '../helpers'; -import { XhrHint } from '@sentry-internal/replay'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index db42ed3f888c..dc5be0eea344 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -1,11 +1,10 @@ -import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from "../../src/integrations/graphqlClient"; +import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from '../../src/integrations/graphqlClient'; describe('GraphqlClient', () => { - describe('parseFetchPayload', () => { - + describe('parseFetchPayload', () => { const data = [1, 2, 3]; const jsonData = '{"data":[1,2,3]}'; - + it.each([ ['string URL only', ['http://example.com'], undefined], ['URL object only', [new URL('http://example.com')], undefined], @@ -28,10 +27,10 @@ describe('GraphqlClient', () => { ], ])('%s', (_name, args, expected) => { const actual = parseFetchPayload(args as unknown[]); - + expect(actual).toEqual(expected); }); - }); + }); describe('parseGraphQLQuery', () => { const queryOne = `query Test { @@ -72,7 +71,7 @@ describe('GraphqlClient', () => { expect(parseGraphQLQuery(input)).toEqual(output); }); }); - + describe('getGraphQLRequestPayload', () => { test('should return undefined for non-GraphQL request', () => { const requestBody = { data: [1, 2, 3] }; @@ -90,5 +89,3 @@ describe('GraphqlClient', () => { }); }); }); - - diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index bb6cb083ab7c..339ab3efcf4f 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -16,7 +16,6 @@ import type { FeedbackEvent, FetchBreadcrumbHint, HandlerDataFetch, - HandlerDataXhr, Integration, MonitorConfig, Outcome, @@ -65,6 +64,17 @@ import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; +type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; + +export type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; +export type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; + /** * Base implementation for all JavaScript SDK clients. * @@ -584,15 +594,12 @@ export abstract class Client { public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** @inheritdoc */ - public on( - hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, hint: XhrHint | FetchHint ) => void, - ): () => void; + public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; /** @inheritdoc */ public on( hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, ): () => void; public on(hook: 'flush', callback: () => void): () => void; @@ -725,11 +732,7 @@ export abstract class Client { public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** @inheritdoc */ - public emit( - hook: 'beforeOutgoingRequestBreadcrumb', - breadcrumb: Breadcrumb, - handlerData: HandlerDataXhr | HandlerDataFetch, - ): void; + public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 7b4acc18eb3f..eec92bf292c2 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -97,8 +97,14 @@ export function instrumentFetchRequest( } if (client) { - const fetchHint = { input: handlerData.args , response: handlerData.response, startTimestamp: handlerData.startTimestamp, endTimestamp: handlerData.endTimestamp } satisfies FetchHint - client.emit('beforeOutgoingRequestSpan', span, fetchHint); + // There's no 'input' key in HandlerDataFetch + const fetchHint = { + input: handlerData.args, + response: handlerData.response, + startTimestamp: handlerData.startTimestamp, + endTimestamp: handlerData.endTimestamp, + }; + client.emit('beforeOutgoingRequestSpan', span, fetchHint as FetchHint); } return span; diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index c352f67df3b3..f3eee711d26d 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -234,4 +234,3 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', }; } - diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 2053189019eb..aede0ce5a5a3 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -9,7 +9,7 @@ import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; import type { FeedbackEvent } from './feedback'; -import type { HandlerDataFetch, HandlerDataXhr, SentryWrappedXMLHttpRequest } from './instrument'; +import type { HandlerDataFetch, SentryWrappedXMLHttpRequest } from './instrument'; import type { Integration } from './integration'; import type { ClientOptions } from './options'; import type { ParameterizedString } from './parameterize'; @@ -309,10 +309,7 @@ export interface Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - on( - hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, hint: XhrHint | FetchHint) => void, - ): () => void; + on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -320,7 +317,7 @@ export interface Client { */ on( hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, ): () => void; /** @@ -427,11 +424,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit( - hook: 'beforeOutgoingRequestBreadcrumb', - breadcrumb: Breadcrumb, - handlerData: HandlerDataXhr | HandlerDataFetch, - ): void; + emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; /** * Emit a hook event for client flush From 8dcf72bbad5da4303efedd7107244814dfef0377 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 14:47:02 -0500 Subject: [PATCH 14/65] test(browser): Add tests for `getRequestPayloadXhrOrFetch` Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 11 ++- .../test/integrations/graphqlClient.test.ts | 85 ++++++++++++++++++- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index a82cea3da809..39ed18830bc8 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -62,7 +62,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(hint); + const payload = getRequestPayloadXhrOrFetch(hint); if (isTracedGraphqlEndpoint && payload) { const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; @@ -87,7 +87,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData); + const payload = getRequestPayloadXhrOrFetch(handlerData); if (isTracedGraphqlEndpoint && data && payload) { const graphqlBody = getGraphQLRequestPayload(payload); @@ -117,18 +117,17 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { /** * Get the request body/payload based on the shape of the hint - * TODO: export for test? + * Exported for tests only. */ -function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { +export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { const isXhr = 'xhr' in hint; - const isFetch = !isXhr; let body: string | undefined; if (isXhr) { const sentryXhrData = hint.xhr[SENTRY_XHR_DATA_KEY]; body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; - } else if (isFetch) { + } else { const sentryFetchData = parseFetchPayload(hint.input); body = getBodyString(sentryFetchData)[0]; } diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index dc5be0eea344..5fb58a8c8150 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -1,11 +1,24 @@ -import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from '../../src/integrations/graphqlClient'; +/** + * @vitest-environment jsdom + */ + +import { describe, expect, test } from 'vitest'; + +import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import type { FetchHint, XhrHint } from '@sentry-internal/replay'; +import { + getGraphQLRequestPayload, + getRequestPayloadXhrOrFetch, + parseFetchPayload, + parseGraphQLQuery, +} from '../../src/integrations/graphqlClient'; describe('GraphqlClient', () => { describe('parseFetchPayload', () => { const data = [1, 2, 3]; const jsonData = '{"data":[1,2,3]}'; - it.each([ + test.each([ ['string URL only', ['http://example.com'], undefined], ['URL object only', [new URL('http://example.com')], undefined], ['Request URL only', [{ url: 'http://example.com' }], undefined], @@ -88,4 +101,72 @@ describe('GraphqlClient', () => { expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); }); }); + + describe('getRequestPayloadXhrOrFetch', () => { + test('should parse xhr payload', () => { + const hint: XhrHint = { + xhr: { + [SENTRY_XHR_DATA_KEY]: { + method: 'POST', + url: 'http://example.com/test', + status_code: 200, + body: JSON.stringify({ key: 'value' }), + request_headers: { + 'Content-Type': 'application/json', + }, + }, + ...new XMLHttpRequest(), + }, + input: JSON.stringify({ key: 'value' }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1000, + }; + + const result = getRequestPayloadXhrOrFetch(hint); + expect(result).toEqual(JSON.stringify({ key: 'value' })); + }); + test('should parse fetch payload', () => { + const hint: FetchHint = { + input: [ + 'http://example.com/test', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key: 'value' }), + }, + ], + response: new Response(JSON.stringify({ key: 'value' }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1000, + }; + + const result = getRequestPayloadXhrOrFetch(hint); + expect(result).toEqual(JSON.stringify({ key: 'value' })); + }); + test('should return undefined if no body is in the response', () => { + const hint: FetchHint = { + input: [ + 'http://example.com/test', + { + method: 'GET', + }, + ], + response: new Response(null, { + status: 200, + }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1000, + }; + + const result = getRequestPayloadXhrOrFetch(hint); + expect(result).toBeUndefined(); + }); + }); }); From 7fdec363adf2f437a942fd03503eb1fd7ce6582b Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 14:56:27 -0500 Subject: [PATCH 15/65] refactor(browser): Remove type assertions Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 39ed18830bc8..77395f515f9d 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -65,11 +65,13 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const payload = getRequestPayloadXhrOrFetch(hint); if (isTracedGraphqlEndpoint && payload) { - const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; - const operationInfo = _getGraphQLOperation(graphqlBody); + const graphqlBody = getGraphQLRequestPayload(payload); - span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('graphql.document', payload); + if (graphqlBody) { + const operationInfo = _getGraphQLOperation(graphqlBody); + span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + span.setAttribute('graphql.document', payload); + } } }); } @@ -93,8 +95,8 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const graphqlBody = getGraphQLRequestPayload(payload); if (!data.graphql && graphqlBody) { - const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data['graphql.document'] = (graphqlBody as GraphQLRequestPayload).query; + const operationInfo = _getGraphQLOperation(graphqlBody); + data['graphql.document'] = graphqlBody.query; data['graphql.operation'] = operationInfo; } } @@ -182,14 +184,13 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { * @param payload - A valid JSON string * @returns A POJO or undefined */ -export function getGraphQLRequestPayload(payload: string): unknown | undefined { +export function getGraphQLRequestPayload(payload: string): GraphQLRequestPayload | undefined { let graphqlBody = undefined; try { - const requestBody = JSON.parse(payload); + const requestBody = JSON.parse(payload) satisfies GraphQLRequestPayload; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const isGraphQLRequest = !!requestBody['query']; - if (isGraphQLRequest) { graphqlBody = requestBody; } From ae04bdac90c51cc6b7dcd1554f64eeec5164bc45 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 15:05:26 -0500 Subject: [PATCH 16/65] fix(browser): Remove unnecessary `FetchInput` type Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/core/src/types-hoist/instrument.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts index b35e6290652f..420482579dd9 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -6,8 +6,6 @@ import type { WebFetchHeaders } from './webfetchapi'; // Make sure to cast it where needed! type XHRSendInput = unknown; -type FetchInput = unknown; - export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' | 'assert' | 'trace'; export interface SentryWrappedXMLHttpRequest { @@ -42,7 +40,6 @@ export interface HandlerDataXhr { interface SentryFetchData { method: string; url: string; - body?: FetchInput; request_body_size?: number; response_body_size?: number; // span_id for the fetch request From 630d67884da1bba10ca7f4c11e24b6d87bb5cff5 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 22 Jan 2025 21:31:52 -0500 Subject: [PATCH 17/65] chore(browser): Remove deleted import Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/utils/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 9aa1740f28c6..245751b3e72c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -39,5 +39,3 @@ export * from './lru'; export * from './buildPolyfills'; export * from './propagationContext'; export * from './version'; -export * from './graphql'; - From 719d7279c630ab6a50965de1f97148ed8d0ab79e Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:37:55 -0500 Subject: [PATCH 18/65] fix(browser): Resolve rebase conflicts Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../integrations/graphqlClient/fetch/test.ts | 8 +- .../integrations/graphqlClient/xhr/test.ts | 8 +- packages/browser/src/index.ts | 2 - .../browser/src/integrations/breadcrumbs.ts | 7 +- .../browser/src/integrations/graphqlClient.ts | 4 +- packages/browser/src/tracing/request.ts | 3 + .../test/integrations/graphqlClient.test.ts | 2 +- packages/core/src/client.ts | 31 +- packages/core/src/fetch.ts | 3 + packages/core/src/utils-hoist/graphql.ts | 0 .../core/test/utils-hoist/graphql.test.ts | 59 --- packages/types/src/client.ts | 440 ------------------ packages/utils/src/index.ts | 41 -- packages/utils/test/graphql.test.ts | 0 packages/utils/test/instrument/fetch.test.ts | 0 15 files changed, 47 insertions(+), 561 deletions(-) delete mode 100644 packages/core/src/utils-hoist/graphql.ts delete mode 100644 packages/core/test/utils-hoist/graphql.test.ts delete mode 100644 packages/types/src/client.ts delete mode 100644 packages/utils/src/index.ts delete mode 100644 packages/utils/test/graphql.test.ts delete mode 100644 packages/utils/test/instrument/fetch.test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 9a5a953901aa..1d25a592f817 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -13,8 +13,8 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ @@ -57,8 +57,8 @@ sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTe }); }); -sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 00357c0acf43..6b3790c663b2 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -13,8 +13,8 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ @@ -57,8 +57,8 @@ sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTest }); }); -sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index b7b77d773bd7..1eecbcfd077a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -32,8 +32,6 @@ import { feedbackSyncIntegration } from './feedbackSync'; export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration }; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; -export * from './metrics'; - export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request'; export { browserTracingIntegration, diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 52eabb32a558..14a1c5dda1fd 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -35,7 +35,7 @@ import { parseUrl, safeJoin, severityLevelFromString, -} from '@sentry/utils'; +} from '@sentry/core'; import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { DEBUG_BUILD } from '../debug-build'; @@ -293,6 +293,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe }; if (handlerData.error) { + const data: FetchBreadcrumbData = handlerData.fetchData; const hint: FetchBreadcrumbHint = { data: handlerData.error, input: handlerData.args, @@ -312,6 +313,10 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe addBreadcrumb(breadcrumb, hint); } else { const response = handlerData.response as Response | undefined; + const data: FetchBreadcrumbData = { + ...handlerData.fetchData, + status_code: response && response.status, + }; breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 77395f515f9d..8c0867bd4e81 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -8,8 +8,8 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { Client, IntegrationFn } from '@sentry/types'; -import { isString, stringMatchesSomePattern } from '@sentry/utils'; +import type { Client, IntegrationFn } from '@sentry/core'; +import { isString, stringMatchesSomePattern } from '@sentry/core'; interface GraphQLClientOptions { endpoints: Array; diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 4bdcf4cd35cd..b8c32cdee279 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -3,6 +3,7 @@ import { addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; +import { XhrHint } from '@sentry-internal/replay'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -12,6 +13,7 @@ import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin, getActiveSpan, + getClient, getLocationHref, getTraceData, hasTracingEnabled, @@ -404,6 +406,7 @@ export function xhrCallback( ); } + const client = getClient(); if (client) { client.emit('beforeOutgoingRequestSpan', span, handlerData as XhrHint); } diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 5fb58a8c8150..e83d874beb06 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -79,7 +79,7 @@ describe('GraphqlClient', () => { queryThree, { operationName: 'OnTestItemAdded', operationType: 'subscription' }, ], - // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + // TODO: ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], ])('%s', (_, input, output) => { expect(parseGraphQLQuery(input)).toEqual(output); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 339ab3efcf4f..7a4d21e9659e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -33,6 +33,7 @@ import type { TransactionEvent, Transport, TransportMakeRequestResponse, + XhrBreadcrumbHint, } from './types-hoist'; import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; @@ -593,15 +594,25 @@ export abstract class Client { */ public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; - /** @inheritdoc */ + /** + * A hook for GraphQL client integration to enhance a span with request data. + * @returns A function that, when executed, removes the registered callback. + */ public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; - /** @inheritdoc */ + /** + * A hook for GraphQL client integration to enhance a breadcrumb with request data. + * @returns A function that, when executed, removes the registered callback. + */ public on( hook: 'beforeOutgoingRequestBreadcrumb', callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, ): () => void; + /** + * A hook that is called when the client is flushing + * @returns A function that, when executed, removes the registered callback. + */ public on(hook: 'flush', callback: () => void): () => void; /** @@ -728,13 +739,19 @@ export abstract class Client { */ public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; - /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; + /** + * Emit a hook event for GraphQL client integration to enhance a span with request data. + */ + emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; - /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; + /** + * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. + */ + emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; - /** @inheritdoc */ + /** + * Emit a hook event for client flush + */ public emit(hook: 'flush'): void; /** diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index eec92bf292c2..e08e5257dc42 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,3 +1,5 @@ +import { FetchHint } from './client'; +import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; @@ -96,6 +98,7 @@ export function instrumentFetchRequest( } } + const client = getClient(); if (client) { // There's no 'input' key in HandlerDataFetch const fetchHint = { diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/core/test/utils-hoist/graphql.test.ts b/packages/core/test/utils-hoist/graphql.test.ts deleted file mode 100644 index a325e9c94bcc..000000000000 --- a/packages/core/test/utils-hoist/graphql.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { getGraphQLRequestPayload, parseGraphQLQuery } from '../src'; - -describe('graphql', () => { - describe('parseGraphQLQuery', () => { - const queryOne = `query Test { - items { - id - } - }`; - - const queryTwo = `mutation AddTestItem($input: TestItem!) { - addItem(input: $input) { - name - } - }`; - - const queryThree = `subscription OnTestItemAdded($itemID: ID!) { - itemAdded(itemID: $itemID) { - id - } - }`; - - // TODO: support name-less queries - // const queryFour = ` query { - // items { - // id - // } - // }`; - - test.each([ - ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], - ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], - [ - 'should handle subscription type', - queryThree, - { operationName: 'OnTestItemAdded', operationType: 'subscription' }, - ], - // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], - ])('%s', (_, input, output) => { - expect(parseGraphQLQuery(input)).toEqual(output); - }); - }); - describe('getGraphQLRequestPayload', () => { - test('should return undefined for non-GraphQL request', () => { - const requestBody = { data: [1, 2, 3] }; - - expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); - }); - test('should return the payload object for GraphQL request', () => { - const requestBody = { - query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', - operationName: 'Test', - variables: {}, - }; - - expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); - }); - }); -}); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts deleted file mode 100644 index aede0ce5a5a3..000000000000 --- a/packages/types/src/client.ts +++ /dev/null @@ -1,440 +0,0 @@ -// if imported, circular dep -// import type { FetchHint, XhrHint } from '@sentry-internal/replay'; -import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './breadcrumb'; -import type { CheckIn, MonitorConfig } from './checkin'; -import type { EventDropReason } from './clientreport'; -import type { DataCategory } from './datacategory'; -import type { DsnComponents } from './dsn'; -import type { DynamicSamplingContext, Envelope } from './envelope'; -import type { Event, EventHint } from './event'; -import type { EventProcessor } from './eventprocessor'; -import type { FeedbackEvent } from './feedback'; -import type { HandlerDataFetch, SentryWrappedXMLHttpRequest } from './instrument'; -import type { Integration } from './integration'; -import type { ClientOptions } from './options'; -import type { ParameterizedString } from './parameterize'; -import type { Scope } from './scope'; -import type { SdkMetadata } from './sdkmetadata'; -import type { Session, SessionAggregates } from './session'; -import type { SeverityLevel } from './severity'; -import type { Span, SpanAttributes, SpanContextData } from './span'; -import type { StartSpanOptions } from './startSpanOptions'; -import type { Transport, TransportMakeRequestResponse } from './transport'; - -type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; - -export type XhrHint = XhrBreadcrumbHint & { - xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; - input?: RequestBody; -}; -export type FetchHint = FetchBreadcrumbHint & { - input: HandlerDataFetch['args']; - response: Response; -}; - -/** - * User-Facing Sentry SDK Client. - * - * This interface contains all methods to interface with the SDK once it has - * been installed. It allows to send events to Sentry, record breadcrumbs and - * set a context included in every event. Since the SDK mutates its environment, - * there will only be one instance during runtime. - * - */ -export interface Client { - /** - * Captures an exception event and sends it to Sentry. - * - * Unlike `captureException` exported from every SDK, this method requires that you pass it the current scope. - * - * @param exception An exception-like object. - * @param hint May contain additional information about the original exception. - * @param currentScope An optional scope containing event metadata. - * @returns The event id - */ - captureException(exception: any, hint?: EventHint, currentScope?: Scope): string; - - /** - * Captures a message event and sends it to Sentry. - * - * Unlike `captureMessage` exported from every SDK, this method requires that you pass it the current scope. - * - * @param message The message to send to Sentry. - * @param level Define the level of the message. - * @param hint May contain additional information about the original exception. - * @param currentScope An optional scope containing event metadata. - * @returns The event id - */ - captureMessage(message: string, level?: SeverityLevel, hint?: EventHint, currentScope?: Scope): string; - - /** - * Captures a manually created event and sends it to Sentry. - * - * Unlike `captureEvent` exported from every SDK, this method requires that you pass it the current scope. - * - * @param event The event to send to Sentry. - * @param hint May contain additional information about the original exception. - * @param currentScope An optional scope containing event metadata. - * @returns The event id - */ - captureEvent(event: Event, hint?: EventHint, currentScope?: Scope): string; - - /** - * Captures a session - * - * @param session Session to be delivered - */ - captureSession(session: Session): void; - - /** - * Create a cron monitor check in and send it to Sentry. This method is not available on all clients. - * - * @param checkIn An object that describes a check in. - * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want - * to create a monitor automatically when sending a check in. - * @param scope An optional scope containing event metadata. - * @returns A string representing the id of the check in. - */ - captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string; - - /** Returns the current Dsn. */ - getDsn(): DsnComponents | undefined; - - /** Returns the current options. */ - getOptions(): O; - - /** - * @inheritdoc - * - */ - getSdkMetadata(): SdkMetadata | undefined; - - /** - * Returns the transport that is used by the client. - * Please note that the transport gets lazy initialized so it will only be there once the first event has been sent. - * - * @returns The transport. - */ - getTransport(): Transport | undefined; - - /** - * Flush the event queue and set the client to `enabled = false`. See {@link Client.flush}. - * - * @param timeout Maximum time in ms the client should wait before shutting down. Omitting this parameter will cause - * the client to wait until all events are sent before disabling itself. - * @returns A promise which resolves to `true` if the flush completes successfully before the timeout, or `false` if - * it doesn't. - */ - close(timeout?: number): PromiseLike; - - /** - * Wait for all events to be sent or the timeout to expire, whichever comes first. - * - * @param timeout Maximum time in ms the client should wait for events to be flushed. Omitting this parameter will - * cause the client to wait until all events are sent before resolving the promise. - * @returns A promise that will resolve with `true` if all events are sent before the timeout, or `false` if there are - * still events in the queue when the timeout is reached. - */ - flush(timeout?: number): PromiseLike; - - /** - * Adds an event processor that applies to any event processed by this client. - */ - addEventProcessor(eventProcessor: EventProcessor): void; - - /** - * Get all added event processors for this client. - */ - getEventProcessors(): EventProcessor[]; - - /** Get the instance of the integration with the given name on the client, if it was added. */ - getIntegrationByName(name: string): T | undefined; - - /** - * Add an integration to the client. - * This can be used to e.g. lazy load integrations. - * In most cases, this should not be necessary, and you're better off just passing the integrations via `integrations: []` at initialization time. - * However, if you find the need to conditionally load & add an integration, you can use `addIntegration` to do so. - * - * */ - addIntegration(integration: Integration): void; - - /** - * Initialize this client. - * Call this after the client was set on a scope. - */ - init(): void; - - /** Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`. */ - eventFromException(exception: any, hint?: EventHint): PromiseLike; - - /** Creates an {@link Event} from primitive inputs to `captureMessage`. */ - eventFromMessage(message: ParameterizedString, level?: SeverityLevel, hint?: EventHint): PromiseLike; - - /** Submits the event to Sentry */ - sendEvent(event: Event, hint?: EventHint): void; - - /** Submits the session to Sentry */ - sendSession(session: Session | SessionAggregates): void; - - /** Sends an envelope to Sentry */ - sendEnvelope(envelope: Envelope): PromiseLike; - - /** - * Record on the client that an event got dropped (ie, an event that will not be sent to sentry). - * - * @param reason The reason why the event got dropped. - * @param category The data category of the dropped event. - * @param event The dropped event. - */ - recordDroppedEvent(reason: EventDropReason, dataCategory: DataCategory, event?: Event): void; - - // HOOKS - /* eslint-disable @typescript-eslint/unified-signatures */ - - /** - * Register a callback for whenever a span is started. - * Receives the span as argument. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'spanStart', callback: (span: Span) => void): () => void; - - /** - * Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision` - * property that can be used to make a sampling decision that will be enforced, before any span sampling runs. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'beforeSampling', - callback: ( - samplingData: { - spanAttributes: SpanAttributes; - spanName: string; - parentSampled?: boolean; - parentContext?: SpanContextData; - }, - samplingDecision: { decision: boolean }, - ) => void, - ): void; - - /** - * Register a callback for whenever a span is ended. - * Receives the span as argument. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'spanEnd', callback: (span: Span) => void): () => void; - - /** - * Register a callback for when an idle span is allowed to auto-finish. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): () => void; - - /** - * Register a callback for transaction start and finish. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): () => void; - - /** - * Register a callback that runs when stack frame metadata should be applied to an event. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'applyFrameMetadata', callback: (event: Event) => void): () => void; - - /** - * Register a callback for before sending an event. - * This is called right before an event is sent and should not be used to mutate the event. - * Receives an Event & EventHint as arguments. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; - - /** - * Register a callback for preprocessing an event, - * before it is passed to (global) event processors. - * Receives an Event & EventHint as arguments. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; - - /** - * Register a callback for when an event has been sent. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): () => void; - - /** - * Register a callback before a breadcrumb is added. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): () => void; - - /** - * Register a callback when a DSC (Dynamic Sampling Context) is created. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext, rootSpan?: Span) => void): () => void; - - /** - * Register a callback when a Feedback event has been prepared. - * This should be used to mutate the event. The options argument can hint - * about what kind of mutation it expects. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'beforeSendFeedback', - callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void, - ): () => void; - - /** - * A hook for the browser tracing integrations to trigger a span start for a page load. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'startPageLoadSpan', - callback: ( - options: StartSpanOptions, - traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, - ) => void, - ): () => void; - - /** - * A hook for browser tracing integrations to trigger a span for a navigation. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; - - /** - * A hook for GraphQL client integration to enhance a span with request data. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; - - /** - * A hook for GraphQL client integration to enhance a breadcrumb with request data. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, - ): () => void; - - /** - * A hook that is called when the client is flushing - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'flush', callback: () => void): () => void; - - /** - * A hook that is called when the client is closing - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'close', callback: () => void): () => void; - - /** Fire a hook whener a span starts. */ - emit(hook: 'spanStart', span: Span): void; - - /** A hook that is called every time before a span is sampled. */ - emit( - hook: 'beforeSampling', - samplingData: { - spanAttributes: SpanAttributes; - spanName: string; - parentSampled?: boolean; - parentContext?: SpanContextData; - }, - samplingDecision: { decision: boolean }, - ): void; - - /** Fire a hook whener a span ends. */ - emit(hook: 'spanEnd', span: Span): void; - - /** - * Fire a hook indicating that an idle span is allowed to auto finish. - */ - emit(hook: 'idleSpanEnableAutoFinish', span: Span): void; - - /* - * Fire a hook event for envelope creation and sending. Expects to be given an envelope as the - * second argument. - */ - emit(hook: 'beforeEnvelope', envelope: Envelope): void; - - /* - * Fire a hook indicating that stack frame metadata should be applied to the event passed to the hook. - */ - emit(hook: 'applyFrameMetadata', event: Event): void; - - /** - * Fire a hook event before sending an event. - * This is called right before an event is sent and should not be used to mutate the event. - * Expects to be given an Event & EventHint as the second/third argument. - */ - emit(hook: 'beforeSendEvent', event: Event, hint?: EventHint): void; - - /** - * Fire a hook event to process events before they are passed to (global) event processors. - * Expects to be given an Event & EventHint as the second/third argument. - */ - emit(hook: 'preprocessEvent', event: Event, hint?: EventHint): void; - - /* - * Fire a hook event after sending an event. Expects to be given an Event as the - * second argument. - */ - emit(hook: 'afterSendEvent', event: Event, sendResponse: TransportMakeRequestResponse): void; - - /** - * Fire a hook for when a breadcrumb is added. Expects the breadcrumb as second argument. - */ - emit(hook: 'beforeAddBreadcrumb', breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void; - - /** - * Fire a hook for when a DSC (Dynamic Sampling Context) is created. Expects the DSC as second argument. - */ - emit(hook: 'createDsc', dsc: DynamicSamplingContext, rootSpan?: Span): void; - - /** - * Fire a hook event for after preparing a feedback event. Events to be given - * a feedback event as the second argument, and an optional options object as - * third argument. - */ - emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void; - - /** - * Emit a hook event for browser tracing integrations to trigger a span start for a page load. - */ - emit( - hook: 'startPageLoadSpan', - options: StartSpanOptions, - traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, - ): void; - - /** - * Emit a hook event for browser tracing integrations to trigger a span for a navigation. - */ - emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; - - /** - * Emit a hook event for GraphQL client integration to enhance a span with request data. - */ - emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; - - /** - * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. - */ - emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; - - /** - * Emit a hook event for client flush - */ - emit(hook: 'flush'): void; - - /** - * Emit a hook event for client close - */ - emit(hook: 'close'): void; - - /* eslint-enable @typescript-eslint/unified-signatures */ -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts deleted file mode 100644 index 245751b3e72c..000000000000 --- a/packages/utils/src/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -export * from './aggregate-errors'; -export * from './array'; -export * from './breadcrumb-log-level'; -export * from './browser'; -export * from './dsn'; -export * from './error'; -export * from './worldwide'; -export * from './instrument'; -export * from './is'; -export * from './isBrowser'; -export * from './logger'; -export * from './memo'; -export * from './misc'; -export * from './node'; -export * from './normalize'; -export * from './object'; -export * from './path'; -export * from './promisebuffer'; -// TODO: Remove requestdata export once equivalent integration is used everywhere -export * from './requestdata'; -export * from './severity'; -export * from './stacktrace'; -export * from './node-stack-trace'; -export * from './string'; -export * from './supports'; -export * from './syncpromise'; -export * from './time'; -export * from './tracing'; -export * from './env'; -export * from './envelope'; -export * from './clientreport'; -export * from './ratelimit'; -export * from './baggage'; -export * from './url'; -export * from './cache'; -export * from './eventbuilder'; -export * from './anr'; -export * from './lru'; -export * from './buildPolyfills'; -export * from './propagationContext'; -export * from './version'; diff --git a/packages/utils/test/graphql.test.ts b/packages/utils/test/graphql.test.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/utils/test/instrument/fetch.test.ts b/packages/utils/test/instrument/fetch.test.ts deleted file mode 100644 index e69de29bb2d1..000000000000 From 4c20f14f0ebf74498cca5d0313003868516aee39 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:42:00 -0500 Subject: [PATCH 19/65] fix(core): Revert resolved rebase conflict Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../test/utils-hoist/instrument/fetch.test.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/core/test/utils-hoist/instrument/fetch.test.ts b/packages/core/test/utils-hoist/instrument/fetch.test.ts index f89e795dd0bd..fc6102d6b617 100644 --- a/packages/core/test/utils-hoist/instrument/fetch.test.ts +++ b/packages/core/test/utils-hoist/instrument/fetch.test.ts @@ -1,31 +1,25 @@ import { parseFetchArgs } from '../../../src/utils-hoist/instrument/fetch'; describe('instrument > parseFetchArgs', () => { - const data = { name: 'Test' }; - it.each([ - ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com', body: null }], - ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/', body: null }], - ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com', body: null }], + ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com' }], + ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/' }], + ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com' }], [ 'Request URL & method only', [{ url: 'http://example.com', method: 'post' }], - { method: 'POST', url: 'http://example.com', body: null }, - ], - [ - 'string URL & options', - ['http://example.com', { method: 'post', body: JSON.stringify(data) }], - { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, + { method: 'POST', url: 'http://example.com' }, ], + ['string URL & options', ['http://example.com', { method: 'post' }], { method: 'POST', url: 'http://example.com' }], [ 'URL object & options', - [new URL('http://example.com'), { method: 'post', body: JSON.stringify(data) }], - { method: 'POST', url: 'http://example.com/', body: '{"name":"Test"}' }, + [new URL('http://example.com'), { method: 'post' }], + { method: 'POST', url: 'http://example.com/' }, ], [ 'Request URL & options', - [{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify(data) }], - { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, + [{ url: 'http://example.com' }, { method: 'post' }], + { method: 'POST', url: 'http://example.com' }, ], ])('%s', (_name, args, expected) => { const actual = parseFetchArgs(args as unknown[]); From ff49b0350a76faf51c0837bf0f0320e8e2c4df3e Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 08:27:39 -0500 Subject: [PATCH 20/65] fix: Lint Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser/src/integrations/breadcrumbs.ts | 2 +- packages/browser/src/integrations/graphqlClient.ts | 2 +- packages/browser/src/tracing/request.ts | 2 +- packages/core/src/client.ts | 4 ++-- packages/core/src/fetch.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 14a1c5dda1fd..59a6a418c66c 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -315,7 +315,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const response = handlerData.response as Response | undefined; const data: FetchBreadcrumbData = { ...handlerData.fetchData, - status_code: response && response.status, + status_code: response?.status, }; breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 8c0867bd4e81..72321f9a86c3 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -85,7 +85,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const isHttpBreadcrumb = type === 'http'; if (isHttpBreadcrumb && (isFetch || isXhr)) { - const httpUrl = data && data.url; + const httpUrl = data?.url; const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index b8c32cdee279..e979d01f3795 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -3,7 +3,7 @@ import { addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; -import { XhrHint } from '@sentry-internal/replay'; +import type { XhrHint } from '@sentry-internal/replay'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7a4d21e9659e..df3a168fbeda 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -742,12 +742,12 @@ export abstract class Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; + public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; /** * Emit a hook event for client flush diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index e08e5257dc42..048b03a74e6d 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,4 +1,4 @@ -import { FetchHint } from './client'; +import type { FetchHint } from './client'; import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; From 8ea71ba05def0988c957cd9f402d491b2fe56322 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:25:05 -0500 Subject: [PATCH 21/65] refactor(browser-utils): Move `getBodyString` - Also moved `NetworkMetaWarning` type. Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/package.json | 6 +- packages/browser-utils/src/index.ts | 4 ++ packages/browser-utils/src/networkUtils.ts | 72 +++++++++++++++++++ packages/browser-utils/src/types.ts | 8 +++ .../browser-utils/test/networkUtils.test.ts | 41 +++++++++++ packages/browser-utils/tsconfig.test.json | 4 +- packages/browser-utils/vite.config.ts | 10 +++ .../browser/src/integrations/graphqlClient.ts | 3 +- packages/core/src/utils-hoist/index.ts | 2 + .../src/coreHandlers/util/fetchUtils.ts | 7 +- .../src/coreHandlers/util/networkUtils.ts | 32 +-------- .../src/coreHandlers/util/xhrUtils.ts | 14 ++-- packages/replay-internal/src/index.ts | 1 - packages/replay-internal/src/types/request.ts | 10 +-- .../coreHandlers/util/networkUtils.test.ts | 36 ---------- 15 files changed, 153 insertions(+), 97 deletions(-) create mode 100644 packages/browser-utils/src/networkUtils.ts create mode 100644 packages/browser-utils/test/networkUtils.test.ts create mode 100644 packages/browser-utils/vite.config.ts diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index b96ebb51962c..f60078893c91 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -56,9 +56,9 @@ "clean": "rimraf build coverage sentry-internal-browser-utils-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", - "test:unit": "jest", - "test": "jest", - "test:watch": "jest --watch", + "test:unit": "vitest run", + "test": "vitest run", + "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, "volta": { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index c71b2d70e31d..c31a3b78f9c8 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -24,3 +24,7 @@ export { addHistoryInstrumentationHandler } from './instrument/history'; export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } from './getNativeImplementation'; export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; + +export type { NetworkMetaWarning } from './types'; + +export { getBodyString } from './networkUtils'; diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts new file mode 100644 index 000000000000..4c80dbebde7f --- /dev/null +++ b/packages/browser-utils/src/networkUtils.ts @@ -0,0 +1,72 @@ +import type { ConsoleLevel, Logger } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import type { NetworkMetaWarning } from './types'; + +type ReplayConsoleLevels = Extract; +type LoggerMethod = (...args: unknown[]) => void; +type LoggerConsoleMethods = Record; + +interface LoggerConfig { + captureExceptions: boolean; + traceInternals: boolean; +} + +// Duplicate from replay-internal +interface ReplayLogger extends LoggerConsoleMethods { + /** + * Calls `logger.info` but saves breadcrumb in the next tick due to race + * conditions before replay is initialized. + */ + infoTick: LoggerMethod; + /** + * Captures exceptions (`Error`) if "capture internal exceptions" is enabled + */ + exception: LoggerMethod; + /** + * Configures the logger with additional debugging behavior + */ + setConfig(config: Partial): void; +} + +function _serializeFormData(formData: FormData): string { + // This is a bit simplified, but gives us a decent estimate + // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' + // @ts-expect-error passing FormData to URLSearchParams actually works + return new URLSearchParams(formData).toString(); +} + +/** Get the string representation of a body. */ +export function getBodyString( + body: unknown, + logger?: Logger | ReplayLogger, +): [string | undefined, NetworkMetaWarning?] { + try { + if (typeof body === 'string') { + return [body]; + } + + if (body instanceof URLSearchParams) { + return [body.toString()]; + } + + if (body instanceof FormData) { + return [_serializeFormData(body)]; + } + + if (!body) { + return [undefined]; + } + } catch (error) { + // RelayLogger + if (DEBUG_BUILD && logger && 'exception' in logger) { + logger.exception(error, 'Failed to serialize body', body); + } else if (DEBUG_BUILD && logger) { + logger.error(error, 'Failed to serialize body', body); + } + return [undefined, 'BODY_PARSE_ERROR']; + } + + DEBUG_BUILD && logger?.info('Skipping network body because of body type', body); + + return [undefined, 'UNPARSEABLE_BODY_TYPE']; +} diff --git a/packages/browser-utils/src/types.ts b/packages/browser-utils/src/types.ts index fd8f997907fc..19f40156bb9a 100644 --- a/packages/browser-utils/src/types.ts +++ b/packages/browser-utils/src/types.ts @@ -4,3 +4,11 @@ export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & // document is not available in all browser environments (webworkers). We make it optional so you have to explicitly check for it Omit & Partial>; + +export type NetworkMetaWarning = + | 'MAYBE_JSON_TRUNCATED' + | 'TEXT_TRUNCATED' + | 'URL_SKIPPED' + | 'BODY_PARSE_ERROR' + | 'BODY_PARSE_TIMEOUT' + | 'UNPARSEABLE_BODY_TYPE'; diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts new file mode 100644 index 000000000000..0db51a127cd8 --- /dev/null +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import { getBodyString } from '../src/networkUtils'; + +describe('getBodyString', () => { + it('works with a string', () => { + const actual = getBodyString('abc'); + expect(actual).toEqual(['abc']); + }); + + it('works with URLSearchParams', () => { + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); + const actual = getBodyString(body); + expect(actual).toEqual(['name=Anne&age=32']); + }); + + it('works with FormData', () => { + const body = new FormData(); + body.append('name', 'Anne'); + body.append('age', '32'); + const actual = getBodyString(body); + expect(actual).toEqual(['name=Anne&age=32']); + }); + + it('works with empty data', () => { + const body = undefined; + const actual = getBodyString(body); + expect(actual).toEqual([undefined]); + }); + + it('works with other type of data', () => { + const body = {}; + const actual = getBodyString(body); + expect(actual).toEqual([undefined, 'UNPARSEABLE_BODY_TYPE']); + }); +}); diff --git a/packages/browser-utils/tsconfig.test.json b/packages/browser-utils/tsconfig.test.json index 87f6afa06b86..5a75500b007f 100644 --- a/packages/browser-utils/tsconfig.test.json +++ b/packages/browser-utils/tsconfig.test.json @@ -1,11 +1,11 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*"], + "include": ["test/**/*", "vite.config.ts"], "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["node", "jest"] + "types": ["node", "jest", "vitest"] // other package-specific, test-specific options } diff --git a/packages/browser-utils/vite.config.ts b/packages/browser-utils/vite.config.ts new file mode 100644 index 000000000000..a5523c61f601 --- /dev/null +++ b/packages/browser-utils/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +import baseConfig from '../../vite/vite.config'; + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + }, +}); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 72321f9a86c3..c5bbd04d536f 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,4 @@ -import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; -import { getBodyString } from '@sentry-internal/replay'; +import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, diff --git a/packages/core/src/utils-hoist/index.ts b/packages/core/src/utils-hoist/index.ts index a593b72e73ad..4141b4583e35 100644 --- a/packages/core/src/utils-hoist/index.ts +++ b/packages/core/src/utils-hoist/index.ts @@ -38,6 +38,8 @@ export { } from './is'; export { isBrowser } from './isBrowser'; export { CONSOLE_LEVELS, consoleSandbox, logger, originalConsoleMethods } from './logger'; +export type { Logger } from './logger'; + export { addContextToFrame, addExceptionMechanism, diff --git a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts index fe7b5656baa9..d8f181558275 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -1,10 +1,10 @@ -import { setTimeout } from '@sentry-internal/browser-utils'; +import { getBodyString, setTimeout } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import type { Breadcrumb, FetchBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { FetchHint, - NetworkMetaWarning, ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, @@ -17,7 +17,6 @@ import { buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, - getBodyString, makeNetworkReplayBreadcrumb, mergeWarning, parseContentLengthHeader, @@ -118,7 +117,7 @@ function _getRequestInfo( // We only want to transmit string or string-like bodies const requestBody = _getFetchRequestArgBody(input); - const [bodyStr, warning] = getBodyString(requestBody); + const [bodyStr, warning] = getBodyString(requestBody, logger); const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); if (warning) { diff --git a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts index 3197b6839e74..3897936af70c 100644 --- a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts @@ -1,16 +1,14 @@ import { dropUndefinedKeys, stringMatchesSomePattern } from '@sentry/core'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import { NETWORK_BODY_MAX_SIZE, WINDOW } from '../../constants'; -import { DEBUG_BUILD } from '../../debug-build'; import type { NetworkBody, - NetworkMetaWarning, NetworkRequestData, ReplayNetworkRequestData, ReplayNetworkRequestOrResponse, ReplayPerformanceEntry, } from '../../types'; -import { logger } from '../../util/logger'; /** Get the size of a body. */ export function getBodySize(body: RequestInit['body']): number | undefined { @@ -60,34 +58,6 @@ export function parseContentLengthHeader(header: string | null | undefined): num return isNaN(size) ? undefined : size; } -/** Get the string representation of a body. */ -export function getBodyString(body: unknown): [string | undefined, NetworkMetaWarning?] { - try { - if (typeof body === 'string') { - return [body]; - } - - if (body instanceof URLSearchParams) { - return [body.toString()]; - } - - if (body instanceof FormData) { - return [_serializeFormData(body)]; - } - - if (!body) { - return [undefined]; - } - } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to serialize body', body); - return [undefined, 'BODY_PARSE_ERROR']; - } - - DEBUG_BUILD && logger.info('Skipping network body because of body type', body); - - return [undefined, 'UNPARSEABLE_BODY_TYPE']; -} - /** Merge a warning into an existing network request/response. */ export function mergeWarning( info: ReplayNetworkRequestOrResponse | undefined, diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index e05dda8e29eb..ed485f5c2d0c 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,14 +1,9 @@ -import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import type { - NetworkMetaWarning, - ReplayContainer, - ReplayNetworkOptions, - ReplayNetworkRequestData, - XhrHint, -} from '../../types'; +import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, XhrHint } from '../../types'; import { logger } from '../../util/logger'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { @@ -16,7 +11,6 @@ import { buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, - getBodyString, makeNetworkReplayBreadcrumb, mergeWarning, parseContentLengthHeader, @@ -111,7 +105,7 @@ function _prepareXhrData( : {}; const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); - const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input) : [undefined]; + const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, logger) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; const request = buildNetworkRequestOrResponse(networkRequestHeaders, requestBodySize, requestBody); diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index 723ef811862f..2919c44c08f2 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -18,4 +18,3 @@ export type { } from './types'; export { getReplay } from './util/getReplay'; -export { getBodyString } from './coreHandlers/util/networkUtils'; diff --git a/packages/replay-internal/src/types/request.ts b/packages/replay-internal/src/types/request.ts index c04b57409d0c..fd24c8bc16ba 100644 --- a/packages/replay-internal/src/types/request.ts +++ b/packages/replay-internal/src/types/request.ts @@ -1,16 +1,10 @@ +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; + type JsonObject = Record; type JsonArray = unknown[]; export type NetworkBody = JsonObject | JsonArray | string; -export type NetworkMetaWarning = - | 'MAYBE_JSON_TRUNCATED' - | 'TEXT_TRUNCATED' - | 'URL_SKIPPED' - | 'BODY_PARSE_ERROR' - | 'BODY_PARSE_TIMEOUT' - | 'UNPARSEABLE_BODY_TYPE'; - interface NetworkMeta { warnings?: NetworkMetaWarning[]; } diff --git a/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts b/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts index 00db91815a5f..f3ad45e10918 100644 --- a/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts @@ -8,7 +8,6 @@ import { NETWORK_BODY_MAX_SIZE } from '../../../../src/constants'; import { buildNetworkRequestOrResponse, getBodySize, - getBodyString, getFullUrl, parseContentLengthHeader, } from '../../../../src/coreHandlers/util/networkUtils'; @@ -252,39 +251,4 @@ describe('Unit | coreHandlers | util | networkUtils', () => { expect(actual).toBe(expected); }); }); - - describe('getBodyString', () => { - it('works with a string', () => { - const actual = getBodyString('abc'); - expect(actual).toEqual(['abc']); - }); - - it('works with URLSearchParams', () => { - const body = new URLSearchParams(); - body.append('name', 'Anne'); - body.append('age', '32'); - const actual = getBodyString(body); - expect(actual).toEqual(['name=Anne&age=32']); - }); - - it('works with FormData', () => { - const body = new FormData(); - body.append('name', 'Anne'); - body.append('age', '32'); - const actual = getBodyString(body); - expect(actual).toEqual(['name=Anne&age=32']); - }); - - it('works with empty data', () => { - const body = undefined; - const actual = getBodyString(body); - expect(actual).toEqual([undefined]); - }); - - it('works with other type of data', () => { - const body = {}; - const actual = getBodyString(body); - expect(actual).toEqual([undefined, 'UNPARSEABLE_BODY_TYPE']); - }); - }); }); From f778e136552b0f6fcd08b043b53382aafec13aa0 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:28:09 -0500 Subject: [PATCH 22/65] refactor(core): Move `hasProp` Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 6 +---- packages/core/src/index.ts | 1 + packages/core/src/utils/hasProp.ts | 6 +++++ packages/core/test/lib/utils/hasProp.test.ts | 27 +++++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/utils/hasProp.ts create mode 100644 packages/core/test/lib/utils/hasProp.test.ts diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index c5bbd04d536f..7bc71b1949f6 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_URL_FULL, defineIntegration, + hasProp, spanToJSON, } from '@sentry/core'; import type { Client, IntegrationFn } from '@sentry/core'; @@ -136,11 +137,6 @@ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | return body; } -// Duplicate from deprecated @sentry-utils/src/instrument/fetch.ts -function hasProp(obj: unknown, prop: T): obj is Record { - return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; -} - /** * Parses the fetch arguments to extract the request payload. * Exported for tests only. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2c89d0e8a60b..26b46a719216 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -96,6 +96,7 @@ export { extractQueryParamsFromUrl, headersToDict, } from './utils/request'; +export { hasProp } from './utils/hasProp'; export { DEFAULT_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; diff --git a/packages/core/src/utils/hasProp.ts b/packages/core/src/utils/hasProp.ts new file mode 100644 index 000000000000..542c5239b496 --- /dev/null +++ b/packages/core/src/utils/hasProp.ts @@ -0,0 +1,6 @@ +/** + * A more comprehensive key property check. + */ +export function hasProp(obj: unknown, prop: T): obj is Record { + return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; +} diff --git a/packages/core/test/lib/utils/hasProp.test.ts b/packages/core/test/lib/utils/hasProp.test.ts new file mode 100644 index 000000000000..256ed163b305 --- /dev/null +++ b/packages/core/test/lib/utils/hasProp.test.ts @@ -0,0 +1,27 @@ +import { hasProp } from '../../../src/utils/hasProp'; + +describe('hasProp', () => { + it('should return true if the object has the provided property', () => { + const obj = { a: 1 }; + const result = hasProp(obj, 'a'); + expect(result).toBe(true); + }); + + it('should return false if the object does not have the provided property', () => { + const obj = { a: 1 }; + const result = hasProp(obj, 'b'); + expect(result).toBe(false); + }); + + it('should return false if the object is null', () => { + const obj = null; + const result = hasProp(obj, 'a'); + expect(result).toBe(false); + }); + + it('should return false if the object is undefined', () => { + const obj = undefined; + const result = hasProp(obj, 'a'); + expect(result).toBe(false); + }); +}); From eccfa7951bdcaf3ea8a5491a25d3b24cc3544e35 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:36:46 -0500 Subject: [PATCH 23/65] chore: Merge import statements Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser/src/integrations/graphqlClient.ts | 3 ++- packages/core/src/client.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 7bc71b1949f6..7c839b044928 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -6,10 +6,11 @@ import { SEMANTIC_ATTRIBUTE_URL_FULL, defineIntegration, hasProp, + isString, spanToJSON, + stringMatchesSomePattern, } from '@sentry/core'; import type { Client, IntegrationFn } from '@sentry/core'; -import { isString, stringMatchesSomePattern } from '@sentry/core'; interface GraphQLClientOptions { endpoints: Array; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index df3a168fbeda..3657d56c19d9 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -611,7 +611,7 @@ export abstract class Client { /** * A hook that is called when the client is flushing - * @returns A function that, when executed, removes the registered callback. + * @returns {() => void} A function that, when executed, removes the registered callback. */ public on(hook: 'flush', callback: () => void): () => void; From e4581b7a60d0ff3db93f6f2a8c48fa9a8ed957d6 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:11:20 -0500 Subject: [PATCH 24/65] refactor(browser-utils): Move `FetchHint` and `XhrHint` Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/src/index.ts | 4 +-- packages/browser-utils/src/types.ts | 17 +++++++++++ .../browser/src/integrations/breadcrumbs.ts | 2 +- .../browser/src/integrations/graphqlClient.ts | 6 ++-- packages/browser/src/tracing/request.ts | 2 +- packages/core/src/client.ts | 28 ++++++++----------- packages/core/src/fetch.ts | 12 ++++---- .../coreHandlers/handleNetworkBreadcrumbs.ts | 3 +- .../src/coreHandlers/util/fetchUtils.ts | 3 +- .../src/coreHandlers/util/xhrUtils.ts | 4 +-- packages/replay-internal/src/index.ts | 2 -- packages/replay-internal/src/types/replay.ts | 23 +-------------- 12 files changed, 47 insertions(+), 59 deletions(-) diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index c31a3b78f9c8..4737a2b47342 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -25,6 +25,6 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export type { NetworkMetaWarning } from './types'; - export { getBodyString } from './networkUtils'; + +export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/types.ts b/packages/browser-utils/src/types.ts index 19f40156bb9a..f2d19dc2e561 100644 --- a/packages/browser-utils/src/types.ts +++ b/packages/browser-utils/src/types.ts @@ -1,3 +1,9 @@ +import type { + FetchBreadcrumbHint, + HandlerDataFetch, + SentryWrappedXMLHttpRequest, + XhrBreadcrumbHint, +} from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & @@ -12,3 +18,14 @@ export type NetworkMetaWarning = | 'BODY_PARSE_ERROR' | 'BODY_PARSE_TIMEOUT' | 'UNPARSEABLE_BODY_TYPE'; + +type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; + +export type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; +export type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 59a6a418c66c..bec6fbff019e 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -6,6 +6,7 @@ import { addHistoryInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; +import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import type { Breadcrumb, Client, @@ -37,7 +38,6 @@ import { severityLevelFromString, } from '@sentry/core'; -import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 7c839b044928..68519cb5ec9e 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,5 @@ import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; -import type { FetchHint, XhrHint } from '@sentry-internal/replay'; +import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -63,7 +63,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = getRequestPayloadXhrOrFetch(hint); + const payload = getRequestPayloadXhrOrFetch(hint as XhrHint | FetchHint); if (isTracedGraphqlEndpoint && payload) { const graphqlBody = getGraphQLRequestPayload(payload); @@ -90,7 +90,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = getRequestPayloadXhrOrFetch(handlerData); + const payload = getRequestPayloadXhrOrFetch(handlerData as XhrHint | FetchHint); if (isTracedGraphqlEndpoint && data && payload) { const graphqlBody = getGraphQLRequestPayload(payload); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index e979d01f3795..f249d04a53ec 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -3,7 +3,7 @@ import { addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; -import type { XhrHint } from '@sentry-internal/replay'; +import type { XhrHint } from '@sentry-internal/browser-utils'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 3657d56c19d9..5dfb28d76c01 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -15,13 +15,11 @@ import type { EventProcessor, FeedbackEvent, FetchBreadcrumbHint, - HandlerDataFetch, Integration, MonitorConfig, Outcome, ParameterizedString, SdkMetadata, - SentryWrappedXMLHttpRequest, Session, SessionAggregates, SeverityLevel, @@ -65,17 +63,6 @@ import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; -type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; - -export type XhrHint = XhrBreadcrumbHint & { - xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; - input?: RequestBody; -}; -export type FetchHint = FetchBreadcrumbHint & { - input: HandlerDataFetch['args']; - response: Response; -}; - /** * Base implementation for all JavaScript SDK clients. * @@ -598,7 +585,10 @@ export abstract class Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; + public on( + hook: 'beforeOutgoingRequestSpan', + callback: (span: Span, hint: XhrBreadcrumbHint | FetchBreadcrumbHint) => void, + ): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -606,7 +596,7 @@ export abstract class Client { */ public on( hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, + callback: (breadcrumb: Breadcrumb, hint: XhrBreadcrumbHint | FetchBreadcrumbHint) => void, ): () => void; /** @@ -742,12 +732,16 @@ export abstract class Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrBreadcrumbHint | FetchBreadcrumbHint): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; + public emit( + hook: 'beforeOutgoingRequestBreadcrumb', + breadcrumb: Breadcrumb, + hint: XhrBreadcrumbHint | FetchBreadcrumbHint, + ): void; /** * Emit a hook event for client flush diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 048b03a74e6d..bc6e54e3cc00 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,9 +1,8 @@ -import type { FetchHint } from './client'; import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; -import type { HandlerDataFetch, Span, SpanOrigin } from './types-hoist'; +import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanOrigin } from './types-hoist'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage'; import { isInstanceOf } from './utils-hoist/is'; import { parseUrl } from './utils-hoist/url'; @@ -99,15 +98,16 @@ export function instrumentFetchRequest( } const client = getClient(); + if (client) { - // There's no 'input' key in HandlerDataFetch const fetchHint = { input: handlerData.args, response: handlerData.response, startTimestamp: handlerData.startTimestamp, - endTimestamp: handlerData.endTimestamp, - }; - client.emit('beforeOutgoingRequestSpan', span, fetchHint as FetchHint); + endTimestamp: handlerData.endTimestamp ?? Date.now(), + } satisfies FetchBreadcrumbHint; + + client.emit('beforeOutgoingRequestSpan', span, fetchHint); } return span; diff --git a/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts index 3b3e52d985c1..e1f3a60bc254 100644 --- a/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -1,8 +1,9 @@ +import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { getClient } from '@sentry/core'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbData, XhrBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import type { FetchHint, ReplayContainer, ReplayNetworkOptions, XhrHint } from '../types'; +import type { ReplayContainer, ReplayNetworkOptions } from '../types'; import { logger } from '../util/logger'; import { captureFetchBreadcrumbToReplay, enrichFetchBreadcrumb } from './util/fetchUtils'; import { captureXhrBreadcrumbToReplay, enrichXhrBreadcrumb } from './util/xhrUtils'; diff --git a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts index d8f181558275..349119d76a58 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -1,10 +1,9 @@ import { getBodyString, setTimeout } from '@sentry-internal/browser-utils'; -import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; +import type { FetchHint, NetworkMetaWarning } from '@sentry-internal/browser-utils'; import type { Breadcrumb, FetchBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { - FetchHint, ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index ed485f5c2d0c..6028a09232ba 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,9 +1,9 @@ import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; -import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning, XhrHint } from '@sentry-internal/browser-utils'; import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, XhrHint } from '../../types'; +import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData } from '../../types'; import { logger } from '../../util/logger'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index 2919c44c08f2..c10beb30228c 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -13,8 +13,6 @@ export type { ReplaySpanFrameEvent, CanvasManagerInterface, CanvasManagerOptions, - FetchHint, - XhrHint, } from './types'; export { getReplay } from './util/getReplay'; diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 7cd4c78a21c5..a2f65421aaa1 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -1,14 +1,4 @@ -import type { - Breadcrumb, - ErrorEvent, - FetchBreadcrumbHint, - HandlerDataFetch, - ReplayRecordingData, - ReplayRecordingMode, - SentryWrappedXMLHttpRequest, - Span, - XhrBreadcrumbHint, -} from '@sentry/core'; +import type { Breadcrumb, ErrorEvent, ReplayRecordingData, ReplayRecordingMode, Span } from '@sentry/core'; import type { SKIPPED, THROTTLED } from '../util/throttle'; import type { AllPerformanceEntry, AllPerformanceEntryData, ReplayPerformanceEntry } from './performance'; @@ -501,17 +491,6 @@ export interface ReplayContainer { handleException(err: unknown): void; } -type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; - -export type XhrHint = XhrBreadcrumbHint & { - xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; - input?: RequestBody; -}; -export type FetchHint = FetchBreadcrumbHint & { - input: HandlerDataFetch['args']; - response: Response; -}; - export type ReplayNetworkRequestData = { startTimestamp: number; endTimestamp: number; From 521f61ff4948bafcb8041ba617679cf241c2047a Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:22:19 -0500 Subject: [PATCH 25/65] chore(e2e): Replace deprecated imports Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../suites/integrations/graphqlClient/fetch/test.ts | 2 +- .../suites/integrations/graphqlClient/xhr/test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 1d25a592f817..d923b1a41970 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { Event } from '@sentry/types'; +import type { Event } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 6b3790c663b2..1be028abea46 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { Event } from '@sentry/types'; +import type { Event } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; From 7a75d8ab6b9a78258516568b14a720d79c6eb99e Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 24 Sep 2024 22:33:49 -0400 Subject: [PATCH 26/65] feat(browser): Add `graphqlClientIntegration` Added support for graphql query with `xhr` with tests. Signed-off-by: Kaung Zin Hein --- .../suites/integrations/graphqlClient/init.js | 14 +++++ .../integrations/graphqlClient/xhr/subject.js | 16 ++++++ .../integrations/graphqlClient/xhr/test.ts | 51 +++++++++++++++++++ packages/browser/src/index.ts | 3 ++ .../browser/src/integrations/graphqlClient.ts | 48 +++++++++++++++++ packages/browser/src/tracing/request.ts | 3 ++ packages/core/src/utils-hoist/graphql.ts | 26 ++++++++++ .../core/test/utils-hoist/graphql.test.ts | 41 +++++++++++++++ packages/utils/src/index.ts | 43 ++++++++++++++++ 9 files changed, 245 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts create mode 100644 packages/browser/src/integrations/graphqlClient.ts create mode 100644 packages/core/src/utils-hoist/graphql.ts create mode 100644 packages/core/test/utils-hoist/graphql.test.ts create mode 100644 packages/utils/src/index.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js new file mode 100644 index 000000000000..7ca9df70b6c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js @@ -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, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js new file mode 100644 index 000000000000..d95cceeb8b7f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js @@ -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); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts new file mode 100644 index 000000000000..09dd02e3862b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -0,0 +1,51 @@ +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(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, + 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', + }, + }); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 42c388d73547..b7b77d773bd7 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -3,6 +3,7 @@ export * from './exports'; export { reportingObserverIntegration } from './integrations/reportingobserver'; export { httpClientIntegration } from './integrations/httpclient'; export { contextLinesIntegration } from './integrations/contextlines'; +export { graphqlClientIntegration } from './integrations/graphqlClient'; export { captureConsoleIntegration, @@ -31,6 +32,8 @@ import { feedbackSyncIntegration } from './feedbackSync'; export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration }; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; +export * from './metrics'; + export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request'; export { browserTracingIntegration, diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts new file mode 100644 index 000000000000..fbe7caabd6cb --- /dev/null +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -0,0 +1,48 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, defineIntegration, spanToJSON } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; +import { parseGraphQLQuery } from '@sentry/utils'; + +interface GraphQLClientOptions { + endpoints: Array; +} + +const INTEGRATION_NAME = 'GraphQLClient'; + +const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { + return { + name: INTEGRATION_NAME, + setup(client) { + client.on('spanStart', 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['http.url']; + + const { endpoints } = options; + + const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); + + if (isTracedGraphqlEndpoint) { + const httpMethod = spanAttributes['http.method']; + const graphqlQuery = spanAttributes['body']?.query as string; + + const { operationName, 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); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 92a8f2924084..26da03ae9e3c 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -370,6 +370,8 @@ export function xhrCallback( return undefined; } + const requestBody = JSON.parse(sentryXhrData.body as string); + const fullUrl = getFullURL(sentryXhrData.url); const host = fullUrl ? parseUrl(fullUrl).host : undefined; @@ -387,6 +389,7 @@ export function xhrCallback( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + body: requestBody, }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts new file mode 100644 index 000000000000..2062643c7d00 --- /dev/null +++ b/packages/core/src/utils-hoist/graphql.ts @@ -0,0 +1,26 @@ +interface GraphQLOperation { + operationType: string | undefined; + operationName: string | undefined; +} + +/** + * Extract the name and type of the operation from the GraphQL query. + * @param query + * @returns + */ +export function parseGraphQLQuery(query: string): GraphQLOperation { + const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[\{\(]/; + + const matched = query.match(queryRe); + + if (matched) { + return { + operationType: matched[1], + operationName: matched[2], + }; + } + return { + operationType: undefined, + operationName: undefined, + }; +} diff --git a/packages/core/test/utils-hoist/graphql.test.ts b/packages/core/test/utils-hoist/graphql.test.ts new file mode 100644 index 000000000000..59d5c0fadda8 --- /dev/null +++ b/packages/core/test/utils-hoist/graphql.test.ts @@ -0,0 +1,41 @@ +import { parseGraphQLQuery } from '../src'; + +describe('parseGraphQLQuery', () => { + const queryOne = `query Test { + items { + id + } + }`; + + const queryTwo = `mutation AddTestItem($input: TestItem!) { + addItem(input: $input) { + name + } + }`; + + const queryThree = `subscription OnTestItemAdded($itemID: ID!) { + itemAdded(itemID: $itemID) { + id + } + }`; + + // TODO: support name-less queries + // const queryFour = ` query { + // items { + // id + // } + // }`; + + test.each([ + ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], + ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], + [ + 'should handle subscription type', + queryThree, + { operationName: 'OnTestItemAdded', operationType: 'subscription' }, + ], + // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ])('%s', (_, input, output) => { + expect(parseGraphQLQuery(input)).toEqual(output); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000000..9aa1740f28c6 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,43 @@ +export * from './aggregate-errors'; +export * from './array'; +export * from './breadcrumb-log-level'; +export * from './browser'; +export * from './dsn'; +export * from './error'; +export * from './worldwide'; +export * from './instrument'; +export * from './is'; +export * from './isBrowser'; +export * from './logger'; +export * from './memo'; +export * from './misc'; +export * from './node'; +export * from './normalize'; +export * from './object'; +export * from './path'; +export * from './promisebuffer'; +// TODO: Remove requestdata export once equivalent integration is used everywhere +export * from './requestdata'; +export * from './severity'; +export * from './stacktrace'; +export * from './node-stack-trace'; +export * from './string'; +export * from './supports'; +export * from './syncpromise'; +export * from './time'; +export * from './tracing'; +export * from './env'; +export * from './envelope'; +export * from './clientreport'; +export * from './ratelimit'; +export * from './baggage'; +export * from './url'; +export * from './cache'; +export * from './eventbuilder'; +export * from './anr'; +export * from './lru'; +export * from './buildPolyfills'; +export * from './propagationContext'; +export * from './version'; +export * from './graphql'; + From b611943a274b1147e3783383cd699763a893e377 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Thu, 26 Sep 2024 10:44:44 -0400 Subject: [PATCH 27/65] feat(browser): Add support for fetch graphql request Added test for fetch graphql. Created new utility functions and added tests. Updated `instrumentFetch` to collect fetch request payload. Signed-off-by: Kaung Zin Hein --- .../graphqlClient/fetch/subject.js | 17 ++++ .../integrations/graphqlClient/fetch/test.ts | 55 ++++++++++++ .../integrations/graphqlClient/xhr/test.ts | 4 + .../browser/src/integrations/graphqlClient.ts | 24 ++++-- packages/browser/src/tracing/request.ts | 6 +- packages/core/src/fetch.ts | 3 + packages/core/src/types-hoist/instrument.ts | 3 + packages/core/src/utils-hoist/graphql.ts | 23 ++++- .../core/src/utils-hoist/instrument/fetch.ts | 9 +- .../core/test/utils-hoist/graphql.test.ts | 86 +++++++++++-------- .../test/utils-hoist/instrument/fetch.test.ts | 24 ++++-- packages/types/src/client.ts | 0 12 files changed, 198 insertions(+), 56 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts create mode 100644 packages/types/src/client.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js new file mode 100644 index 000000000000..6a9398578b8b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/subject.js @@ -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, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts new file mode 100644 index 000000000000..ce8cbce4f8ce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -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(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), + }, + }), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 09dd02e3862b..0e8323f5ae17 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -38,6 +38,7 @@ sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLoca start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: eventData.contexts?.trace?.trace_id, + status: 'ok', data: { type: 'xhr', 'http.method': 'POST', @@ -46,6 +47,9 @@ sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLoca 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', + body: { + query: expect.any(String), + }, }, }); }); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index fbe7caabd6cb..9f922854486d 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,4 +1,10 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, defineIntegration, spanToJSON } from '@sentry/core'; +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'; @@ -13,6 +19,10 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { 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 || {}; @@ -21,17 +31,21 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { const isHttpClientSpan = spanOp === 'http.client'; if (isHttpClientSpan) { - const httpUrl = spanAttributes['http.url']; + const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; const { endpoints } = options; const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); if (isTracedGraphqlEndpoint) { - const httpMethod = spanAttributes['http.method']; - const graphqlQuery = spanAttributes['body']?.query as string; + 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, operationType } = parseGraphQLQuery(graphqlQuery); + const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 26da03ae9e3c..270f7ae10229 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -370,13 +370,13 @@ export function xhrCallback( return undefined; } - const requestBody = JSON.parse(sentryXhrData.body as string); - const fullUrl = getFullURL(sentryXhrData.url); const host = fullUrl ? parseUrl(fullUrl).host : undefined; const hasParent = !!getActiveSpan(); + const graphqlRequest = getGraphQLRequestPayload(sentryXhrData.body as string); + const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -389,7 +389,7 @@ export function xhrCallback( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - body: requestBody, + body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 8998eb45fce0..dfa585a76ef9 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -57,6 +57,8 @@ export function instrumentFetchRequest( const hasParent = !!getActiveSpan(); + const graphqlRequest = getGraphQLRequestPayload(body as string); + const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -69,6 +71,7 @@ export function instrumentFetchRequest( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts index 420482579dd9..b35e6290652f 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -6,6 +6,8 @@ import type { WebFetchHeaders } from './webfetchapi'; // Make sure to cast it where needed! type XHRSendInput = unknown; +type FetchInput = unknown; + export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' | 'assert' | 'trace'; export interface SentryWrappedXMLHttpRequest { @@ -40,6 +42,7 @@ export interface HandlerDataXhr { interface SentryFetchData { method: string; url: string; + body?: FetchInput; request_body_size?: number; response_body_size?: number; // span_id for the fetch request diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 2062643c7d00..5ffc2640ffb1 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -6,10 +6,9 @@ interface GraphQLOperation { /** * Extract the name and type of the operation from the GraphQL query. * @param query - * @returns */ export function parseGraphQLQuery(query: string): GraphQLOperation { - const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[\{\(]/; + const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; const matched = query.match(queryRe); @@ -24,3 +23,23 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { operationName: undefined, }; } + +/** + * Extract the payload of a request ONLY if it's GraphQL. + * @param payload - A valid JSON string + */ +export function getGraphQLRequestPayload(payload: string): any | undefined { + let graphqlBody = undefined; + try { + const requestBody = JSON.parse(payload); + const isGraphQLRequest = !!requestBody['query']; + if (isGraphQLRequest) { + graphqlBody = requestBody; + } + } finally { + // Fallback to undefined if payload is an invalid JSON (SyntaxError) + + /* eslint-disable no-unsafe-finally */ + return graphqlBody; + } +} diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index f3eee711d26d..1eefb1a15082 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -63,6 +63,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat fetchData: { method, url, + body, }, startTimestamp: timestampInSeconds() * 1000, // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation @@ -211,12 +212,12 @@ function getUrlFromResource(resource: FetchResource): string { } /** - * Parses the fetch arguments to find the used Http method and the url of the request. + * Parses the fetch arguments to find the used Http method, the url, and the payload of the request. * Exported for tests only. */ -export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } { +export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string; body: string | null } { if (fetchArgs.length === 0) { - return { method: 'GET', url: '' }; + return { method: 'GET', url: '', body: null }; } if (fetchArgs.length === 2) { @@ -225,6 +226,7 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(url), method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET', + body: hasProp(options, 'body') ? String(options.body) : null, }; } @@ -232,5 +234,6 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(arg as FetchResource), method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', + body: hasProp(arg, 'body') ? String(arg.body) : null, }; } diff --git a/packages/core/test/utils-hoist/graphql.test.ts b/packages/core/test/utils-hoist/graphql.test.ts index 59d5c0fadda8..a325e9c94bcc 100644 --- a/packages/core/test/utils-hoist/graphql.test.ts +++ b/packages/core/test/utils-hoist/graphql.test.ts @@ -1,41 +1,59 @@ -import { parseGraphQLQuery } from '../src'; +import { getGraphQLRequestPayload, parseGraphQLQuery } from '../src'; -describe('parseGraphQLQuery', () => { - const queryOne = `query Test { - items { - id - } - }`; +describe('graphql', () => { + describe('parseGraphQLQuery', () => { + const queryOne = `query Test { + items { + id + } + }`; - const queryTwo = `mutation AddTestItem($input: TestItem!) { - addItem(input: $input) { - name - } - }`; + const queryTwo = `mutation AddTestItem($input: TestItem!) { + addItem(input: $input) { + name + } + }`; - const queryThree = `subscription OnTestItemAdded($itemID: ID!) { - itemAdded(itemID: $itemID) { - id - } - }`; + const queryThree = `subscription OnTestItemAdded($itemID: ID!) { + itemAdded(itemID: $itemID) { + id + } + }`; - // TODO: support name-less queries - // const queryFour = ` query { - // items { - // id - // } - // }`; + // TODO: support name-less queries + // const queryFour = ` query { + // items { + // id + // } + // }`; - test.each([ - ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], - ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], - [ - 'should handle subscription type', - queryThree, - { operationName: 'OnTestItemAdded', operationType: 'subscription' }, - ], - // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], - ])('%s', (_, input, output) => { - expect(parseGraphQLQuery(input)).toEqual(output); + test.each([ + ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], + ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], + [ + 'should handle subscription type', + queryThree, + { operationName: 'OnTestItemAdded', operationType: 'subscription' }, + ], + // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ])('%s', (_, input, output) => { + expect(parseGraphQLQuery(input)).toEqual(output); + }); + }); + describe('getGraphQLRequestPayload', () => { + test('should return undefined for non-GraphQL request', () => { + const requestBody = { data: [1, 2, 3] }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + test('should return the payload object for GraphQL request', () => { + const requestBody = { + query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', + operationName: 'Test', + variables: {}, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); + }); }); }); diff --git a/packages/core/test/utils-hoist/instrument/fetch.test.ts b/packages/core/test/utils-hoist/instrument/fetch.test.ts index fc6102d6b617..f89e795dd0bd 100644 --- a/packages/core/test/utils-hoist/instrument/fetch.test.ts +++ b/packages/core/test/utils-hoist/instrument/fetch.test.ts @@ -1,25 +1,31 @@ import { parseFetchArgs } from '../../../src/utils-hoist/instrument/fetch'; describe('instrument > parseFetchArgs', () => { + const data = { name: 'Test' }; + it.each([ - ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com' }], - ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/' }], - ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com' }], + ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com', body: null }], + ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/', body: null }], + ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com', body: null }], [ 'Request URL & method only', [{ url: 'http://example.com', method: 'post' }], - { method: 'POST', url: 'http://example.com' }, + { method: 'POST', url: 'http://example.com', body: null }, + ], + [ + 'string URL & options', + ['http://example.com', { method: 'post', body: JSON.stringify(data) }], + { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, ], - ['string URL & options', ['http://example.com', { method: 'post' }], { method: 'POST', url: 'http://example.com' }], [ 'URL object & options', - [new URL('http://example.com'), { method: 'post' }], - { method: 'POST', url: 'http://example.com/' }, + [new URL('http://example.com'), { method: 'post', body: JSON.stringify(data) }], + { method: 'POST', url: 'http://example.com/', body: '{"name":"Test"}' }, ], [ 'Request URL & options', - [{ url: 'http://example.com' }, { method: 'post' }], - { method: 'POST', url: 'http://example.com' }, + [{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify(data) }], + { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, ], ])('%s', (_name, args, expected) => { const actual = parseFetchArgs(args as unknown[]); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts new file mode 100644 index 000000000000..e69de29bb2d1 From 1be1082904e5e4bf9aabb0d9c8353c3742629a08 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Thu, 26 Sep 2024 11:24:38 -0400 Subject: [PATCH 28/65] test(browser): Remove skip test Signed-off-by: Kaung Zin Hein --- .../suites/integrations/graphqlClient/fetch/test.ts | 8 ++------ .../suites/integrations/graphqlClient/xhr/test.ts | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index ce8cbce4f8ce..dc7277989f82 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -2,13 +2,9 @@ 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(); - } +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 0e8323f5ae17..1efa45598a26 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -2,13 +2,9 @@ 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(); - } +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { From 29c1dc1af1d8fb25debd6f31741b4542c81da2fb Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Thu, 26 Sep 2024 21:57:27 -0400 Subject: [PATCH 29/65] fix(browser): Attach request payload to fetch instrumentation only for graphql requests Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/graphqlClient.ts | 4 +++- packages/core/src/utils-hoist/graphql.ts | 4 ++++ .../core/src/utils-hoist/instrument/fetch.ts | 24 ++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 9f922854486d..37bb159bea0a 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -34,7 +34,6 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); if (isTracedGraphqlEndpoint) { @@ -42,7 +41,10 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { const graphqlBody = spanAttributes['body']; // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const graphqlQuery = graphqlBody && (graphqlBody['query'] as string); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const graphqlOperationName = graphqlBody && (graphqlBody['operationName'] as string); const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 5ffc2640ffb1..8b4265f4307c 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -27,12 +27,16 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { /** * Extract the payload of a request ONLY if it's GraphQL. * @param payload - A valid JSON string + * @returns A POJO or undefined */ export function getGraphQLRequestPayload(payload: string): any | undefined { let graphqlBody = undefined; try { const requestBody = JSON.parse(payload); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const isGraphQLRequest = !!requestBody['query']; + if (isGraphQLRequest) { graphqlBody = requestBody; } diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index 1eefb1a15082..63832e9ee1fd 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { HandlerDataFetch } from '../../types-hoist'; +import { getGraphQLRequestPayload } from '../graphql'; import { isError } from '../is'; import { addNonEnumerableProperty, fill } from '../object'; import { supportsNativeFetch } from '../supports'; @@ -63,7 +64,6 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat fetchData: { method, url, - body, }, startTimestamp: timestampInSeconds() * 1000, // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation @@ -212,12 +212,12 @@ function getUrlFromResource(resource: FetchResource): string { } /** - * Parses the fetch arguments to find the used Http method, the url, and the payload of the request. + * Parses the fetch arguments to find the used Http method and the url of the request. * Exported for tests only. */ -export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string; body: string | null } { +export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } { if (fetchArgs.length === 0) { - return { method: 'GET', url: '', body: null }; + return { method: 'GET', url: '' }; } if (fetchArgs.length === 2) { @@ -226,7 +226,6 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(url), method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET', - body: hasProp(options, 'body') ? String(options.body) : null, }; } @@ -234,6 +233,19 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str return { url: getUrlFromResource(arg as FetchResource), method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', - body: hasProp(arg, 'body') ? String(arg.body) : null, }; } + +/** + * Parses the fetch arguments to extract the request payload. + * Exported for tests only. + */ +export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { + if (fetchArgs.length === 2) { + const options = fetchArgs[1]; + return hasProp(options, 'body') ? String(options.body) : undefined; + } + + const arg = fetchArgs[0]; + return hasProp(arg, 'body') ? String(arg.body) : undefined; +} From 0a5663c2c1b2adedc74cacd012e6d3d810df4260 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Fri, 27 Sep 2024 11:57:27 -0400 Subject: [PATCH 30/65] fix(browser): Emit the `outgoingRequestSpanStart` hook after the span has started Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 13 +- .../integrations/graphqlClient/xhr/subject.js | 9 +- .../integrations/graphqlClient/xhr/test.ts | 13 +- .../browser/src/integrations/graphqlClient.ts | 22 +- packages/browser/src/tracing/request.ts | 7 +- packages/core/src/client.ts | 14 +- packages/core/src/fetch.ts | 7 +- packages/types/src/client.ts | 412 ++++++++++++++++++ 8 files changed, 462 insertions(+), 35 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index dc7277989f82..17bdfa4b9215 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -4,6 +4,15 @@ import type { Event } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +// Duplicate from subject.js +const query = `query Test{ + people { + name + pet + } +}`; +const queryPayload = JSON.stringify({ query }); + sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); @@ -43,9 +52,7 @@ sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTe 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: { - query: expect.any(String), - }, + body: queryPayload, }), }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js index d95cceeb8b7f..85645f645635 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js @@ -5,11 +5,10 @@ xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json'); const query = `query Test{ - - people { - name - pet - } + people { + name + pet + } }`; const requestBody = JSON.stringify({ query }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 1efa45598a26..d1c78626d6c3 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -4,6 +4,15 @@ import type { Event } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; +// Duplicate from subject.js +const query = `query Test{ + people { + name + pet + } +}`; +const queryPayload = JSON.stringify({ query }); + sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); @@ -43,9 +52,7 @@ sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTest 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: { - query: expect.any(String), - }, + body: queryPayload, }, }); }); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 37bb159bea0a..ee6b4cd1f8b8 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -12,17 +12,19 @@ interface GraphQLClientOptions { endpoints: Array; } +interface GraphQLRequestPayload { + query: string; + operationName?: string; + variables?: Record; +} + 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 => { + client.on('outgoingRequestSpanStart', (span, { body }) => { const spanJSON = spanToJSON(span); const spanAttributes = spanJSON.data || {}; @@ -38,19 +40,17 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { if (isTracedGraphqlEndpoint) { const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const graphqlBody = spanAttributes['body']; + const graphqlBody = body as GraphQLRequestPayload; // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const graphqlQuery = graphqlBody && (graphqlBody['query'] as string); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const graphqlOperationName = graphqlBody && (graphqlBody['operationName'] as string); + const graphqlQuery = graphqlBody.query; + const graphqlOperationName = graphqlBody.operationName; const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`); + span.setAttribute('body', JSON.stringify(graphqlBody)); } } }); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 270f7ae10229..de9058fc1140 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -375,8 +375,6 @@ export function xhrCallback( const hasParent = !!getActiveSpan(); - const graphqlRequest = getGraphQLRequestPayload(sentryXhrData.body as string); - const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -389,7 +387,6 @@ export function xhrCallback( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); @@ -407,6 +404,10 @@ export function xhrCallback( ); } + if (client) { + client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(sentryXhrData.body as string) }); + } + return span; } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7334b2d294ed..c7eef9eb6e00 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -579,10 +579,9 @@ export abstract class Client { */ public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; - /** - * A hook that is called when the client is flushing - * @returns {() => void} A function that, when executed, removes the registered callback. - */ + /** @inheritdoc */ + public on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + public on(hook: 'flush', callback: () => void): () => void; /** @@ -709,9 +708,10 @@ export abstract class Client { */ public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; - /** - * Emit a hook event for client flush - */ + /** @inheritdoc */ + public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + + /** @inheritdoc */ public emit(hook: 'flush'): void; /** diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index dfa585a76ef9..d3c8dcf4a6d7 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -57,8 +57,6 @@ export function instrumentFetchRequest( const hasParent = !!getActiveSpan(); - const graphqlRequest = getGraphQLRequestPayload(body as string); - const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ @@ -71,7 +69,6 @@ export function instrumentFetchRequest( 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - body: graphqlRequest, }, }) : new SentryNonRecordingSpan(); @@ -99,6 +96,10 @@ export function instrumentFetchRequest( } } + if (client) { + client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(body as string) }); + } + return span; } diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index e69de29bb2d1..aa2fd7485ad0 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -0,0 +1,412 @@ +import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; +import type { CheckIn, MonitorConfig } from './checkin'; +import type { EventDropReason } from './clientreport'; +import type { DataCategory } from './datacategory'; +import type { DsnComponents } from './dsn'; +import type { DynamicSamplingContext, Envelope } from './envelope'; +import type { Event, EventHint } from './event'; +import type { EventProcessor } from './eventprocessor'; +import type { FeedbackEvent } from './feedback'; +import type { Integration } from './integration'; +import type { ClientOptions } from './options'; +import type { ParameterizedString } from './parameterize'; +import type { Scope } from './scope'; +import type { SdkMetadata } from './sdkmetadata'; +import type { Session, SessionAggregates } from './session'; +import type { SeverityLevel } from './severity'; +import type { Span, SpanAttributes, SpanContextData } from './span'; +import type { StartSpanOptions } from './startSpanOptions'; +import type { Transport, TransportMakeRequestResponse } from './transport'; + +/** + * User-Facing Sentry SDK Client. + * + * This interface contains all methods to interface with the SDK once it has + * been installed. It allows to send events to Sentry, record breadcrumbs and + * set a context included in every event. Since the SDK mutates its environment, + * there will only be one instance during runtime. + * + */ +export interface Client { + /** + * Captures an exception event and sends it to Sentry. + * + * Unlike `captureException` exported from every SDK, this method requires that you pass it the current scope. + * + * @param exception An exception-like object. + * @param hint May contain additional information about the original exception. + * @param currentScope An optional scope containing event metadata. + * @returns The event id + */ + captureException(exception: any, hint?: EventHint, currentScope?: Scope): string; + + /** + * Captures a message event and sends it to Sentry. + * + * Unlike `captureMessage` exported from every SDK, this method requires that you pass it the current scope. + * + * @param message The message to send to Sentry. + * @param level Define the level of the message. + * @param hint May contain additional information about the original exception. + * @param currentScope An optional scope containing event metadata. + * @returns The event id + */ + captureMessage(message: string, level?: SeverityLevel, hint?: EventHint, currentScope?: Scope): string; + + /** + * Captures a manually created event and sends it to Sentry. + * + * Unlike `captureEvent` exported from every SDK, this method requires that you pass it the current scope. + * + * @param event The event to send to Sentry. + * @param hint May contain additional information about the original exception. + * @param currentScope An optional scope containing event metadata. + * @returns The event id + */ + captureEvent(event: Event, hint?: EventHint, currentScope?: Scope): string; + + /** + * Captures a session + * + * @param session Session to be delivered + */ + captureSession(session: Session): void; + + /** + * Create a cron monitor check in and send it to Sentry. This method is not available on all clients. + * + * @param checkIn An object that describes a check in. + * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want + * to create a monitor automatically when sending a check in. + * @param scope An optional scope containing event metadata. + * @returns A string representing the id of the check in. + */ + captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string; + + /** Returns the current Dsn. */ + getDsn(): DsnComponents | undefined; + + /** Returns the current options. */ + getOptions(): O; + + /** + * @inheritdoc + * + */ + getSdkMetadata(): SdkMetadata | undefined; + + /** + * Returns the transport that is used by the client. + * Please note that the transport gets lazy initialized so it will only be there once the first event has been sent. + * + * @returns The transport. + */ + getTransport(): Transport | undefined; + + /** + * Flush the event queue and set the client to `enabled = false`. See {@link Client.flush}. + * + * @param timeout Maximum time in ms the client should wait before shutting down. Omitting this parameter will cause + * the client to wait until all events are sent before disabling itself. + * @returns A promise which resolves to `true` if the flush completes successfully before the timeout, or `false` if + * it doesn't. + */ + close(timeout?: number): PromiseLike; + + /** + * Wait for all events to be sent or the timeout to expire, whichever comes first. + * + * @param timeout Maximum time in ms the client should wait for events to be flushed. Omitting this parameter will + * cause the client to wait until all events are sent before resolving the promise. + * @returns A promise that will resolve with `true` if all events are sent before the timeout, or `false` if there are + * still events in the queue when the timeout is reached. + */ + flush(timeout?: number): PromiseLike; + + /** + * Adds an event processor that applies to any event processed by this client. + */ + addEventProcessor(eventProcessor: EventProcessor): void; + + /** + * Get all added event processors for this client. + */ + getEventProcessors(): EventProcessor[]; + + /** Get the instance of the integration with the given name on the client, if it was added. */ + getIntegrationByName(name: string): T | undefined; + + /** + * Add an integration to the client. + * This can be used to e.g. lazy load integrations. + * In most cases, this should not be necessary, and you're better off just passing the integrations via `integrations: []` at initialization time. + * However, if you find the need to conditionally load & add an integration, you can use `addIntegration` to do so. + * + * */ + addIntegration(integration: Integration): void; + + /** + * Initialize this client. + * Call this after the client was set on a scope. + */ + init(): void; + + /** Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`. */ + eventFromException(exception: any, hint?: EventHint): PromiseLike; + + /** Creates an {@link Event} from primitive inputs to `captureMessage`. */ + eventFromMessage(message: ParameterizedString, level?: SeverityLevel, hint?: EventHint): PromiseLike; + + /** Submits the event to Sentry */ + sendEvent(event: Event, hint?: EventHint): void; + + /** Submits the session to Sentry */ + sendSession(session: Session | SessionAggregates): void; + + /** Sends an envelope to Sentry */ + sendEnvelope(envelope: Envelope): PromiseLike; + + /** + * Record on the client that an event got dropped (ie, an event that will not be sent to sentry). + * + * @param reason The reason why the event got dropped. + * @param category The data category of the dropped event. + * @param event The dropped event. + */ + recordDroppedEvent(reason: EventDropReason, dataCategory: DataCategory, event?: Event): void; + + // HOOKS + /* eslint-disable @typescript-eslint/unified-signatures */ + + /** + * Register a callback for whenever a span is started. + * Receives the span as argument. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'spanStart', callback: (span: Span) => void): () => void; + + /** + * Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision` + * property that can be used to make a sampling decision that will be enforced, before any span sampling runs. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'beforeSampling', + callback: ( + samplingData: { + spanAttributes: SpanAttributes; + spanName: string; + parentSampled?: boolean; + parentContext?: SpanContextData; + }, + samplingDecision: { decision: boolean }, + ) => void, + ): void; + + /** + * Register a callback for whenever a span is ended. + * Receives the span as argument. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + + /** + * Register a callback for when an idle span is allowed to auto-finish. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): () => void; + + /** + * Register a callback for transaction start and finish. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): () => void; + + /** + * Register a callback that runs when stack frame metadata should be applied to an event. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'applyFrameMetadata', callback: (event: Event) => void): () => void; + + /** + * Register a callback for before sending an event. + * This is called right before an event is sent and should not be used to mutate the event. + * Receives an Event & EventHint as arguments. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; + + /** + * Register a callback for preprocessing an event, + * before it is passed to (global) event processors. + * Receives an Event & EventHint as arguments. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; + + /** + * Register a callback for when an event has been sent. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): () => void; + + /** + * Register a callback before a breadcrumb is added. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): () => void; + + /** + * Register a callback when a DSC (Dynamic Sampling Context) is created. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext, rootSpan?: Span) => void): () => void; + + /** + * Register a callback when a Feedback event has been prepared. + * This should be used to mutate the event. The options argument can hint + * about what kind of mutation it expects. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'beforeSendFeedback', + callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void, + ): () => void; + + /** + * A hook for the browser tracing integrations to trigger a span start for a page load. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'startPageLoadSpan', + callback: ( + options: StartSpanOptions, + traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, + ) => void, + ): () => void; + + /** + * A hook for browser tracing integrations to trigger a span for a navigation. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; + + /** + * A hook for GraphQL client integration to enhance a span and breadcrumbs with request data. + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + + /** + * A hook that is called when the client is flushing + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'flush', callback: () => void): () => void; + + /** + * A hook that is called when the client is closing + * @returns A function that, when executed, removes the registered callback. + */ + on(hook: 'close', callback: () => void): () => void; + + /** Fire a hook whener a span starts. */ + emit(hook: 'spanStart', span: Span): void; + + /** A hook that is called every time before a span is sampled. */ + emit( + hook: 'beforeSampling', + samplingData: { + spanAttributes: SpanAttributes; + spanName: string; + parentSampled?: boolean; + parentContext?: SpanContextData; + }, + samplingDecision: { decision: boolean }, + ): void; + + /** Fire a hook whener a span ends. */ + emit(hook: 'spanEnd', span: Span): void; + + /** + * Fire a hook indicating that an idle span is allowed to auto finish. + */ + emit(hook: 'idleSpanEnableAutoFinish', span: Span): void; + + /* + * Fire a hook event for envelope creation and sending. Expects to be given an envelope as the + * second argument. + */ + emit(hook: 'beforeEnvelope', envelope: Envelope): void; + + /* + * Fire a hook indicating that stack frame metadata should be applied to the event passed to the hook. + */ + emit(hook: 'applyFrameMetadata', event: Event): void; + + /** + * Fire a hook event before sending an event. + * This is called right before an event is sent and should not be used to mutate the event. + * Expects to be given an Event & EventHint as the second/third argument. + */ + emit(hook: 'beforeSendEvent', event: Event, hint?: EventHint): void; + + /** + * Fire a hook event to process events before they are passed to (global) event processors. + * Expects to be given an Event & EventHint as the second/third argument. + */ + emit(hook: 'preprocessEvent', event: Event, hint?: EventHint): void; + + /* + * Fire a hook event after sending an event. Expects to be given an Event as the + * second argument. + */ + emit(hook: 'afterSendEvent', event: Event, sendResponse: TransportMakeRequestResponse): void; + + /** + * Fire a hook for when a breadcrumb is added. Expects the breadcrumb as second argument. + */ + emit(hook: 'beforeAddBreadcrumb', breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void; + + /** + * Fire a hook for when a DSC (Dynamic Sampling Context) is created. Expects the DSC as second argument. + */ + emit(hook: 'createDsc', dsc: DynamicSamplingContext, rootSpan?: Span): void; + + /** + * Fire a hook event for after preparing a feedback event. Events to be given + * a feedback event as the second argument, and an optional options object as + * third argument. + */ + emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void; + + /** + * Emit a hook event for browser tracing integrations to trigger a span start for a page load. + */ + emit( + hook: 'startPageLoadSpan', + options: StartSpanOptions, + traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, + ): void; + + /** + * Emit a hook event for browser tracing integrations to trigger a span for a navigation. + */ + emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; + + /** + * Emit a hook event for GraphQL client integration to enhance a span and breadcrumbs with request data. + */ + emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + + /** + * Emit a hook event for client flush + */ + emit(hook: 'flush'): void; + + /** + * Emit a hook event for client close + */ + emit(hook: 'close'): void; + + /* eslint-enable @typescript-eslint/unified-signatures */ +} From f5320b2dcca435e6e538dbc8cc0a0efe695f92db Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Sun, 29 Sep 2024 11:25:08 -0400 Subject: [PATCH 31/65] feat(browser): Update breadcrumbs with graphql request data Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 41 +++++++- .../integrations/graphqlClient/xhr/test.ts | 40 +++++++- .../browser/src/integrations/breadcrumbs.ts | 66 +++++++------ .../browser/src/integrations/graphqlClient.ts | 93 ++++++++++++++----- packages/core/src/client.ts | 9 ++ packages/types/src/client.ts | 18 +++- 6 files changed, 208 insertions(+), 59 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 17bdfa4b9215..c6d12cb1f17f 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -13,7 +13,7 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { +sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { @@ -56,3 +56,42 @@ sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTe }), }); }); + +sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { + 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(page, url); + + expect(eventData?.breadcrumbs?.length).toBe(1); + + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://sentry-test.io/foo', + __span: expect.any(String), + graphql: { + query: query, + operationName: 'query Test', + }, + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index d1c78626d6c3..983c22905478 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -13,7 +13,7 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { +sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', route => { @@ -56,3 +56,41 @@ sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTest }, }); }); + +sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { + 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(page, url); + + expect(eventData?.breadcrumbs?.length).toBe(1); + + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://sentry-test.io/foo', + graphql: { + query: query, + operationName: 'query Test', + }, + }, + }); +}); diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index a45048ce2640..6d4f0be850d6 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -18,6 +18,7 @@ import type { HandlerDataHistory, HandlerDataXhr, IntegrationFn, + SeverityLevel, XhrBreadcrumbData, XhrBreadcrumbHint, } from '@sentry/core'; @@ -30,6 +31,7 @@ import { getClient, getComponentName, getEventDescription, + getGraphQLRequestPayload, htmlTreeAsString, logger, parseUrl, @@ -251,17 +253,16 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) endTimestamp, }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(status_code); + const breadcrumb = { + category: 'xhr', + data, + type: 'http', + level: getBreadcrumbLogLevelFromHttpStatusCode(status_code), + }; - addBreadcrumb( - { - category: 'xhr', - data, - type: 'http', - level, - }, - hint, - ); + client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { body: getGraphQLRequestPayload(body as string) }); + + addBreadcrumb(breadcrumb, hint); }; } @@ -299,15 +300,18 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe endTimestamp, }; - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); + const breadcrumb = { + category: 'fetch', + data, + level: 'error' as SeverityLevel, + type: 'http', + }; + + client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { + body: getGraphQLRequestPayload(handlerData.fetchData.body as string), + }); + + addBreadcrumb(breadcrumb, hint); } else { const response = handlerData.response as Response | undefined; @@ -321,17 +325,19 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe startTimestamp, endTimestamp, }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); + + const breadcrumb = { + category: 'fetch', + data, + type: 'http', + level: getBreadcrumbLogLevelFromHttpStatusCode(data.status_code), + }; + + client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { + body: getGraphQLRequestPayload(handlerData.fetchData.body as string), + }); + + addBreadcrumb(breadcrumb, hint); } }; } diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index ee6b4cd1f8b8..2e0843d91af4 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -5,7 +5,7 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { IntegrationFn } from '@sentry/types'; +import type { Client, IntegrationFn } from '@sentry/types'; import { parseGraphQLQuery } from '@sentry/utils'; interface GraphQLClientOptions { @@ -24,39 +24,82 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { return { name: INTEGRATION_NAME, setup(client) { - client.on('outgoingRequestSpanStart', (span, { body }) => { - const spanJSON = spanToJSON(span); + _updateSpanWithGraphQLData(client, options); + _updateBreadcrumbWithGraphQLData(client, options); + }, + }; +}) satisfies IntegrationFn; + +function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void { + client.on('outgoingRequestSpanStart', (span, { body }) => { + const spanJSON = spanToJSON(span); + + const spanAttributes = spanJSON.data || {}; + const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]; - const spanAttributes = spanJSON.data || {}; + const isHttpClientSpan = spanOp === 'http.client'; - const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]; - const isHttpClientSpan = spanOp === 'http.client'; + if (isHttpClientSpan) { + const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; - if (isHttpClientSpan) { - const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; + const { endpoints } = options; + const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); - const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); + if (isTracedGraphqlEndpoint) { + const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - if (isTracedGraphqlEndpoint) { - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const graphqlBody = body as GraphQLRequestPayload; + const operationInfo = _getGraphQLOperation(body); + span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + span.setAttribute('body', JSON.stringify(body)); + } + } + }); +} + +function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClientOptions): void { + client.on('outgoingRequestBreadcrumbStart', (breadcrumb, { body }) => { + const { category, type, data } = breadcrumb; + + const isFetch = category === 'fetch'; + const isXhr = category === 'xhr'; + const isHttpBreadcrumb = type === 'http'; - // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request - const graphqlQuery = graphqlBody.query; - const graphqlOperationName = graphqlBody.operationName; + if (isHttpBreadcrumb && (isFetch || isXhr)) { + const httpUrl = data && data.url; + const { endpoints } = options; - const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); - const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; + const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); - span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`); - span.setAttribute('body', JSON.stringify(graphqlBody)); - } + if (isTracedGraphqlEndpoint && data) { + if (!data.graphql) { + const operationInfo = _getGraphQLOperation(body); + + data.graphql = { + query: (body as GraphQLRequestPayload).query, + operationName: operationInfo, + }; } - }); - }, - }; -}) satisfies IntegrationFn; + + // The body prop attached to HandlerDataFetch for the span should be removed. + if (isFetch && data.body) { + delete data.body; + } + } + } + }); +} + +function _getGraphQLOperation(requestBody: unknown): string { + // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request + const graphqlBody = requestBody as GraphQLRequestPayload; + const graphqlQuery = graphqlBody.query; + const graphqlOperationName = graphqlBody.operationName; + + const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); + const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; + + return operationInfo; +} /** * GraphQL Client integration for the browser. diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c7eef9eb6e00..74e733c5b578 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -582,6 +582,12 @@ export abstract class Client { /** @inheritdoc */ public on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + /** @inheritdoc */ + public on( + hook: 'outgoingRequestBreadcrumbStart', + callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + ): () => void; + public on(hook: 'flush', callback: () => void): () => void; /** @@ -711,6 +717,9 @@ export abstract class Client { /** @inheritdoc */ public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + /** @inheritdoc */ + public emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index aa2fd7485ad0..b5b3c7dc0dff 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -292,11 +292,20 @@ export interface Client { on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** - * A hook for GraphQL client integration to enhance a span and breadcrumbs with request data. + * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + /** + * A hook for GraphQL client integration to enhance a breadcrumb with request data. + * @returns A function that, when executed, removes the registered callback. + */ + on( + hook: 'outgoingRequestBreadcrumbStart', + callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + ): () => void; + /** * A hook that is called when the client is flushing * @returns A function that, when executed, removes the registered callback. @@ -394,10 +403,15 @@ export interface Client { emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; /** - * Emit a hook event for GraphQL client integration to enhance a span and breadcrumbs with request data. + * Emit a hook event for GraphQL client integration to enhance a span with request data. */ emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + /** + * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. + */ + emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + /** * Emit a hook event for client flush */ From 26a787349d5dcf9737e82c4dd997dbd33a1a0efb Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 10:51:00 -0500 Subject: [PATCH 32/65] fix(browser): Change breadcrumb hook signature to not be graphql-specific - Updated `outgoingRequestBreadcrumbStart` hook name to `beforeOutgoingRequestBreadcrumb`. - Updated standard graphql request payload structure Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/breadcrumbs.ts | 10 ++--- .../browser/src/integrations/graphqlClient.ts | 40 +++++++++++++------ packages/core/src/client.ts | 8 ++-- packages/core/src/utils-hoist/graphql.ts | 4 +- packages/replay-internal/src/index.ts | 1 + packages/types/src/client.ts | 7 ++-- 6 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 6d4f0be850d6..e0802d3ea861 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -260,7 +260,7 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) level: getBreadcrumbLogLevelFromHttpStatusCode(status_code), }; - client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { body: getGraphQLRequestPayload(body as string) }); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); addBreadcrumb(breadcrumb, hint); }; @@ -307,9 +307,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe type: 'http', }; - client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { - body: getGraphQLRequestPayload(handlerData.fetchData.body as string), - }); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); addBreadcrumb(breadcrumb, hint); } else { @@ -333,9 +331,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe level: getBreadcrumbLogLevelFromHttpStatusCode(data.status_code), }; - client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { - body: getGraphQLRequestPayload(handlerData.fetchData.body as string), - }); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); addBreadcrumb(breadcrumb, hint); } diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 2e0843d91af4..e85572c29986 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,3 +1,5 @@ +import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { getBodyString } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -5,17 +7,19 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { Client, IntegrationFn } from '@sentry/types'; -import { parseGraphQLQuery } from '@sentry/utils'; +import type { Client, HandlerDataFetch, HandlerDataXhr, IntegrationFn } from '@sentry/types'; +import { getGraphQLRequestPayload, parseGraphQLQuery } from '@sentry/utils'; interface GraphQLClientOptions { endpoints: Array; } +// Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body interface GraphQLRequestPayload { query: string; operationName?: string; - variables?: Record; + variables?: Record; + extensions?: Record; } const INTEGRATION_NAME = 'GraphQLClient'; @@ -48,7 +52,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isTracedGraphqlEndpoint) { const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const operationInfo = _getGraphQLOperation(body); + const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(body as string) as GraphQLRequestPayload); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); span.setAttribute('body', JSON.stringify(body)); } @@ -57,7 +61,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption } function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClientOptions): void { - client.on('outgoingRequestBreadcrumbStart', (breadcrumb, { body }) => { + client.on('beforeOutgoingRequestBreadcrumb', (breadcrumb, handlerData) => { const { category, type, data } = breadcrumb; const isFetch = category === 'fetch'; @@ -71,11 +75,24 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); if (isTracedGraphqlEndpoint && data) { - if (!data.graphql) { - const operationInfo = _getGraphQLOperation(body); + + let body: string | undefined; + + if(isXhr){ + const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + body = getBodyString(sentryXhrData?.body)[0] + + } else if(isFetch){ + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData + body = getBodyString(sentryFetchData.body)[0] + } + + const graphqlBody = getGraphQLRequestPayload(body as string) + if (!data.graphql && graphqlBody) { + const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); data.graphql = { - query: (body as GraphQLRequestPayload).query, + query: (graphqlBody as GraphQLRequestPayload).query, operationName: operationInfo, }; } @@ -89,11 +106,8 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient }); } -function _getGraphQLOperation(requestBody: unknown): string { - // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request - const graphqlBody = requestBody as GraphQLRequestPayload; - const graphqlQuery = graphqlBody.query; - const graphqlOperationName = graphqlBody.operationName; +function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { + const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 74e733c5b578..5e2aac3560eb 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -14,6 +14,8 @@ import type { EventHint, EventProcessor, FeedbackEvent, + HandlerDataFetch, + HandlerDataXhr, Integration, MonitorConfig, Outcome, @@ -584,8 +586,8 @@ export abstract class Client { /** @inheritdoc */ public on( - hook: 'outgoingRequestBreadcrumbStart', - callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + hook: 'beforeOutgoingRequestBreadcrumb', + callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, ): () => void; public on(hook: 'flush', callback: () => void): () => void; @@ -718,7 +720,7 @@ export abstract class Client { public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; /** @inheritdoc */ - public emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 8b4265f4307c..0abc7796ca0a 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -25,11 +25,11 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { } /** - * Extract the payload of a request ONLY if it's GraphQL. + * Extract the payload of a request if it's GraphQL. * @param payload - A valid JSON string * @returns A POJO or undefined */ -export function getGraphQLRequestPayload(payload: string): any | undefined { +export function getGraphQLRequestPayload(payload: string): unknown | undefined { let graphqlBody = undefined; try { const requestBody = JSON.parse(payload); diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index c10beb30228c..c94bf837244a 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -16,3 +16,4 @@ export type { } from './types'; export { getReplay } from './util/getReplay'; +export { getBodyString } from './coreHandlers/util/networkUtils'; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index b5b3c7dc0dff..2976d164e1fa 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -7,6 +7,7 @@ import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; import type { FeedbackEvent } from './feedback'; +import type { HandlerDataFetch, HandlerDataXhr } from './instrument'; import type { Integration } from './integration'; import type { ClientOptions } from './options'; import type { ParameterizedString } from './parameterize'; @@ -302,8 +303,8 @@ export interface Client { * @returns A function that, when executed, removes the registered callback. */ on( - hook: 'outgoingRequestBreadcrumbStart', - callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void, + hook: 'beforeOutgoingRequestBreadcrumb', + callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, ): () => void; /** @@ -410,7 +411,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void; + emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** * Emit a hook event for client flush From f0c88e830b4f887405165c595acd8c50caf6fdc0 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 12:32:14 -0500 Subject: [PATCH 33/65] fix(browser): Change span hook signature to not be graphql-specific - Renamed `outgoingRequestSpanStart` hook to `beforeOutgoingRequestSpan`. - Followed Otel semantic for GraphQL Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 2 +- .../integrations/graphqlClient/xhr/test.ts | 2 +- .../browser/src/integrations/breadcrumbs.ts | 6 ++---- .../browser/src/integrations/graphqlClient.ts | 18 ++++++++++++++++-- packages/browser/src/tracing/request.ts | 2 +- packages/core/src/client.ts | 4 ++-- packages/core/src/fetch.ts | 2 +- packages/types/src/client.ts | 4 ++-- 8 files changed, 26 insertions(+), 14 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index c6d12cb1f17f..6de03670040d 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -52,7 +52,7 @@ sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTe 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: queryPayload, + 'graphql.document': queryPayload, }), }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 983c22905478..337f032f3746 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -52,7 +52,7 @@ sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTest 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - body: queryPayload, + 'graphql.document': queryPayload, }, }); }); diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index e0802d3ea861..6339769c0b91 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -18,7 +18,6 @@ import type { HandlerDataHistory, HandlerDataXhr, IntegrationFn, - SeverityLevel, XhrBreadcrumbData, XhrBreadcrumbHint, } from '@sentry/core'; @@ -31,7 +30,6 @@ import { getClient, getComponentName, getEventDescription, - getGraphQLRequestPayload, htmlTreeAsString, logger, parseUrl, @@ -303,9 +301,9 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const breadcrumb = { category: 'fetch', data, - level: 'error' as SeverityLevel, + level: 'error', type: 'http', - }; + } satisfies Breadcrumb; client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index e85572c29986..cae7426eb2ad 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -35,7 +35,7 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { }) satisfies IntegrationFn; function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void { - client.on('outgoingRequestSpanStart', (span, { body }) => { + client.on('beforeOutgoingRequestSpan', (span, handlerData) => { const spanJSON = spanToJSON(span); const spanAttributes = spanJSON.data || {}; @@ -51,10 +51,24 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isTracedGraphqlEndpoint) { const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; + + const isXhr = 'xhr' in handlerData; + const isFetch = 'fetchData' in handlerData; + + let body: string | undefined; + + if(isXhr){ + const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + body = getBodyString(sentryXhrData?.body)[0] + + } else if(isFetch){ + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData + body = getBodyString(sentryFetchData.body)[0] + } const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(body as string) as GraphQLRequestPayload); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('body', JSON.stringify(body)); + span.setAttribute('graphql.document', body); } } }); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index de9058fc1140..b6b3505f945e 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -405,7 +405,7 @@ export function xhrCallback( } if (client) { - client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(sentryXhrData.body as string) }); + client.emit('beforeOutgoingRequestSpan', span, handlerData); } return span; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5e2aac3560eb..fffd01794384 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -582,7 +582,7 @@ export abstract class Client { public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** @inheritdoc */ - public on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; /** @inheritdoc */ public on( @@ -717,7 +717,7 @@ export abstract class Client { public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; /** @inheritdoc */ - public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** @inheritdoc */ public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index d3c8dcf4a6d7..67a08e84f17a 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -97,7 +97,7 @@ export function instrumentFetchRequest( } if (client) { - client.emit('outgoingRequestSpanStart', span, { body: getGraphQLRequestPayload(body as string) }); + client.emit('beforeOutgoingRequestSpan', span, handlerData); } return span; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 2976d164e1fa..297457df33ef 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -296,7 +296,7 @@ export interface Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void; + on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -406,7 +406,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void; + emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. From 4e78e209dab3af689eaa9a9cd483064941aa093e Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 13:05:51 -0500 Subject: [PATCH 34/65] fix(browser): Add more requested fixes - Added guard for missing `httpMethod`. - Refactored getting body based on xhr or fetch logic into a function. - Added Otel semantic in breadcrumb data. Signed-off-by: Kaung Zin Hein --- .../integrations/graphqlClient/fetch/test.ts | 6 +- .../integrations/graphqlClient/xhr/test.ts | 6 +- .../browser/src/integrations/graphqlClient.ts | 76 ++++++++++--------- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 6de03670040d..9a5a953901aa 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -88,10 +88,8 @@ sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getL status_code: 200, url: 'http://sentry-test.io/foo', __span: expect.any(String), - graphql: { - query: query, - operationName: 'query Test', - }, + 'graphql.document': query, + 'graphql.operation': 'query Test', }, }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 337f032f3746..00357c0acf43 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -87,10 +87,8 @@ sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLoc method: 'POST', status_code: 200, url: 'http://sentry-test.io/foo', - graphql: { - query: query, - operationName: 'query Test', - }, + 'graphql.document': query, + 'graphql.operation': 'query Test', }, }); }); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index cae7426eb2ad..aa187d14670a 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -8,10 +8,10 @@ import { spanToJSON, } from '@sentry/core'; import type { Client, HandlerDataFetch, HandlerDataXhr, IntegrationFn } from '@sentry/types'; -import { getGraphQLRequestPayload, parseGraphQLQuery } from '@sentry/utils'; +import { getGraphQLRequestPayload, isString, parseGraphQLQuery, stringMatchesSomePattern } from '@sentry/utils'; interface GraphQLClientOptions { - endpoints: Array; + endpoints: Array; } // Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body @@ -45,30 +45,21 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isHttpClientSpan) { const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; + const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; + + if (!isString(httpUrl) || !isString(httpMethod)){ + return + } const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); - - if (isTracedGraphqlEndpoint) { - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - - const isXhr = 'xhr' in handlerData; - const isFetch = 'fetchData' in handlerData; + const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - let body: string | undefined; + if (isTracedGraphqlEndpoint) { + const payload = _getRequestPayloadXhrOrFetch(handlerData) + const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(payload as string) as GraphQLRequestPayload); - if(isXhr){ - const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; - body = getBodyString(sentryXhrData?.body)[0] - - } else if(isFetch){ - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData - body = getBodyString(sentryFetchData.body)[0] - } - - const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(body as string) as GraphQLRequestPayload); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('graphql.document', body); + span.setAttribute('graphql.document', payload); } } }); @@ -86,29 +77,18 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const httpUrl = data && data.url; const { endpoints } = options; - const isTracedGraphqlEndpoint = endpoints.includes(httpUrl); + const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); if (isTracedGraphqlEndpoint && data) { - let body: string | undefined; - - if(isXhr){ - const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; - body = getBodyString(sentryXhrData?.body)[0] + const payload = _getRequestPayloadXhrOrFetch(handlerData) + const graphqlBody = getGraphQLRequestPayload(payload as string) - } else if(isFetch){ - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData - body = getBodyString(sentryFetchData.body)[0] - } - - const graphqlBody = getGraphQLRequestPayload(body as string) if (!data.graphql && graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data.graphql = { - query: (graphqlBody as GraphQLRequestPayload).query, - operationName: operationInfo, - }; + data["graphql.document"] = (graphqlBody as GraphQLRequestPayload).query + data["graphql.operation"] = operationInfo; } // The body prop attached to HandlerDataFetch for the span should be removed. @@ -129,6 +109,28 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { return operationInfo; } +/** + * Get the request body/payload based on the shape of the HandlerData + * @param handlerData - Xhr or Fetch HandlerData + */ +function _getRequestPayloadXhrOrFetch(handlerData: HandlerDataXhr | HandlerDataFetch): string | undefined { + const isXhr = 'xhr' in handlerData; + const isFetch = 'fetchData' in handlerData; + + let body: string | undefined; + + if(isXhr){ + const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + body = getBodyString(sentryXhrData?.body)[0] + + } else if(isFetch){ + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData + body = getBodyString(sentryFetchData.body)[0] + } + + return body +} + /** * GraphQL Client integration for the browser. */ From 4cb51b4e0574c4b2cfe9bcd926e4de1a51adc5a3 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 13:28:41 -0500 Subject: [PATCH 35/65] fix(browser): Refactor to reduce type assertions Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/graphqlClient.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index aa187d14670a..d0fe5fcd73d7 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -53,10 +53,11 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); + const payload = _getRequestPayloadXhrOrFetch(handlerData) - if (isTracedGraphqlEndpoint) { - const payload = _getRequestPayloadXhrOrFetch(handlerData) - const operationInfo = _getGraphQLOperation(getGraphQLRequestPayload(payload as string) as GraphQLRequestPayload); + if (isTracedGraphqlEndpoint && payload) { + const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload + const operationInfo = _getGraphQLOperation(graphqlBody); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); span.setAttribute('graphql.document', payload); @@ -78,15 +79,14 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); + const payload = _getRequestPayloadXhrOrFetch(handlerData) - if (isTracedGraphqlEndpoint && data) { + if (isTracedGraphqlEndpoint && data && payload) { - const payload = _getRequestPayloadXhrOrFetch(handlerData) - const graphqlBody = getGraphQLRequestPayload(payload as string) + const graphqlBody = getGraphQLRequestPayload(payload) if (!data.graphql && graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data["graphql.document"] = (graphqlBody as GraphQLRequestPayload).query data["graphql.operation"] = operationInfo; } @@ -100,6 +100,10 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient }); } +/** + * @param requestBody - GraphQL request + * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' + */ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody From 95dcbe8b2d4700c0b58db7feffc4ac38e8d5f80a Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein Date: Tue, 7 Jan 2025 13:40:55 -0500 Subject: [PATCH 36/65] chore(browser): Fix lint errors Signed-off-by: Kaung Zin Hein --- .../browser/src/integrations/graphqlClient.ts | 38 +++++++++---------- packages/core/src/client.ts | 11 +++++- packages/types/src/client.ts | 11 +++++- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index d0fe5fcd73d7..075a4ab60f72 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -46,17 +46,17 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (isHttpClientSpan) { const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - - if (!isString(httpUrl) || !isString(httpMethod)){ - return + + if (!isString(httpUrl) || !isString(httpMethod)) { + return; } const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData) + const payload = _getRequestPayloadXhrOrFetch(handlerData); - if (isTracedGraphqlEndpoint && payload) { - const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload + if (isTracedGraphqlEndpoint && payload) { + const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; const operationInfo = _getGraphQLOperation(graphqlBody); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); @@ -79,16 +79,15 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData) + const payload = _getRequestPayloadXhrOrFetch(handlerData); if (isTracedGraphqlEndpoint && data && payload) { - - const graphqlBody = getGraphQLRequestPayload(payload) + const graphqlBody = getGraphQLRequestPayload(payload); if (!data.graphql && graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data["graphql.document"] = (graphqlBody as GraphQLRequestPayload).query - data["graphql.operation"] = operationInfo; + data['graphql.document'] = (graphqlBody as GraphQLRequestPayload).query; + data['graphql.operation'] = operationInfo; } // The body prop attached to HandlerDataFetch for the span should be removed. @@ -101,11 +100,11 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient } /** - * @param requestBody - GraphQL request + * @param requestBody - GraphQL request * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' */ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { - const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody + const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; @@ -123,16 +122,15 @@ function _getRequestPayloadXhrOrFetch(handlerData: HandlerDataXhr | HandlerDataF let body: string | undefined; - if(isXhr){ + if (isXhr) { const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; - body = getBodyString(sentryXhrData?.body)[0] - - } else if(isFetch){ - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData - body = getBodyString(sentryFetchData.body)[0] + body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; + } else if (isFetch) { + const sentryFetchData = (handlerData as HandlerDataFetch).fetchData; + body = getBodyString(sentryFetchData.body)[0]; } - return body + return body; } /** diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index fffd01794384..e3c433fb615d 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -582,7 +582,10 @@ export abstract class Client { public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** @inheritdoc */ - public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; + public on( + hook: 'beforeOutgoingRequestSpan', + callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + ): () => void; /** @inheritdoc */ public on( @@ -720,7 +723,11 @@ export abstract class Client { public emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; + public emit( + hook: 'beforeOutgoingRequestBreadcrumb', + breadcrumb: Breadcrumb, + handlerData: HandlerDataXhr | HandlerDataFetch, + ): void; /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 297457df33ef..2a829725364b 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -296,7 +296,10 @@ export interface Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void): () => void; + on( + hook: 'beforeOutgoingRequestSpan', + callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + ): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -411,7 +414,11 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch): void; + emit( + hook: 'beforeOutgoingRequestBreadcrumb', + breadcrumb: Breadcrumb, + handlerData: HandlerDataXhr | HandlerDataFetch, + ): void; /** * Emit a hook event for client flush From efbfe1f5a81b6861f4fa756043bc5a67e41d4d6d Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 13:19:12 -0500 Subject: [PATCH 37/65] fix(browser): Refactor span handler to use hint - Moved fetch-related operations into the grahqlClient integration. - Used Hint types for `beforeOutgoingRequestSpan` hooks. Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 126 ++++++++++++++---- packages/browser/src/tracing/request.ts | 3 +- .../test/integrations/graphqlClient.test.ts | 94 +++++++++++++ packages/core/src/client.ts | 6 +- packages/core/src/fetch.ts | 3 +- packages/core/src/utils-hoist/graphql.ts | 49 ------- .../core/src/utils-hoist/instrument/fetch.ts | 14 -- packages/replay-internal/src/index.ts | 2 + packages/types/src/client.ts | 21 ++- packages/utils/test/graphql.test.ts | 0 packages/utils/test/instrument/fetch.test.ts | 0 11 files changed, 220 insertions(+), 98 deletions(-) create mode 100644 packages/browser/test/integrations/graphqlClient.test.ts create mode 100644 packages/utils/test/graphql.test.ts create mode 100644 packages/utils/test/instrument/fetch.test.ts diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 075a4ab60f72..2b3755d6d66e 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,5 @@ import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; -import { getBodyString } from '@sentry-internal/replay'; +import { FetchHint, getBodyString, XhrHint } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -7,8 +7,8 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { Client, HandlerDataFetch, HandlerDataXhr, IntegrationFn } from '@sentry/types'; -import { getGraphQLRequestPayload, isString, parseGraphQLQuery, stringMatchesSomePattern } from '@sentry/utils'; +import type { Client, IntegrationFn } from '@sentry/types'; +import { isString, stringMatchesSomePattern } from '@sentry/utils'; interface GraphQLClientOptions { endpoints: Array; @@ -35,7 +35,7 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { }) satisfies IntegrationFn; function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void { - client.on('beforeOutgoingRequestSpan', (span, handlerData) => { + client.on('beforeOutgoingRequestSpan', (span, hint) => { const spanJSON = spanToJSON(span); const spanAttributes = spanJSON.data || {}; @@ -43,26 +43,29 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const isHttpClientSpan = spanOp === 'http.client'; - if (isHttpClientSpan) { - const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; + if (!isHttpClientSpan) { + return; + } - if (!isString(httpUrl) || !isString(httpMethod)) { - return; - } + const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; + const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; - const { endpoints } = options; - const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData); + if (!isString(httpUrl) || !isString(httpMethod)) { + return; + } - if (isTracedGraphqlEndpoint && payload) { - const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; - const operationInfo = _getGraphQLOperation(graphqlBody); + const { endpoints } = options; + const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); + const payload = _getRequestPayloadXhrOrFetch(hint); - span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('graphql.document', payload); - } + if (isTracedGraphqlEndpoint && payload) { + const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; + const operationInfo = _getGraphQLOperation(graphqlBody); + + span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + span.setAttribute('graphql.document', payload); } + }); } @@ -113,26 +116,95 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { } /** - * Get the request body/payload based on the shape of the HandlerData - * @param handlerData - Xhr or Fetch HandlerData + * Get the request body/payload based on the shape of the hint + * TODO: export for test? */ -function _getRequestPayloadXhrOrFetch(handlerData: HandlerDataXhr | HandlerDataFetch): string | undefined { - const isXhr = 'xhr' in handlerData; - const isFetch = 'fetchData' in handlerData; +function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { + const isXhr = 'xhr' in hint; + const isFetch = !isXhr let body: string | undefined; if (isXhr) { - const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY]; + const sentryXhrData = hint.xhr[SENTRY_XHR_DATA_KEY]; body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; } else if (isFetch) { - const sentryFetchData = (handlerData as HandlerDataFetch).fetchData; - body = getBodyString(sentryFetchData.body)[0]; + const sentryFetchData = parseFetchPayload(hint.input); + body = getBodyString(sentryFetchData)[0]; } return body; } +function hasProp(obj: unknown, prop: T): obj is Record { + return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; +} + +/** + * Parses the fetch arguments to extract the request payload. + * Exported for tests only. + */ +export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { + if (fetchArgs.length === 2) { + const options = fetchArgs[1]; + return hasProp(options, 'body') ? String(options.body) : undefined; + } + + const arg = fetchArgs[0]; + return hasProp(arg, 'body') ? String(arg.body) : undefined; +} + +interface GraphQLOperation { + operationType: string | undefined; + operationName: string | undefined; +} + +/** + * Extract the name and type of the operation from the GraphQL query. + * @param query + */ +export function parseGraphQLQuery(query: string): GraphQLOperation { + const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; + + const matched = query.match(queryRe); + + if (matched) { + return { + operationType: matched[1], + operationName: matched[2], + }; + } + return { + operationType: undefined, + operationName: undefined, + }; +} + +/** + * 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): unknown | undefined { + let graphqlBody = undefined; + try { + const requestBody = JSON.parse(payload); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const isGraphQLRequest = !!requestBody['query']; + + if (isGraphQLRequest) { + graphqlBody = requestBody; + } + } finally { + // Fallback to undefined if payload is an invalid JSON (SyntaxError) + + /* eslint-disable no-unsafe-finally */ + return graphqlBody; + } +} + /** * GraphQL Client integration for the browser. */ diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index b6b3505f945e..3d0f5b13c848 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -23,6 +23,7 @@ import { stringMatchesSomePattern, } from '@sentry/core'; import { WINDOW } from '../helpers'; +import { XhrHint } from '@sentry-internal/replay'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { @@ -405,7 +406,7 @@ export function xhrCallback( } if (client) { - client.emit('beforeOutgoingRequestSpan', span, handlerData); + client.emit('beforeOutgoingRequestSpan', span, handlerData as XhrHint); } return span; diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts new file mode 100644 index 000000000000..db42ed3f888c --- /dev/null +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -0,0 +1,94 @@ +import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from "../../src/integrations/graphqlClient"; + +describe('GraphqlClient', () => { + describe('parseFetchPayload', () => { + + const data = [1, 2, 3]; + const jsonData = '{"data":[1,2,3]}'; + + it.each([ + ['string URL only', ['http://example.com'], undefined], + ['URL object only', [new URL('http://example.com')], undefined], + ['Request URL only', [{ url: 'http://example.com' }], undefined], + [ + 'Request URL & method only', + [{ url: 'http://example.com', method: 'post', body: JSON.stringify({ data }) }], + jsonData, + ], + ['string URL & options', ['http://example.com', { method: 'post', body: JSON.stringify({ data }) }], jsonData], + [ + 'URL object & options', + [new URL('http://example.com'), { method: 'post', body: JSON.stringify({ data }) }], + jsonData, + ], + [ + 'Request URL & options', + [{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify({ data }) }], + jsonData, + ], + ])('%s', (_name, args, expected) => { + const actual = parseFetchPayload(args as unknown[]); + + expect(actual).toEqual(expected); + }); + }); + + describe('parseGraphQLQuery', () => { + const queryOne = `query Test { + items { + id + } + }`; + + const queryTwo = `mutation AddTestItem($input: TestItem!) { + addItem(input: $input) { + name + } + }`; + + const queryThree = `subscription OnTestItemAdded($itemID: ID!) { + itemAdded(itemID: $itemID) { + id + } + }`; + + // TODO: support name-less queries + // const queryFour = ` query { + // items { + // id + // } + // }`; + + test.each([ + ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], + ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], + [ + 'should handle subscription type', + queryThree, + { operationName: 'OnTestItemAdded', operationType: 'subscription' }, + ], + // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ])('%s', (_, input, output) => { + expect(parseGraphQLQuery(input)).toEqual(output); + }); + }); + + describe('getGraphQLRequestPayload', () => { + test('should return undefined for non-GraphQL request', () => { + const requestBody = { data: [1, 2, 3] }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + test('should return the payload object for GraphQL request', () => { + const requestBody = { + query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', + operationName: 'Test', + variables: {}, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); + }); + }); +}); + + diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index e3c433fb615d..bb6cb083ab7c 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -14,6 +14,7 @@ import type { EventHint, EventProcessor, FeedbackEvent, + FetchBreadcrumbHint, HandlerDataFetch, HandlerDataXhr, Integration, @@ -21,6 +22,7 @@ import type { Outcome, ParameterizedString, SdkMetadata, + SentryWrappedXMLHttpRequest, Session, SessionAggregates, SeverityLevel, @@ -584,7 +586,7 @@ export abstract class Client { /** @inheritdoc */ public on( hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (span: Span, hint: XhrHint | FetchHint ) => void, ): () => void; /** @inheritdoc */ @@ -720,7 +722,7 @@ export abstract class Client { public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** @inheritdoc */ public emit( diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 67a08e84f17a..7b4acc18eb3f 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -97,7 +97,8 @@ export function instrumentFetchRequest( } if (client) { - client.emit('beforeOutgoingRequestSpan', span, handlerData); + const fetchHint = { input: handlerData.args , response: handlerData.response, startTimestamp: handlerData.startTimestamp, endTimestamp: handlerData.endTimestamp } satisfies FetchHint + client.emit('beforeOutgoingRequestSpan', span, fetchHint); } return span; diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts index 0abc7796ca0a..e69de29bb2d1 100644 --- a/packages/core/src/utils-hoist/graphql.ts +++ b/packages/core/src/utils-hoist/graphql.ts @@ -1,49 +0,0 @@ -interface GraphQLOperation { - operationType: string | undefined; - operationName: string | undefined; -} - -/** - * Extract the name and type of the operation from the GraphQL query. - * @param query - */ -export function parseGraphQLQuery(query: string): GraphQLOperation { - const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; - - const matched = query.match(queryRe); - - if (matched) { - return { - operationType: matched[1], - operationName: matched[2], - }; - } - return { - operationType: undefined, - operationName: undefined, - }; -} - -/** - * Extract the payload of a request if it's GraphQL. - * @param payload - A valid JSON string - * @returns A POJO or undefined - */ -export function getGraphQLRequestPayload(payload: string): unknown | undefined { - let graphqlBody = undefined; - try { - const requestBody = JSON.parse(payload); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const isGraphQLRequest = !!requestBody['query']; - - if (isGraphQLRequest) { - graphqlBody = requestBody; - } - } finally { - // Fallback to undefined if payload is an invalid JSON (SyntaxError) - - /* eslint-disable no-unsafe-finally */ - return graphqlBody; - } -} diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index 63832e9ee1fd..c352f67df3b3 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { HandlerDataFetch } from '../../types-hoist'; -import { getGraphQLRequestPayload } from '../graphql'; import { isError } from '../is'; import { addNonEnumerableProperty, fill } from '../object'; import { supportsNativeFetch } from '../supports'; @@ -236,16 +235,3 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str }; } -/** - * Parses the fetch arguments to extract the request payload. - * Exported for tests only. - */ -export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { - if (fetchArgs.length === 2) { - const options = fetchArgs[1]; - return hasProp(options, 'body') ? String(options.body) : undefined; - } - - const arg = fetchArgs[0]; - return hasProp(arg, 'body') ? String(arg.body) : undefined; -} diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index c94bf837244a..723ef811862f 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -13,6 +13,8 @@ export type { ReplaySpanFrameEvent, CanvasManagerInterface, CanvasManagerOptions, + FetchHint, + XhrHint, } from './types'; export { getReplay } from './util/getReplay'; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 2a829725364b..2053189019eb 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -1,4 +1,6 @@ -import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; +// if imported, circular dep +// import type { FetchHint, XhrHint } from '@sentry-internal/replay'; +import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './breadcrumb'; import type { CheckIn, MonitorConfig } from './checkin'; import type { EventDropReason } from './clientreport'; import type { DataCategory } from './datacategory'; @@ -7,7 +9,7 @@ import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; import type { FeedbackEvent } from './feedback'; -import type { HandlerDataFetch, HandlerDataXhr } from './instrument'; +import type { HandlerDataFetch, HandlerDataXhr, SentryWrappedXMLHttpRequest } from './instrument'; import type { Integration } from './integration'; import type { ClientOptions } from './options'; import type { ParameterizedString } from './parameterize'; @@ -19,6 +21,17 @@ import type { Span, SpanAttributes, SpanContextData } from './span'; import type { StartSpanOptions } from './startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './transport'; +type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; + +export type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; +export type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; + /** * User-Facing Sentry SDK Client. * @@ -298,7 +311,7 @@ export interface Client { */ on( hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (span: Span, hint: XhrHint | FetchHint) => void, ): () => void; /** @@ -409,7 +422,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void; + emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. diff --git a/packages/utils/test/graphql.test.ts b/packages/utils/test/graphql.test.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/utils/test/instrument/fetch.test.ts b/packages/utils/test/instrument/fetch.test.ts new file mode 100644 index 000000000000..e69de29bb2d1 From 787b18cfc492c55e7ad042c749347b4e1abbf8f2 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 14:03:13 -0500 Subject: [PATCH 38/65] fix(browser): Refactor breadcrumb handlers to use hint Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/breadcrumbs.ts | 10 +++++--- .../browser/src/integrations/graphqlClient.ts | 23 ++++++++--------- packages/browser/src/tracing/request.ts | 1 - .../test/integrations/graphqlClient.test.ts | 15 +++++------ packages/core/src/client.ts | 25 +++++++++++-------- packages/core/src/fetch.ts | 10 ++++++-- .../core/src/utils-hoist/instrument/fetch.ts | 1 - packages/types/src/client.ts | 15 +++-------- 8 files changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 6339769c0b91..52eabb32a558 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -35,7 +35,9 @@ import { parseUrl, safeJoin, severityLevelFromString, -} from '@sentry/core'; +} from '@sentry/utils'; + +import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; @@ -258,7 +260,7 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) level: getBreadcrumbLogLevelFromHttpStatusCode(status_code), }; - client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as XhrHint); addBreadcrumb(breadcrumb, hint); }; @@ -305,7 +307,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe type: 'http', } satisfies Breadcrumb; - client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as FetchHint); addBreadcrumb(breadcrumb, hint); } else { @@ -329,7 +331,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe level: getBreadcrumbLogLevelFromHttpStatusCode(data.status_code), }; - client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, handlerData); + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as FetchHint); addBreadcrumb(breadcrumb, hint); } diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 2b3755d6d66e..a82cea3da809 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,6 @@ import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; -import { FetchHint, getBodyString, XhrHint } from '@sentry-internal/replay'; +import { getBodyString } from '@sentry-internal/replay'; +import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -22,6 +23,11 @@ interface GraphQLRequestPayload { extensions?: Record; } +interface GraphQLOperation { + operationType: string | undefined; + operationName: string | undefined; +} + const INTEGRATION_NAME = 'GraphQLClient'; const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { @@ -65,7 +71,6 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); span.setAttribute('graphql.document', payload); } - }); } @@ -92,11 +97,6 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient data['graphql.document'] = (graphqlBody as GraphQLRequestPayload).query; data['graphql.operation'] = operationInfo; } - - // The body prop attached to HandlerDataFetch for the span should be removed. - if (isFetch && data.body) { - delete data.body; - } } } }); @@ -121,7 +121,7 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { */ function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { const isXhr = 'xhr' in hint; - const isFetch = !isXhr + const isFetch = !isXhr; let body: string | undefined; @@ -136,6 +136,7 @@ function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undef return body; } +// Duplicate from deprecated @sentry-utils/src/instrument/fetch.ts function hasProp(obj: unknown, prop: T): obj is Record { return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; } @@ -154,13 +155,9 @@ export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { return hasProp(arg, 'body') ? String(arg.body) : undefined; } -interface GraphQLOperation { - operationType: string | undefined; - operationName: string | undefined; -} - /** * Extract the name and type of the operation from the GraphQL query. + * Exported for tests only. * @param query */ export function parseGraphQLQuery(query: string): GraphQLOperation { diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 3d0f5b13c848..4bdcf4cd35cd 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -23,7 +23,6 @@ import { stringMatchesSomePattern, } from '@sentry/core'; import { WINDOW } from '../helpers'; -import { XhrHint } from '@sentry-internal/replay'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index db42ed3f888c..dc5be0eea344 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -1,11 +1,10 @@ -import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from "../../src/integrations/graphqlClient"; +import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from '../../src/integrations/graphqlClient'; describe('GraphqlClient', () => { - describe('parseFetchPayload', () => { - + describe('parseFetchPayload', () => { const data = [1, 2, 3]; const jsonData = '{"data":[1,2,3]}'; - + it.each([ ['string URL only', ['http://example.com'], undefined], ['URL object only', [new URL('http://example.com')], undefined], @@ -28,10 +27,10 @@ describe('GraphqlClient', () => { ], ])('%s', (_name, args, expected) => { const actual = parseFetchPayload(args as unknown[]); - + expect(actual).toEqual(expected); }); - }); + }); describe('parseGraphQLQuery', () => { const queryOne = `query Test { @@ -72,7 +71,7 @@ describe('GraphqlClient', () => { expect(parseGraphQLQuery(input)).toEqual(output); }); }); - + describe('getGraphQLRequestPayload', () => { test('should return undefined for non-GraphQL request', () => { const requestBody = { data: [1, 2, 3] }; @@ -90,5 +89,3 @@ describe('GraphqlClient', () => { }); }); }); - - diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index bb6cb083ab7c..339ab3efcf4f 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -16,7 +16,6 @@ import type { FeedbackEvent, FetchBreadcrumbHint, HandlerDataFetch, - HandlerDataXhr, Integration, MonitorConfig, Outcome, @@ -65,6 +64,17 @@ import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; +type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; + +export type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; +export type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; + /** * Base implementation for all JavaScript SDK clients. * @@ -584,15 +594,12 @@ export abstract class Client { public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; /** @inheritdoc */ - public on( - hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, hint: XhrHint | FetchHint ) => void, - ): () => void; + public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; /** @inheritdoc */ public on( hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, ): () => void; public on(hook: 'flush', callback: () => void): () => void; @@ -725,11 +732,7 @@ export abstract class Client { public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** @inheritdoc */ - public emit( - hook: 'beforeOutgoingRequestBreadcrumb', - breadcrumb: Breadcrumb, - handlerData: HandlerDataXhr | HandlerDataFetch, - ): void; + public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; /** @inheritdoc */ public emit(hook: 'flush'): void; diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 7b4acc18eb3f..eec92bf292c2 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -97,8 +97,14 @@ export function instrumentFetchRequest( } if (client) { - const fetchHint = { input: handlerData.args , response: handlerData.response, startTimestamp: handlerData.startTimestamp, endTimestamp: handlerData.endTimestamp } satisfies FetchHint - client.emit('beforeOutgoingRequestSpan', span, fetchHint); + // There's no 'input' key in HandlerDataFetch + const fetchHint = { + input: handlerData.args, + response: handlerData.response, + startTimestamp: handlerData.startTimestamp, + endTimestamp: handlerData.endTimestamp, + }; + client.emit('beforeOutgoingRequestSpan', span, fetchHint as FetchHint); } return span; diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index c352f67df3b3..f3eee711d26d 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -234,4 +234,3 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', }; } - diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 2053189019eb..aede0ce5a5a3 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -9,7 +9,7 @@ import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; import type { FeedbackEvent } from './feedback'; -import type { HandlerDataFetch, HandlerDataXhr, SentryWrappedXMLHttpRequest } from './instrument'; +import type { HandlerDataFetch, SentryWrappedXMLHttpRequest } from './instrument'; import type { Integration } from './integration'; import type { ClientOptions } from './options'; import type { ParameterizedString } from './parameterize'; @@ -309,10 +309,7 @@ export interface Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - on( - hook: 'beforeOutgoingRequestSpan', - callback: (span: Span, hint: XhrHint | FetchHint) => void, - ): () => void; + on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -320,7 +317,7 @@ export interface Client { */ on( hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, handlerData: HandlerDataXhr | HandlerDataFetch) => void, + callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, ): () => void; /** @@ -427,11 +424,7 @@ export interface Client { /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit( - hook: 'beforeOutgoingRequestBreadcrumb', - breadcrumb: Breadcrumb, - handlerData: HandlerDataXhr | HandlerDataFetch, - ): void; + emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; /** * Emit a hook event for client flush From 93a485356048599780ca79910879d0521e72549d Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 14:47:02 -0500 Subject: [PATCH 39/65] test(browser): Add tests for `getRequestPayloadXhrOrFetch` Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 11 ++- .../test/integrations/graphqlClient.test.ts | 85 ++++++++++++++++++- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index a82cea3da809..39ed18830bc8 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -62,7 +62,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(hint); + const payload = getRequestPayloadXhrOrFetch(hint); if (isTracedGraphqlEndpoint && payload) { const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; @@ -87,7 +87,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = _getRequestPayloadXhrOrFetch(handlerData); + const payload = getRequestPayloadXhrOrFetch(handlerData); if (isTracedGraphqlEndpoint && data && payload) { const graphqlBody = getGraphQLRequestPayload(payload); @@ -117,18 +117,17 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { /** * Get the request body/payload based on the shape of the hint - * TODO: export for test? + * Exported for tests only. */ -function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { +export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { const isXhr = 'xhr' in hint; - const isFetch = !isXhr; let body: string | undefined; if (isXhr) { const sentryXhrData = hint.xhr[SENTRY_XHR_DATA_KEY]; body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; - } else if (isFetch) { + } else { const sentryFetchData = parseFetchPayload(hint.input); body = getBodyString(sentryFetchData)[0]; } diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index dc5be0eea344..5fb58a8c8150 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -1,11 +1,24 @@ -import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from '../../src/integrations/graphqlClient'; +/** + * @vitest-environment jsdom + */ + +import { describe, expect, test } from 'vitest'; + +import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import type { FetchHint, XhrHint } from '@sentry-internal/replay'; +import { + getGraphQLRequestPayload, + getRequestPayloadXhrOrFetch, + parseFetchPayload, + parseGraphQLQuery, +} from '../../src/integrations/graphqlClient'; describe('GraphqlClient', () => { describe('parseFetchPayload', () => { const data = [1, 2, 3]; const jsonData = '{"data":[1,2,3]}'; - it.each([ + test.each([ ['string URL only', ['http://example.com'], undefined], ['URL object only', [new URL('http://example.com')], undefined], ['Request URL only', [{ url: 'http://example.com' }], undefined], @@ -88,4 +101,72 @@ describe('GraphqlClient', () => { expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); }); }); + + describe('getRequestPayloadXhrOrFetch', () => { + test('should parse xhr payload', () => { + const hint: XhrHint = { + xhr: { + [SENTRY_XHR_DATA_KEY]: { + method: 'POST', + url: 'http://example.com/test', + status_code: 200, + body: JSON.stringify({ key: 'value' }), + request_headers: { + 'Content-Type': 'application/json', + }, + }, + ...new XMLHttpRequest(), + }, + input: JSON.stringify({ key: 'value' }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1000, + }; + + const result = getRequestPayloadXhrOrFetch(hint); + expect(result).toEqual(JSON.stringify({ key: 'value' })); + }); + test('should parse fetch payload', () => { + const hint: FetchHint = { + input: [ + 'http://example.com/test', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key: 'value' }), + }, + ], + response: new Response(JSON.stringify({ key: 'value' }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1000, + }; + + const result = getRequestPayloadXhrOrFetch(hint); + expect(result).toEqual(JSON.stringify({ key: 'value' })); + }); + test('should return undefined if no body is in the response', () => { + const hint: FetchHint = { + input: [ + 'http://example.com/test', + { + method: 'GET', + }, + ], + response: new Response(null, { + status: 200, + }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1000, + }; + + const result = getRequestPayloadXhrOrFetch(hint); + expect(result).toBeUndefined(); + }); + }); }); From 859a1eeb995f950cdbd357bf622f57b70e067874 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 14:56:27 -0500 Subject: [PATCH 40/65] refactor(browser): Remove type assertions Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 39ed18830bc8..77395f515f9d 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -65,11 +65,13 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const payload = getRequestPayloadXhrOrFetch(hint); if (isTracedGraphqlEndpoint && payload) { - const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload; - const operationInfo = _getGraphQLOperation(graphqlBody); + const graphqlBody = getGraphQLRequestPayload(payload); - span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('graphql.document', payload); + if (graphqlBody) { + const operationInfo = _getGraphQLOperation(graphqlBody); + span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + span.setAttribute('graphql.document', payload); + } } }); } @@ -93,8 +95,8 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const graphqlBody = getGraphQLRequestPayload(payload); if (!data.graphql && graphqlBody) { - const operationInfo = _getGraphQLOperation(graphqlBody as GraphQLRequestPayload); - data['graphql.document'] = (graphqlBody as GraphQLRequestPayload).query; + const operationInfo = _getGraphQLOperation(graphqlBody); + data['graphql.document'] = graphqlBody.query; data['graphql.operation'] = operationInfo; } } @@ -182,14 +184,13 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { * @param payload - A valid JSON string * @returns A POJO or undefined */ -export function getGraphQLRequestPayload(payload: string): unknown | undefined { +export function getGraphQLRequestPayload(payload: string): GraphQLRequestPayload | undefined { let graphqlBody = undefined; try { - const requestBody = JSON.parse(payload); + const requestBody = JSON.parse(payload) satisfies GraphQLRequestPayload; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const isGraphQLRequest = !!requestBody['query']; - if (isGraphQLRequest) { graphqlBody = requestBody; } From ad6bc3091ce6a18c935516d0aa8f513cb8f9ca7a Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Sun, 19 Jan 2025 15:05:26 -0500 Subject: [PATCH 41/65] fix(browser): Remove unnecessary `FetchInput` type Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/core/src/types-hoist/instrument.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts index b35e6290652f..420482579dd9 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -6,8 +6,6 @@ import type { WebFetchHeaders } from './webfetchapi'; // Make sure to cast it where needed! type XHRSendInput = unknown; -type FetchInput = unknown; - export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' | 'assert' | 'trace'; export interface SentryWrappedXMLHttpRequest { @@ -42,7 +40,6 @@ export interface HandlerDataXhr { interface SentryFetchData { method: string; url: string; - body?: FetchInput; request_body_size?: number; response_body_size?: number; // span_id for the fetch request From f4c151a1f4ec3aad84ba2c26c8f1d18e7c0fb5f1 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 22 Jan 2025 21:31:52 -0500 Subject: [PATCH 42/65] chore(browser): Remove deleted import Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/utils/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 9aa1740f28c6..245751b3e72c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -39,5 +39,3 @@ export * from './lru'; export * from './buildPolyfills'; export * from './propagationContext'; export * from './version'; -export * from './graphql'; - From 8d5550eda3f18e42b71fc8de1db9c3c835fd86a2 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:37:55 -0500 Subject: [PATCH 43/65] fix(browser): Resolve rebase conflicts Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../integrations/graphqlClient/fetch/test.ts | 8 +- .../integrations/graphqlClient/xhr/test.ts | 8 +- packages/browser/src/index.ts | 2 - .../browser/src/integrations/breadcrumbs.ts | 7 +- .../browser/src/integrations/graphqlClient.ts | 4 +- packages/browser/src/tracing/request.ts | 3 + .../test/integrations/graphqlClient.test.ts | 2 +- packages/core/src/client.ts | 31 +- packages/core/src/fetch.ts | 3 + packages/core/src/utils-hoist/graphql.ts | 0 .../core/test/utils-hoist/graphql.test.ts | 59 --- packages/types/src/client.ts | 440 ------------------ packages/utils/src/index.ts | 41 -- packages/utils/test/graphql.test.ts | 0 packages/utils/test/instrument/fetch.test.ts | 0 15 files changed, 47 insertions(+), 561 deletions(-) delete mode 100644 packages/core/src/utils-hoist/graphql.ts delete mode 100644 packages/core/test/utils-hoist/graphql.test.ts delete mode 100644 packages/types/src/client.ts delete mode 100644 packages/utils/src/index.ts delete mode 100644 packages/utils/test/graphql.test.ts delete mode 100644 packages/utils/test/instrument/fetch.test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 9a5a953901aa..1d25a592f817 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -13,8 +13,8 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ @@ -57,8 +57,8 @@ sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTe }); }); -sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 00357c0acf43..6b3790c663b2 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -13,8 +13,8 @@ const query = `query Test{ }`; const queryPayload = JSON.stringify({ query }); -sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ @@ -57,8 +57,8 @@ sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTest }); }); -sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestPath, page }) => { - const url = await getLocalTestPath({ testDir: __dirname }); +sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); await page.route('**/foo', route => { return route.fulfill({ diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index b7b77d773bd7..1eecbcfd077a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -32,8 +32,6 @@ import { feedbackSyncIntegration } from './feedbackSync'; export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration }; export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; -export * from './metrics'; - export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request'; export { browserTracingIntegration, diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 52eabb32a558..14a1c5dda1fd 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -35,7 +35,7 @@ import { parseUrl, safeJoin, severityLevelFromString, -} from '@sentry/utils'; +} from '@sentry/core'; import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { DEBUG_BUILD } from '../debug-build'; @@ -293,6 +293,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe }; if (handlerData.error) { + const data: FetchBreadcrumbData = handlerData.fetchData; const hint: FetchBreadcrumbHint = { data: handlerData.error, input: handlerData.args, @@ -312,6 +313,10 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe addBreadcrumb(breadcrumb, hint); } else { const response = handlerData.response as Response | undefined; + const data: FetchBreadcrumbData = { + ...handlerData.fetchData, + status_code: response && response.status, + }; breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 77395f515f9d..8c0867bd4e81 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -8,8 +8,8 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import type { Client, IntegrationFn } from '@sentry/types'; -import { isString, stringMatchesSomePattern } from '@sentry/utils'; +import type { Client, IntegrationFn } from '@sentry/core'; +import { isString, stringMatchesSomePattern } from '@sentry/core'; interface GraphQLClientOptions { endpoints: Array; diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 4bdcf4cd35cd..b8c32cdee279 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -3,6 +3,7 @@ import { addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; +import { XhrHint } from '@sentry-internal/replay'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -12,6 +13,7 @@ import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin, getActiveSpan, + getClient, getLocationHref, getTraceData, hasTracingEnabled, @@ -404,6 +406,7 @@ export function xhrCallback( ); } + const client = getClient(); if (client) { client.emit('beforeOutgoingRequestSpan', span, handlerData as XhrHint); } diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 5fb58a8c8150..e83d874beb06 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -79,7 +79,7 @@ describe('GraphqlClient', () => { queryThree, { operationName: 'OnTestItemAdded', operationType: 'subscription' }, ], - // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + // TODO: ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], ])('%s', (_, input, output) => { expect(parseGraphQLQuery(input)).toEqual(output); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 339ab3efcf4f..7a4d21e9659e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -33,6 +33,7 @@ import type { TransactionEvent, Transport, TransportMakeRequestResponse, + XhrBreadcrumbHint, } from './types-hoist'; import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; @@ -593,15 +594,25 @@ export abstract class Client { */ public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; - /** @inheritdoc */ + /** + * A hook for GraphQL client integration to enhance a span with request data. + * @returns A function that, when executed, removes the registered callback. + */ public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; - /** @inheritdoc */ + /** + * A hook for GraphQL client integration to enhance a breadcrumb with request data. + * @returns A function that, when executed, removes the registered callback. + */ public on( hook: 'beforeOutgoingRequestBreadcrumb', callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, ): () => void; + /** + * A hook that is called when the client is flushing + * @returns A function that, when executed, removes the registered callback. + */ public on(hook: 'flush', callback: () => void): () => void; /** @@ -728,13 +739,19 @@ export abstract class Client { */ public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; - /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; + /** + * Emit a hook event for GraphQL client integration to enhance a span with request data. + */ + emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; - /** @inheritdoc */ - public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; + /** + * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. + */ + emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; - /** @inheritdoc */ + /** + * Emit a hook event for client flush + */ public emit(hook: 'flush'): void; /** diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index eec92bf292c2..e08e5257dc42 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,3 +1,5 @@ +import { FetchHint } from './client'; +import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; @@ -96,6 +98,7 @@ export function instrumentFetchRequest( } } + const client = getClient(); if (client) { // There's no 'input' key in HandlerDataFetch const fetchHint = { diff --git a/packages/core/src/utils-hoist/graphql.ts b/packages/core/src/utils-hoist/graphql.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/core/test/utils-hoist/graphql.test.ts b/packages/core/test/utils-hoist/graphql.test.ts deleted file mode 100644 index a325e9c94bcc..000000000000 --- a/packages/core/test/utils-hoist/graphql.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { getGraphQLRequestPayload, parseGraphQLQuery } from '../src'; - -describe('graphql', () => { - describe('parseGraphQLQuery', () => { - const queryOne = `query Test { - items { - id - } - }`; - - const queryTwo = `mutation AddTestItem($input: TestItem!) { - addItem(input: $input) { - name - } - }`; - - const queryThree = `subscription OnTestItemAdded($itemID: ID!) { - itemAdded(itemID: $itemID) { - id - } - }`; - - // TODO: support name-less queries - // const queryFour = ` query { - // items { - // id - // } - // }`; - - test.each([ - ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], - ['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }], - [ - 'should handle subscription type', - queryThree, - { operationName: 'OnTestItemAdded', operationType: 'subscription' }, - ], - // ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], - ])('%s', (_, input, output) => { - expect(parseGraphQLQuery(input)).toEqual(output); - }); - }); - describe('getGraphQLRequestPayload', () => { - test('should return undefined for non-GraphQL request', () => { - const requestBody = { data: [1, 2, 3] }; - - expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); - }); - test('should return the payload object for GraphQL request', () => { - const requestBody = { - query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', - operationName: 'Test', - variables: {}, - }; - - expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); - }); - }); -}); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts deleted file mode 100644 index aede0ce5a5a3..000000000000 --- a/packages/types/src/client.ts +++ /dev/null @@ -1,440 +0,0 @@ -// if imported, circular dep -// import type { FetchHint, XhrHint } from '@sentry-internal/replay'; -import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './breadcrumb'; -import type { CheckIn, MonitorConfig } from './checkin'; -import type { EventDropReason } from './clientreport'; -import type { DataCategory } from './datacategory'; -import type { DsnComponents } from './dsn'; -import type { DynamicSamplingContext, Envelope } from './envelope'; -import type { Event, EventHint } from './event'; -import type { EventProcessor } from './eventprocessor'; -import type { FeedbackEvent } from './feedback'; -import type { HandlerDataFetch, SentryWrappedXMLHttpRequest } from './instrument'; -import type { Integration } from './integration'; -import type { ClientOptions } from './options'; -import type { ParameterizedString } from './parameterize'; -import type { Scope } from './scope'; -import type { SdkMetadata } from './sdkmetadata'; -import type { Session, SessionAggregates } from './session'; -import type { SeverityLevel } from './severity'; -import type { Span, SpanAttributes, SpanContextData } from './span'; -import type { StartSpanOptions } from './startSpanOptions'; -import type { Transport, TransportMakeRequestResponse } from './transport'; - -type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; - -export type XhrHint = XhrBreadcrumbHint & { - xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; - input?: RequestBody; -}; -export type FetchHint = FetchBreadcrumbHint & { - input: HandlerDataFetch['args']; - response: Response; -}; - -/** - * User-Facing Sentry SDK Client. - * - * This interface contains all methods to interface with the SDK once it has - * been installed. It allows to send events to Sentry, record breadcrumbs and - * set a context included in every event. Since the SDK mutates its environment, - * there will only be one instance during runtime. - * - */ -export interface Client { - /** - * Captures an exception event and sends it to Sentry. - * - * Unlike `captureException` exported from every SDK, this method requires that you pass it the current scope. - * - * @param exception An exception-like object. - * @param hint May contain additional information about the original exception. - * @param currentScope An optional scope containing event metadata. - * @returns The event id - */ - captureException(exception: any, hint?: EventHint, currentScope?: Scope): string; - - /** - * Captures a message event and sends it to Sentry. - * - * Unlike `captureMessage` exported from every SDK, this method requires that you pass it the current scope. - * - * @param message The message to send to Sentry. - * @param level Define the level of the message. - * @param hint May contain additional information about the original exception. - * @param currentScope An optional scope containing event metadata. - * @returns The event id - */ - captureMessage(message: string, level?: SeverityLevel, hint?: EventHint, currentScope?: Scope): string; - - /** - * Captures a manually created event and sends it to Sentry. - * - * Unlike `captureEvent` exported from every SDK, this method requires that you pass it the current scope. - * - * @param event The event to send to Sentry. - * @param hint May contain additional information about the original exception. - * @param currentScope An optional scope containing event metadata. - * @returns The event id - */ - captureEvent(event: Event, hint?: EventHint, currentScope?: Scope): string; - - /** - * Captures a session - * - * @param session Session to be delivered - */ - captureSession(session: Session): void; - - /** - * Create a cron monitor check in and send it to Sentry. This method is not available on all clients. - * - * @param checkIn An object that describes a check in. - * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want - * to create a monitor automatically when sending a check in. - * @param scope An optional scope containing event metadata. - * @returns A string representing the id of the check in. - */ - captureCheckIn?(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string; - - /** Returns the current Dsn. */ - getDsn(): DsnComponents | undefined; - - /** Returns the current options. */ - getOptions(): O; - - /** - * @inheritdoc - * - */ - getSdkMetadata(): SdkMetadata | undefined; - - /** - * Returns the transport that is used by the client. - * Please note that the transport gets lazy initialized so it will only be there once the first event has been sent. - * - * @returns The transport. - */ - getTransport(): Transport | undefined; - - /** - * Flush the event queue and set the client to `enabled = false`. See {@link Client.flush}. - * - * @param timeout Maximum time in ms the client should wait before shutting down. Omitting this parameter will cause - * the client to wait until all events are sent before disabling itself. - * @returns A promise which resolves to `true` if the flush completes successfully before the timeout, or `false` if - * it doesn't. - */ - close(timeout?: number): PromiseLike; - - /** - * Wait for all events to be sent or the timeout to expire, whichever comes first. - * - * @param timeout Maximum time in ms the client should wait for events to be flushed. Omitting this parameter will - * cause the client to wait until all events are sent before resolving the promise. - * @returns A promise that will resolve with `true` if all events are sent before the timeout, or `false` if there are - * still events in the queue when the timeout is reached. - */ - flush(timeout?: number): PromiseLike; - - /** - * Adds an event processor that applies to any event processed by this client. - */ - addEventProcessor(eventProcessor: EventProcessor): void; - - /** - * Get all added event processors for this client. - */ - getEventProcessors(): EventProcessor[]; - - /** Get the instance of the integration with the given name on the client, if it was added. */ - getIntegrationByName(name: string): T | undefined; - - /** - * Add an integration to the client. - * This can be used to e.g. lazy load integrations. - * In most cases, this should not be necessary, and you're better off just passing the integrations via `integrations: []` at initialization time. - * However, if you find the need to conditionally load & add an integration, you can use `addIntegration` to do so. - * - * */ - addIntegration(integration: Integration): void; - - /** - * Initialize this client. - * Call this after the client was set on a scope. - */ - init(): void; - - /** Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`. */ - eventFromException(exception: any, hint?: EventHint): PromiseLike; - - /** Creates an {@link Event} from primitive inputs to `captureMessage`. */ - eventFromMessage(message: ParameterizedString, level?: SeverityLevel, hint?: EventHint): PromiseLike; - - /** Submits the event to Sentry */ - sendEvent(event: Event, hint?: EventHint): void; - - /** Submits the session to Sentry */ - sendSession(session: Session | SessionAggregates): void; - - /** Sends an envelope to Sentry */ - sendEnvelope(envelope: Envelope): PromiseLike; - - /** - * Record on the client that an event got dropped (ie, an event that will not be sent to sentry). - * - * @param reason The reason why the event got dropped. - * @param category The data category of the dropped event. - * @param event The dropped event. - */ - recordDroppedEvent(reason: EventDropReason, dataCategory: DataCategory, event?: Event): void; - - // HOOKS - /* eslint-disable @typescript-eslint/unified-signatures */ - - /** - * Register a callback for whenever a span is started. - * Receives the span as argument. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'spanStart', callback: (span: Span) => void): () => void; - - /** - * Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision` - * property that can be used to make a sampling decision that will be enforced, before any span sampling runs. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'beforeSampling', - callback: ( - samplingData: { - spanAttributes: SpanAttributes; - spanName: string; - parentSampled?: boolean; - parentContext?: SpanContextData; - }, - samplingDecision: { decision: boolean }, - ) => void, - ): void; - - /** - * Register a callback for whenever a span is ended. - * Receives the span as argument. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'spanEnd', callback: (span: Span) => void): () => void; - - /** - * Register a callback for when an idle span is allowed to auto-finish. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): () => void; - - /** - * Register a callback for transaction start and finish. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): () => void; - - /** - * Register a callback that runs when stack frame metadata should be applied to an event. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'applyFrameMetadata', callback: (event: Event) => void): () => void; - - /** - * Register a callback for before sending an event. - * This is called right before an event is sent and should not be used to mutate the event. - * Receives an Event & EventHint as arguments. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeSendEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; - - /** - * Register a callback for preprocessing an event, - * before it is passed to (global) event processors. - * Receives an Event & EventHint as arguments. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'preprocessEvent', callback: (event: Event, hint?: EventHint | undefined) => void): () => void; - - /** - * Register a callback for when an event has been sent. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'afterSendEvent', callback: (event: Event, sendResponse: TransportMakeRequestResponse) => void): () => void; - - /** - * Register a callback before a breadcrumb is added. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): () => void; - - /** - * Register a callback when a DSC (Dynamic Sampling Context) is created. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext, rootSpan?: Span) => void): () => void; - - /** - * Register a callback when a Feedback event has been prepared. - * This should be used to mutate the event. The options argument can hint - * about what kind of mutation it expects. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'beforeSendFeedback', - callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void, - ): () => void; - - /** - * A hook for the browser tracing integrations to trigger a span start for a page load. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'startPageLoadSpan', - callback: ( - options: StartSpanOptions, - traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, - ) => void, - ): () => void; - - /** - * A hook for browser tracing integrations to trigger a span for a navigation. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void; - - /** - * A hook for GraphQL client integration to enhance a span with request data. - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; - - /** - * A hook for GraphQL client integration to enhance a breadcrumb with request data. - * @returns A function that, when executed, removes the registered callback. - */ - on( - hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, - ): () => void; - - /** - * A hook that is called when the client is flushing - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'flush', callback: () => void): () => void; - - /** - * A hook that is called when the client is closing - * @returns A function that, when executed, removes the registered callback. - */ - on(hook: 'close', callback: () => void): () => void; - - /** Fire a hook whener a span starts. */ - emit(hook: 'spanStart', span: Span): void; - - /** A hook that is called every time before a span is sampled. */ - emit( - hook: 'beforeSampling', - samplingData: { - spanAttributes: SpanAttributes; - spanName: string; - parentSampled?: boolean; - parentContext?: SpanContextData; - }, - samplingDecision: { decision: boolean }, - ): void; - - /** Fire a hook whener a span ends. */ - emit(hook: 'spanEnd', span: Span): void; - - /** - * Fire a hook indicating that an idle span is allowed to auto finish. - */ - emit(hook: 'idleSpanEnableAutoFinish', span: Span): void; - - /* - * Fire a hook event for envelope creation and sending. Expects to be given an envelope as the - * second argument. - */ - emit(hook: 'beforeEnvelope', envelope: Envelope): void; - - /* - * Fire a hook indicating that stack frame metadata should be applied to the event passed to the hook. - */ - emit(hook: 'applyFrameMetadata', event: Event): void; - - /** - * Fire a hook event before sending an event. - * This is called right before an event is sent and should not be used to mutate the event. - * Expects to be given an Event & EventHint as the second/third argument. - */ - emit(hook: 'beforeSendEvent', event: Event, hint?: EventHint): void; - - /** - * Fire a hook event to process events before they are passed to (global) event processors. - * Expects to be given an Event & EventHint as the second/third argument. - */ - emit(hook: 'preprocessEvent', event: Event, hint?: EventHint): void; - - /* - * Fire a hook event after sending an event. Expects to be given an Event as the - * second argument. - */ - emit(hook: 'afterSendEvent', event: Event, sendResponse: TransportMakeRequestResponse): void; - - /** - * Fire a hook for when a breadcrumb is added. Expects the breadcrumb as second argument. - */ - emit(hook: 'beforeAddBreadcrumb', breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void; - - /** - * Fire a hook for when a DSC (Dynamic Sampling Context) is created. Expects the DSC as second argument. - */ - emit(hook: 'createDsc', dsc: DynamicSamplingContext, rootSpan?: Span): void; - - /** - * Fire a hook event for after preparing a feedback event. Events to be given - * a feedback event as the second argument, and an optional options object as - * third argument. - */ - emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void; - - /** - * Emit a hook event for browser tracing integrations to trigger a span start for a page load. - */ - emit( - hook: 'startPageLoadSpan', - options: StartSpanOptions, - traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, - ): void; - - /** - * Emit a hook event for browser tracing integrations to trigger a span for a navigation. - */ - emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; - - /** - * Emit a hook event for GraphQL client integration to enhance a span with request data. - */ - emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; - - /** - * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. - */ - emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; - - /** - * Emit a hook event for client flush - */ - emit(hook: 'flush'): void; - - /** - * Emit a hook event for client close - */ - emit(hook: 'close'): void; - - /* eslint-enable @typescript-eslint/unified-signatures */ -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts deleted file mode 100644 index 245751b3e72c..000000000000 --- a/packages/utils/src/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -export * from './aggregate-errors'; -export * from './array'; -export * from './breadcrumb-log-level'; -export * from './browser'; -export * from './dsn'; -export * from './error'; -export * from './worldwide'; -export * from './instrument'; -export * from './is'; -export * from './isBrowser'; -export * from './logger'; -export * from './memo'; -export * from './misc'; -export * from './node'; -export * from './normalize'; -export * from './object'; -export * from './path'; -export * from './promisebuffer'; -// TODO: Remove requestdata export once equivalent integration is used everywhere -export * from './requestdata'; -export * from './severity'; -export * from './stacktrace'; -export * from './node-stack-trace'; -export * from './string'; -export * from './supports'; -export * from './syncpromise'; -export * from './time'; -export * from './tracing'; -export * from './env'; -export * from './envelope'; -export * from './clientreport'; -export * from './ratelimit'; -export * from './baggage'; -export * from './url'; -export * from './cache'; -export * from './eventbuilder'; -export * from './anr'; -export * from './lru'; -export * from './buildPolyfills'; -export * from './propagationContext'; -export * from './version'; diff --git a/packages/utils/test/graphql.test.ts b/packages/utils/test/graphql.test.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/utils/test/instrument/fetch.test.ts b/packages/utils/test/instrument/fetch.test.ts deleted file mode 100644 index e69de29bb2d1..000000000000 From 3e3322de51f420c18195284d50342b00d40bbb5f Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:42:00 -0500 Subject: [PATCH 44/65] fix(core): Revert resolved rebase conflict Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../test/utils-hoist/instrument/fetch.test.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/core/test/utils-hoist/instrument/fetch.test.ts b/packages/core/test/utils-hoist/instrument/fetch.test.ts index f89e795dd0bd..fc6102d6b617 100644 --- a/packages/core/test/utils-hoist/instrument/fetch.test.ts +++ b/packages/core/test/utils-hoist/instrument/fetch.test.ts @@ -1,31 +1,25 @@ import { parseFetchArgs } from '../../../src/utils-hoist/instrument/fetch'; describe('instrument > parseFetchArgs', () => { - const data = { name: 'Test' }; - it.each([ - ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com', body: null }], - ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/', body: null }], - ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com', body: null }], + ['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com' }], + ['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/' }], + ['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com' }], [ 'Request URL & method only', [{ url: 'http://example.com', method: 'post' }], - { method: 'POST', url: 'http://example.com', body: null }, - ], - [ - 'string URL & options', - ['http://example.com', { method: 'post', body: JSON.stringify(data) }], - { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, + { method: 'POST', url: 'http://example.com' }, ], + ['string URL & options', ['http://example.com', { method: 'post' }], { method: 'POST', url: 'http://example.com' }], [ 'URL object & options', - [new URL('http://example.com'), { method: 'post', body: JSON.stringify(data) }], - { method: 'POST', url: 'http://example.com/', body: '{"name":"Test"}' }, + [new URL('http://example.com'), { method: 'post' }], + { method: 'POST', url: 'http://example.com/' }, ], [ 'Request URL & options', - [{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify(data) }], - { method: 'POST', url: 'http://example.com', body: '{"name":"Test"}' }, + [{ url: 'http://example.com' }, { method: 'post' }], + { method: 'POST', url: 'http://example.com' }, ], ])('%s', (_name, args, expected) => { const actual = parseFetchArgs(args as unknown[]); From 8ec59ce23d40230659f18d92bc6449d8ebfbdffc Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 08:27:39 -0500 Subject: [PATCH 45/65] fix: Lint Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser/src/integrations/breadcrumbs.ts | 2 +- packages/browser/src/integrations/graphqlClient.ts | 2 +- packages/browser/src/tracing/request.ts | 2 +- packages/core/src/client.ts | 4 ++-- packages/core/src/fetch.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 14a1c5dda1fd..59a6a418c66c 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -315,7 +315,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const response = handlerData.response as Response | undefined; const data: FetchBreadcrumbData = { ...handlerData.fetchData, - status_code: response && response.status, + status_code: response?.status, }; breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 8c0867bd4e81..72321f9a86c3 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -85,7 +85,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const isHttpBreadcrumb = type === 'http'; if (isHttpBreadcrumb && (isFetch || isXhr)) { - const httpUrl = data && data.url; + const httpUrl = data?.url; const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index b8c32cdee279..e979d01f3795 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -3,7 +3,7 @@ import { addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; -import { XhrHint } from '@sentry-internal/replay'; +import type { XhrHint } from '@sentry-internal/replay'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7a4d21e9659e..df3a168fbeda 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -742,12 +742,12 @@ export abstract class Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; + public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; /** * Emit a hook event for client flush diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index e08e5257dc42..048b03a74e6d 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,4 +1,4 @@ -import { FetchHint } from './client'; +import type { FetchHint } from './client'; import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; From 59c8fcdbf57f1ae59d371f50055d1faa92a7b58b Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:25:05 -0500 Subject: [PATCH 46/65] refactor(browser-utils): Move `getBodyString` - Also moved `NetworkMetaWarning` type. Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/package.json | 6 +- packages/browser-utils/src/index.ts | 4 ++ packages/browser-utils/src/networkUtils.ts | 72 +++++++++++++++++++ packages/browser-utils/src/types.ts | 8 +++ .../browser-utils/test/networkUtils.test.ts | 41 +++++++++++ packages/browser-utils/tsconfig.test.json | 4 +- packages/browser-utils/vite.config.ts | 10 +++ .../browser/src/integrations/graphqlClient.ts | 3 +- packages/core/src/utils-hoist/index.ts | 2 + .../src/coreHandlers/util/fetchUtils.ts | 7 +- .../src/coreHandlers/util/networkUtils.ts | 32 +-------- .../src/coreHandlers/util/xhrUtils.ts | 14 ++-- packages/replay-internal/src/index.ts | 1 - packages/replay-internal/src/types/request.ts | 10 +-- .../coreHandlers/util/networkUtils.test.ts | 36 ---------- 15 files changed, 153 insertions(+), 97 deletions(-) create mode 100644 packages/browser-utils/src/networkUtils.ts create mode 100644 packages/browser-utils/test/networkUtils.test.ts create mode 100644 packages/browser-utils/vite.config.ts diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index b96ebb51962c..f60078893c91 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -56,9 +56,9 @@ "clean": "rimraf build coverage sentry-internal-browser-utils-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", - "test:unit": "jest", - "test": "jest", - "test:watch": "jest --watch", + "test:unit": "vitest run", + "test": "vitest run", + "test:watch": "vitest --watch", "yalc:publish": "yalc publish --push --sig" }, "volta": { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index c71b2d70e31d..c31a3b78f9c8 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -24,3 +24,7 @@ export { addHistoryInstrumentationHandler } from './instrument/history'; export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } from './getNativeImplementation'; export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; + +export type { NetworkMetaWarning } from './types'; + +export { getBodyString } from './networkUtils'; diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts new file mode 100644 index 000000000000..4c80dbebde7f --- /dev/null +++ b/packages/browser-utils/src/networkUtils.ts @@ -0,0 +1,72 @@ +import type { ConsoleLevel, Logger } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import type { NetworkMetaWarning } from './types'; + +type ReplayConsoleLevels = Extract; +type LoggerMethod = (...args: unknown[]) => void; +type LoggerConsoleMethods = Record; + +interface LoggerConfig { + captureExceptions: boolean; + traceInternals: boolean; +} + +// Duplicate from replay-internal +interface ReplayLogger extends LoggerConsoleMethods { + /** + * Calls `logger.info` but saves breadcrumb in the next tick due to race + * conditions before replay is initialized. + */ + infoTick: LoggerMethod; + /** + * Captures exceptions (`Error`) if "capture internal exceptions" is enabled + */ + exception: LoggerMethod; + /** + * Configures the logger with additional debugging behavior + */ + setConfig(config: Partial): void; +} + +function _serializeFormData(formData: FormData): string { + // This is a bit simplified, but gives us a decent estimate + // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' + // @ts-expect-error passing FormData to URLSearchParams actually works + return new URLSearchParams(formData).toString(); +} + +/** Get the string representation of a body. */ +export function getBodyString( + body: unknown, + logger?: Logger | ReplayLogger, +): [string | undefined, NetworkMetaWarning?] { + try { + if (typeof body === 'string') { + return [body]; + } + + if (body instanceof URLSearchParams) { + return [body.toString()]; + } + + if (body instanceof FormData) { + return [_serializeFormData(body)]; + } + + if (!body) { + return [undefined]; + } + } catch (error) { + // RelayLogger + if (DEBUG_BUILD && logger && 'exception' in logger) { + logger.exception(error, 'Failed to serialize body', body); + } else if (DEBUG_BUILD && logger) { + logger.error(error, 'Failed to serialize body', body); + } + return [undefined, 'BODY_PARSE_ERROR']; + } + + DEBUG_BUILD && logger?.info('Skipping network body because of body type', body); + + return [undefined, 'UNPARSEABLE_BODY_TYPE']; +} diff --git a/packages/browser-utils/src/types.ts b/packages/browser-utils/src/types.ts index fd8f997907fc..19f40156bb9a 100644 --- a/packages/browser-utils/src/types.ts +++ b/packages/browser-utils/src/types.ts @@ -4,3 +4,11 @@ export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & // document is not available in all browser environments (webworkers). We make it optional so you have to explicitly check for it Omit & Partial>; + +export type NetworkMetaWarning = + | 'MAYBE_JSON_TRUNCATED' + | 'TEXT_TRUNCATED' + | 'URL_SKIPPED' + | 'BODY_PARSE_ERROR' + | 'BODY_PARSE_TIMEOUT' + | 'UNPARSEABLE_BODY_TYPE'; diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts new file mode 100644 index 000000000000..0db51a127cd8 --- /dev/null +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import { getBodyString } from '../src/networkUtils'; + +describe('getBodyString', () => { + it('works with a string', () => { + const actual = getBodyString('abc'); + expect(actual).toEqual(['abc']); + }); + + it('works with URLSearchParams', () => { + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); + const actual = getBodyString(body); + expect(actual).toEqual(['name=Anne&age=32']); + }); + + it('works with FormData', () => { + const body = new FormData(); + body.append('name', 'Anne'); + body.append('age', '32'); + const actual = getBodyString(body); + expect(actual).toEqual(['name=Anne&age=32']); + }); + + it('works with empty data', () => { + const body = undefined; + const actual = getBodyString(body); + expect(actual).toEqual([undefined]); + }); + + it('works with other type of data', () => { + const body = {}; + const actual = getBodyString(body); + expect(actual).toEqual([undefined, 'UNPARSEABLE_BODY_TYPE']); + }); +}); diff --git a/packages/browser-utils/tsconfig.test.json b/packages/browser-utils/tsconfig.test.json index 87f6afa06b86..5a75500b007f 100644 --- a/packages/browser-utils/tsconfig.test.json +++ b/packages/browser-utils/tsconfig.test.json @@ -1,11 +1,11 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*"], + "include": ["test/**/*", "vite.config.ts"], "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["node", "jest"] + "types": ["node", "jest", "vitest"] // other package-specific, test-specific options } diff --git a/packages/browser-utils/vite.config.ts b/packages/browser-utils/vite.config.ts new file mode 100644 index 000000000000..a5523c61f601 --- /dev/null +++ b/packages/browser-utils/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +import baseConfig from '../../vite/vite.config'; + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + }, +}); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 72321f9a86c3..c5bbd04d536f 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,4 @@ -import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; -import { getBodyString } from '@sentry-internal/replay'; +import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, diff --git a/packages/core/src/utils-hoist/index.ts b/packages/core/src/utils-hoist/index.ts index a593b72e73ad..4141b4583e35 100644 --- a/packages/core/src/utils-hoist/index.ts +++ b/packages/core/src/utils-hoist/index.ts @@ -38,6 +38,8 @@ export { } from './is'; export { isBrowser } from './isBrowser'; export { CONSOLE_LEVELS, consoleSandbox, logger, originalConsoleMethods } from './logger'; +export type { Logger } from './logger'; + export { addContextToFrame, addExceptionMechanism, diff --git a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts index fe7b5656baa9..d8f181558275 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -1,10 +1,10 @@ -import { setTimeout } from '@sentry-internal/browser-utils'; +import { getBodyString, setTimeout } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import type { Breadcrumb, FetchBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { FetchHint, - NetworkMetaWarning, ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, @@ -17,7 +17,6 @@ import { buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, - getBodyString, makeNetworkReplayBreadcrumb, mergeWarning, parseContentLengthHeader, @@ -118,7 +117,7 @@ function _getRequestInfo( // We only want to transmit string or string-like bodies const requestBody = _getFetchRequestArgBody(input); - const [bodyStr, warning] = getBodyString(requestBody); + const [bodyStr, warning] = getBodyString(requestBody, logger); const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); if (warning) { diff --git a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts index 3197b6839e74..3897936af70c 100644 --- a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts @@ -1,16 +1,14 @@ import { dropUndefinedKeys, stringMatchesSomePattern } from '@sentry/core'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import { NETWORK_BODY_MAX_SIZE, WINDOW } from '../../constants'; -import { DEBUG_BUILD } from '../../debug-build'; import type { NetworkBody, - NetworkMetaWarning, NetworkRequestData, ReplayNetworkRequestData, ReplayNetworkRequestOrResponse, ReplayPerformanceEntry, } from '../../types'; -import { logger } from '../../util/logger'; /** Get the size of a body. */ export function getBodySize(body: RequestInit['body']): number | undefined { @@ -60,34 +58,6 @@ export function parseContentLengthHeader(header: string | null | undefined): num return isNaN(size) ? undefined : size; } -/** Get the string representation of a body. */ -export function getBodyString(body: unknown): [string | undefined, NetworkMetaWarning?] { - try { - if (typeof body === 'string') { - return [body]; - } - - if (body instanceof URLSearchParams) { - return [body.toString()]; - } - - if (body instanceof FormData) { - return [_serializeFormData(body)]; - } - - if (!body) { - return [undefined]; - } - } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to serialize body', body); - return [undefined, 'BODY_PARSE_ERROR']; - } - - DEBUG_BUILD && logger.info('Skipping network body because of body type', body); - - return [undefined, 'UNPARSEABLE_BODY_TYPE']; -} - /** Merge a warning into an existing network request/response. */ export function mergeWarning( info: ReplayNetworkRequestOrResponse | undefined, diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index e05dda8e29eb..ed485f5c2d0c 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,14 +1,9 @@ -import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import type { - NetworkMetaWarning, - ReplayContainer, - ReplayNetworkOptions, - ReplayNetworkRequestData, - XhrHint, -} from '../../types'; +import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, XhrHint } from '../../types'; import { logger } from '../../util/logger'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { @@ -16,7 +11,6 @@ import { buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, - getBodyString, makeNetworkReplayBreadcrumb, mergeWarning, parseContentLengthHeader, @@ -111,7 +105,7 @@ function _prepareXhrData( : {}; const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); - const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input) : [undefined]; + const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, logger) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; const request = buildNetworkRequestOrResponse(networkRequestHeaders, requestBodySize, requestBody); diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index 723ef811862f..2919c44c08f2 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -18,4 +18,3 @@ export type { } from './types'; export { getReplay } from './util/getReplay'; -export { getBodyString } from './coreHandlers/util/networkUtils'; diff --git a/packages/replay-internal/src/types/request.ts b/packages/replay-internal/src/types/request.ts index c04b57409d0c..fd24c8bc16ba 100644 --- a/packages/replay-internal/src/types/request.ts +++ b/packages/replay-internal/src/types/request.ts @@ -1,16 +1,10 @@ +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; + type JsonObject = Record; type JsonArray = unknown[]; export type NetworkBody = JsonObject | JsonArray | string; -export type NetworkMetaWarning = - | 'MAYBE_JSON_TRUNCATED' - | 'TEXT_TRUNCATED' - | 'URL_SKIPPED' - | 'BODY_PARSE_ERROR' - | 'BODY_PARSE_TIMEOUT' - | 'UNPARSEABLE_BODY_TYPE'; - interface NetworkMeta { warnings?: NetworkMetaWarning[]; } diff --git a/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts b/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts index 00db91815a5f..f3ad45e10918 100644 --- a/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/util/networkUtils.test.ts @@ -8,7 +8,6 @@ import { NETWORK_BODY_MAX_SIZE } from '../../../../src/constants'; import { buildNetworkRequestOrResponse, getBodySize, - getBodyString, getFullUrl, parseContentLengthHeader, } from '../../../../src/coreHandlers/util/networkUtils'; @@ -252,39 +251,4 @@ describe('Unit | coreHandlers | util | networkUtils', () => { expect(actual).toBe(expected); }); }); - - describe('getBodyString', () => { - it('works with a string', () => { - const actual = getBodyString('abc'); - expect(actual).toEqual(['abc']); - }); - - it('works with URLSearchParams', () => { - const body = new URLSearchParams(); - body.append('name', 'Anne'); - body.append('age', '32'); - const actual = getBodyString(body); - expect(actual).toEqual(['name=Anne&age=32']); - }); - - it('works with FormData', () => { - const body = new FormData(); - body.append('name', 'Anne'); - body.append('age', '32'); - const actual = getBodyString(body); - expect(actual).toEqual(['name=Anne&age=32']); - }); - - it('works with empty data', () => { - const body = undefined; - const actual = getBodyString(body); - expect(actual).toEqual([undefined]); - }); - - it('works with other type of data', () => { - const body = {}; - const actual = getBodyString(body); - expect(actual).toEqual([undefined, 'UNPARSEABLE_BODY_TYPE']); - }); - }); }); From ee8b1d799f55cac9bdcb9ed3cd3e8812ea6e790b Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:28:09 -0500 Subject: [PATCH 47/65] refactor(core): Move `hasProp` Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 6 +---- packages/core/src/index.ts | 1 + packages/core/src/utils/hasProp.ts | 6 +++++ packages/core/test/lib/utils/hasProp.test.ts | 27 +++++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/utils/hasProp.ts create mode 100644 packages/core/test/lib/utils/hasProp.test.ts diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index c5bbd04d536f..7bc71b1949f6 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_URL_FULL, defineIntegration, + hasProp, spanToJSON, } from '@sentry/core'; import type { Client, IntegrationFn } from '@sentry/core'; @@ -136,11 +137,6 @@ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | return body; } -// Duplicate from deprecated @sentry-utils/src/instrument/fetch.ts -function hasProp(obj: unknown, prop: T): obj is Record { - return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; -} - /** * Parses the fetch arguments to extract the request payload. * Exported for tests only. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2c89d0e8a60b..26b46a719216 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -96,6 +96,7 @@ export { extractQueryParamsFromUrl, headersToDict, } from './utils/request'; +export { hasProp } from './utils/hasProp'; export { DEFAULT_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; diff --git a/packages/core/src/utils/hasProp.ts b/packages/core/src/utils/hasProp.ts new file mode 100644 index 000000000000..542c5239b496 --- /dev/null +++ b/packages/core/src/utils/hasProp.ts @@ -0,0 +1,6 @@ +/** + * A more comprehensive key property check. + */ +export function hasProp(obj: unknown, prop: T): obj is Record { + return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; +} diff --git a/packages/core/test/lib/utils/hasProp.test.ts b/packages/core/test/lib/utils/hasProp.test.ts new file mode 100644 index 000000000000..256ed163b305 --- /dev/null +++ b/packages/core/test/lib/utils/hasProp.test.ts @@ -0,0 +1,27 @@ +import { hasProp } from '../../../src/utils/hasProp'; + +describe('hasProp', () => { + it('should return true if the object has the provided property', () => { + const obj = { a: 1 }; + const result = hasProp(obj, 'a'); + expect(result).toBe(true); + }); + + it('should return false if the object does not have the provided property', () => { + const obj = { a: 1 }; + const result = hasProp(obj, 'b'); + expect(result).toBe(false); + }); + + it('should return false if the object is null', () => { + const obj = null; + const result = hasProp(obj, 'a'); + expect(result).toBe(false); + }); + + it('should return false if the object is undefined', () => { + const obj = undefined; + const result = hasProp(obj, 'a'); + expect(result).toBe(false); + }); +}); From 6d0d3c5bdba796adf1b540da6f0da6649a93b9b0 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:36:46 -0500 Subject: [PATCH 48/65] chore: Merge import statements Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser/src/integrations/graphqlClient.ts | 3 ++- packages/core/src/client.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 7bc71b1949f6..7c839b044928 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -6,10 +6,11 @@ import { SEMANTIC_ATTRIBUTE_URL_FULL, defineIntegration, hasProp, + isString, spanToJSON, + stringMatchesSomePattern, } from '@sentry/core'; import type { Client, IntegrationFn } from '@sentry/core'; -import { isString, stringMatchesSomePattern } from '@sentry/core'; interface GraphQLClientOptions { endpoints: Array; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index df3a168fbeda..3657d56c19d9 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -611,7 +611,7 @@ export abstract class Client { /** * A hook that is called when the client is flushing - * @returns A function that, when executed, removes the registered callback. + * @returns {() => void} A function that, when executed, removes the registered callback. */ public on(hook: 'flush', callback: () => void): () => void; From db865bfe549c54c0a2a4beb1eba6157295f02058 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:11:20 -0500 Subject: [PATCH 49/65] refactor(browser-utils): Move `FetchHint` and `XhrHint` Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/src/index.ts | 4 +-- packages/browser-utils/src/types.ts | 17 +++++++++++ .../browser/src/integrations/breadcrumbs.ts | 2 +- .../browser/src/integrations/graphqlClient.ts | 6 ++-- packages/browser/src/tracing/request.ts | 2 +- packages/core/src/client.ts | 28 ++++++++----------- packages/core/src/fetch.ts | 12 ++++---- .../coreHandlers/handleNetworkBreadcrumbs.ts | 3 +- .../src/coreHandlers/util/fetchUtils.ts | 3 +- .../src/coreHandlers/util/xhrUtils.ts | 4 +-- packages/replay-internal/src/index.ts | 2 -- packages/replay-internal/src/types/replay.ts | 23 +-------------- 12 files changed, 47 insertions(+), 59 deletions(-) diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index c31a3b78f9c8..4737a2b47342 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -25,6 +25,6 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export type { NetworkMetaWarning } from './types'; - export { getBodyString } from './networkUtils'; + +export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/types.ts b/packages/browser-utils/src/types.ts index 19f40156bb9a..f2d19dc2e561 100644 --- a/packages/browser-utils/src/types.ts +++ b/packages/browser-utils/src/types.ts @@ -1,3 +1,9 @@ +import type { + FetchBreadcrumbHint, + HandlerDataFetch, + SentryWrappedXMLHttpRequest, + XhrBreadcrumbHint, +} from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & @@ -12,3 +18,14 @@ export type NetworkMetaWarning = | 'BODY_PARSE_ERROR' | 'BODY_PARSE_TIMEOUT' | 'UNPARSEABLE_BODY_TYPE'; + +type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; + +export type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; +export type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 59a6a418c66c..bec6fbff019e 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -6,6 +6,7 @@ import { addHistoryInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; +import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import type { Breadcrumb, Client, @@ -37,7 +38,6 @@ import { severityLevelFromString, } from '@sentry/core'; -import type { FetchHint, XhrHint } from '@sentry-internal/replay'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 7c839b044928..68519cb5ec9e 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,5 +1,5 @@ import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; -import type { FetchHint, XhrHint } from '@sentry-internal/replay'; +import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -63,7 +63,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = getRequestPayloadXhrOrFetch(hint); + const payload = getRequestPayloadXhrOrFetch(hint as XhrHint | FetchHint); if (isTracedGraphqlEndpoint && payload) { const graphqlBody = getGraphQLRequestPayload(payload); @@ -90,7 +90,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient const { endpoints } = options; const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); - const payload = getRequestPayloadXhrOrFetch(handlerData); + const payload = getRequestPayloadXhrOrFetch(handlerData as XhrHint | FetchHint); if (isTracedGraphqlEndpoint && data && payload) { const graphqlBody = getGraphQLRequestPayload(payload); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index e979d01f3795..f249d04a53ec 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -3,7 +3,7 @@ import { addPerformanceInstrumentationHandler, addXhrInstrumentationHandler, } from '@sentry-internal/browser-utils'; -import type { XhrHint } from '@sentry-internal/replay'; +import type { XhrHint } from '@sentry-internal/browser-utils'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 3657d56c19d9..5dfb28d76c01 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -15,13 +15,11 @@ import type { EventProcessor, FeedbackEvent, FetchBreadcrumbHint, - HandlerDataFetch, Integration, MonitorConfig, Outcome, ParameterizedString, SdkMetadata, - SentryWrappedXMLHttpRequest, Session, SessionAggregates, SeverityLevel, @@ -65,17 +63,6 @@ import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; -type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; - -export type XhrHint = XhrBreadcrumbHint & { - xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; - input?: RequestBody; -}; -export type FetchHint = FetchBreadcrumbHint & { - input: HandlerDataFetch['args']; - response: Response; -}; - /** * Base implementation for all JavaScript SDK clients. * @@ -598,7 +585,10 @@ export abstract class Client { * A hook for GraphQL client integration to enhance a span with request data. * @returns A function that, when executed, removes the registered callback. */ - public on(hook: 'beforeOutgoingRequestSpan', callback: (span: Span, hint: XhrHint | FetchHint) => void): () => void; + public on( + hook: 'beforeOutgoingRequestSpan', + callback: (span: Span, hint: XhrBreadcrumbHint | FetchBreadcrumbHint) => void, + ): () => void; /** * A hook for GraphQL client integration to enhance a breadcrumb with request data. @@ -606,7 +596,7 @@ export abstract class Client { */ public on( hook: 'beforeOutgoingRequestBreadcrumb', - callback: (breadcrumb: Breadcrumb, hint: XhrHint | FetchHint) => void, + callback: (breadcrumb: Breadcrumb, hint: XhrBreadcrumbHint | FetchBreadcrumbHint) => void, ): () => void; /** @@ -742,12 +732,16 @@ export abstract class Client { /** * Emit a hook event for GraphQL client integration to enhance a span with request data. */ - public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void; + public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrBreadcrumbHint | FetchBreadcrumbHint): void; /** * Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data. */ - public emit(hook: 'beforeOutgoingRequestBreadcrumb', breadcrumb: Breadcrumb, hint: XhrHint | FetchHint): void; + public emit( + hook: 'beforeOutgoingRequestBreadcrumb', + breadcrumb: Breadcrumb, + hint: XhrBreadcrumbHint | FetchBreadcrumbHint, + ): void; /** * Emit a hook event for client flush diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 048b03a74e6d..bc6e54e3cc00 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,9 +1,8 @@ -import type { FetchHint } from './client'; import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; -import type { HandlerDataFetch, Span, SpanOrigin } from './types-hoist'; +import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanOrigin } from './types-hoist'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage'; import { isInstanceOf } from './utils-hoist/is'; import { parseUrl } from './utils-hoist/url'; @@ -99,15 +98,16 @@ export function instrumentFetchRequest( } const client = getClient(); + if (client) { - // There's no 'input' key in HandlerDataFetch const fetchHint = { input: handlerData.args, response: handlerData.response, startTimestamp: handlerData.startTimestamp, - endTimestamp: handlerData.endTimestamp, - }; - client.emit('beforeOutgoingRequestSpan', span, fetchHint as FetchHint); + endTimestamp: handlerData.endTimestamp ?? Date.now(), + } satisfies FetchBreadcrumbHint; + + client.emit('beforeOutgoingRequestSpan', span, fetchHint); } return span; diff --git a/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts index 3b3e52d985c1..e1f3a60bc254 100644 --- a/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -1,8 +1,9 @@ +import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { getClient } from '@sentry/core'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbData, XhrBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import type { FetchHint, ReplayContainer, ReplayNetworkOptions, XhrHint } from '../types'; +import type { ReplayContainer, ReplayNetworkOptions } from '../types'; import { logger } from '../util/logger'; import { captureFetchBreadcrumbToReplay, enrichFetchBreadcrumb } from './util/fetchUtils'; import { captureXhrBreadcrumbToReplay, enrichXhrBreadcrumb } from './util/xhrUtils'; diff --git a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts index d8f181558275..349119d76a58 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -1,10 +1,9 @@ import { getBodyString, setTimeout } from '@sentry-internal/browser-utils'; -import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; +import type { FetchHint, NetworkMetaWarning } from '@sentry-internal/browser-utils'; import type { Breadcrumb, FetchBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { - FetchHint, ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index ed485f5c2d0c..6028a09232ba 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,9 +1,9 @@ import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; -import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning, XhrHint } from '@sentry-internal/browser-utils'; import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, XhrHint } from '../../types'; +import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData } from '../../types'; import { logger } from '../../util/logger'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { diff --git a/packages/replay-internal/src/index.ts b/packages/replay-internal/src/index.ts index 2919c44c08f2..c10beb30228c 100644 --- a/packages/replay-internal/src/index.ts +++ b/packages/replay-internal/src/index.ts @@ -13,8 +13,6 @@ export type { ReplaySpanFrameEvent, CanvasManagerInterface, CanvasManagerOptions, - FetchHint, - XhrHint, } from './types'; export { getReplay } from './util/getReplay'; diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 7cd4c78a21c5..a2f65421aaa1 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -1,14 +1,4 @@ -import type { - Breadcrumb, - ErrorEvent, - FetchBreadcrumbHint, - HandlerDataFetch, - ReplayRecordingData, - ReplayRecordingMode, - SentryWrappedXMLHttpRequest, - Span, - XhrBreadcrumbHint, -} from '@sentry/core'; +import type { Breadcrumb, ErrorEvent, ReplayRecordingData, ReplayRecordingMode, Span } from '@sentry/core'; import type { SKIPPED, THROTTLED } from '../util/throttle'; import type { AllPerformanceEntry, AllPerformanceEntryData, ReplayPerformanceEntry } from './performance'; @@ -501,17 +491,6 @@ export interface ReplayContainer { handleException(err: unknown): void; } -type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; - -export type XhrHint = XhrBreadcrumbHint & { - xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; - input?: RequestBody; -}; -export type FetchHint = FetchBreadcrumbHint & { - input: HandlerDataFetch['args']; - response: Response; -}; - export type ReplayNetworkRequestData = { startTimestamp: number; endTimestamp: number; From 2e8d2ae24d7892258770b5fc9154d786bf677554 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:22:19 -0500 Subject: [PATCH 50/65] chore(e2e): Replace deprecated imports Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../suites/integrations/graphqlClient/fetch/test.ts | 2 +- .../suites/integrations/graphqlClient/xhr/test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index 1d25a592f817..d923b1a41970 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { Event } from '@sentry/types'; +import type { Event } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index 6b3790c663b2..1be028abea46 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { Event } from '@sentry/types'; +import type { Event } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; From 4d06a2f91b9ce7795fd0e1edc3fb7faf86322c9a Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 29 Jan 2025 14:31:30 -0500 Subject: [PATCH 51/65] chore(browser-utils): Remove jest Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/jest.config.js | 1 - packages/browser-utils/tsconfig.test.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 packages/browser-utils/jest.config.js diff --git a/packages/browser-utils/jest.config.js b/packages/browser-utils/jest.config.js deleted file mode 100644 index 24f49ab59a4c..000000000000 --- a/packages/browser-utils/jest.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('../../jest/jest.config.js'); diff --git a/packages/browser-utils/tsconfig.test.json b/packages/browser-utils/tsconfig.test.json index 5a75500b007f..b2ccc6d8b08c 100644 --- a/packages/browser-utils/tsconfig.test.json +++ b/packages/browser-utils/tsconfig.test.json @@ -5,7 +5,7 @@ "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["node", "jest", "vitest"] + "types": ["node", "vitest"] // other package-specific, test-specific options } From 67e297117b5ebe5197eb3656815cb1aa35ad8c81 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:04:05 -0500 Subject: [PATCH 52/65] fix(browser): Change parsing the fetch req payload to allow more types other than string - Moved internal `getFetchRequestArgBody` to `browser-utils`. - Added unit tests. Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/src/index.ts | 2 +- packages/browser-utils/src/networkUtils.ts | 12 +++ .../browser-utils/test/networkUtils.test.ts | 75 +++++++++++++++++-- .../browser/src/integrations/graphqlClient.ts | 19 +---- .../test/integrations/graphqlClient.test.ts | 34 +-------- packages/core/src/index.ts | 1 - packages/core/src/utils/hasProp.ts | 6 -- packages/core/test/lib/utils/hasProp.test.ts | 27 ------- .../src/coreHandlers/util/fetchUtils.ts | 15 +--- 9 files changed, 86 insertions(+), 105 deletions(-) delete mode 100644 packages/core/src/utils/hasProp.ts delete mode 100644 packages/core/test/lib/utils/hasProp.test.ts diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index fad5772d1b96..7976aec58765 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -27,6 +27,6 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export { getBodyString } from './networkUtils'; +export { getBodyString, getFetchRequestArgBody } from './networkUtils'; export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index 4c80dbebde7f..3a5c52d118b5 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -70,3 +70,15 @@ export function getBodyString( return [undefined, 'UNPARSEABLE_BODY_TYPE']; } + +/** + * Parses the fetch arguments to extract the request payload. + * We only support getting the body from the fetch options. + */ +export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined { + if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') { + return undefined; + } + + return (fetchArgs[1] as RequestInit).body; +} diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts index 0db51a127cd8..bc21d63caab4 100644 --- a/packages/browser-utils/test/networkUtils.test.ts +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -3,15 +3,15 @@ */ import { describe, expect, it } from 'vitest'; -import { getBodyString } from '../src/networkUtils'; +import { getBodyString, getFetchRequestArgBody } from '../src/networkUtils'; describe('getBodyString', () => { - it('works with a string', () => { + it('should work with a string', () => { const actual = getBodyString('abc'); expect(actual).toEqual(['abc']); }); - it('works with URLSearchParams', () => { + it('should work with URLSearchParams', () => { const body = new URLSearchParams(); body.append('name', 'Anne'); body.append('age', '32'); @@ -19,23 +19,82 @@ describe('getBodyString', () => { expect(actual).toEqual(['name=Anne&age=32']); }); - it('works with FormData', () => { + it('should work with FormData', () => { const body = new FormData(); - body.append('name', 'Anne'); + body.append('name', 'Bob'); body.append('age', '32'); const actual = getBodyString(body); - expect(actual).toEqual(['name=Anne&age=32']); + expect(actual).toEqual(['name=Bob&age=32']); }); - it('works with empty data', () => { + it('should work with empty data', () => { const body = undefined; const actual = getBodyString(body); expect(actual).toEqual([undefined]); }); - it('works with other type of data', () => { + it('should return unparsable with other types of data', () => { const body = {}; const actual = getBodyString(body); expect(actual).toEqual([undefined, 'UNPARSEABLE_BODY_TYPE']); }); }); + +describe('getFetchRequestArgBody', () => { + describe('valid types of body', () => { + it('should work with json string', () => { + const body = { data: [1, 2, 3] }; + const jsonBody = JSON.stringify(body); + + const actual = getFetchRequestArgBody(['http://example.com', { method: 'POST', body: jsonBody }]); + expect(actual).toEqual(jsonBody); + }); + + it('should work with URLSearchParams', () => { + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); + + const actual = getFetchRequestArgBody(['http://example.com', { method: 'POST', body }]); + expect(actual).toEqual(body); + }); + + it('should work with FormData', () => { + const body = new FormData(); + body.append('name', 'Bob'); + body.append('age', '32'); + + const actual = getFetchRequestArgBody(['http://example.com', { method: 'POST', body }]); + expect(actual).toEqual(body); + }); + + it('should work with Blob', () => { + const body = new Blob(['example'], { type: 'text/plain' }); + const actual = getFetchRequestArgBody(['http://example.com', { method: 'POST', body }]); + expect(actual).toEqual(body); + }); + + it('should work with BufferSource (ArrayBufferView | ArrayBuffer)', () => { + const body = new Uint8Array([1, 2, 3]); + const actual = getFetchRequestArgBody(['http://example.com', { method: 'POST', body }]); + expect(actual).toEqual(body); + }); + }); + + describe('does not work without body passed as the second option', () => { + it.each([ + ['string URL only', ['http://example.com'], undefined], + ['URL object only', [new URL('http://example.com')], undefined], + ['Request URL only', [{ url: 'http://example.com' }], undefined], + [ + 'body in first arg', + [{ url: 'http://example.com', method: 'POST', body: JSON.stringify({ data: [1, 2, 3] }) }], + undefined, + ], + ])('%s', (_name, args, expected) => { + const actual = getFetchRequestArgBody(args); + + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 68519cb5ec9e..18cd79ec962a 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,11 +1,10 @@ -import { SENTRY_XHR_DATA_KEY, getBodyString } from '@sentry-internal/browser-utils'; +import { SENTRY_XHR_DATA_KEY, getBodyString, getFetchRequestArgBody } from '@sentry-internal/browser-utils'; import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_URL_FULL, defineIntegration, - hasProp, isString, spanToJSON, stringMatchesSomePattern, @@ -131,27 +130,13 @@ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | const sentryXhrData = hint.xhr[SENTRY_XHR_DATA_KEY]; body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; } else { - const sentryFetchData = parseFetchPayload(hint.input); + const sentryFetchData = getFetchRequestArgBody(hint.input); body = getBodyString(sentryFetchData)[0]; } return body; } -/** - * Parses the fetch arguments to extract the request payload. - * Exported for tests only. - */ -export function parseFetchPayload(fetchArgs: unknown[]): string | undefined { - if (fetchArgs.length === 2) { - const options = fetchArgs[1]; - return hasProp(options, 'body') ? String(options.body) : undefined; - } - - const arg = fetchArgs[0]; - return hasProp(arg, 'body') ? String(arg.body) : undefined; -} - /** * Extract the name and type of the operation from the GraphQL query. * Exported for tests only. diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index e83d874beb06..d79ab1ea1800 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -5,46 +5,14 @@ import { describe, expect, test } from 'vitest'; import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; -import type { FetchHint, XhrHint } from '@sentry-internal/replay'; +import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { getGraphQLRequestPayload, getRequestPayloadXhrOrFetch, - parseFetchPayload, parseGraphQLQuery, } from '../../src/integrations/graphqlClient'; describe('GraphqlClient', () => { - describe('parseFetchPayload', () => { - const data = [1, 2, 3]; - const jsonData = '{"data":[1,2,3]}'; - - test.each([ - ['string URL only', ['http://example.com'], undefined], - ['URL object only', [new URL('http://example.com')], undefined], - ['Request URL only', [{ url: 'http://example.com' }], undefined], - [ - 'Request URL & method only', - [{ url: 'http://example.com', method: 'post', body: JSON.stringify({ data }) }], - jsonData, - ], - ['string URL & options', ['http://example.com', { method: 'post', body: JSON.stringify({ data }) }], jsonData], - [ - 'URL object & options', - [new URL('http://example.com'), { method: 'post', body: JSON.stringify({ data }) }], - jsonData, - ], - [ - 'Request URL & options', - [{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify({ data }) }], - jsonData, - ], - ])('%s', (_name, args, expected) => { - const actual = parseFetchPayload(args as unknown[]); - - expect(actual).toEqual(expected); - }); - }); - describe('parseGraphQLQuery', () => { const queryOne = `query Test { items { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26b46a719216..2c89d0e8a60b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -96,7 +96,6 @@ export { extractQueryParamsFromUrl, headersToDict, } from './utils/request'; -export { hasProp } from './utils/hasProp'; export { DEFAULT_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; diff --git a/packages/core/src/utils/hasProp.ts b/packages/core/src/utils/hasProp.ts deleted file mode 100644 index 542c5239b496..000000000000 --- a/packages/core/src/utils/hasProp.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * A more comprehensive key property check. - */ -export function hasProp(obj: unknown, prop: T): obj is Record { - return !!obj && typeof obj === 'object' && !!(obj as Record)[prop]; -} diff --git a/packages/core/test/lib/utils/hasProp.test.ts b/packages/core/test/lib/utils/hasProp.test.ts deleted file mode 100644 index 256ed163b305..000000000000 --- a/packages/core/test/lib/utils/hasProp.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { hasProp } from '../../../src/utils/hasProp'; - -describe('hasProp', () => { - it('should return true if the object has the provided property', () => { - const obj = { a: 1 }; - const result = hasProp(obj, 'a'); - expect(result).toBe(true); - }); - - it('should return false if the object does not have the provided property', () => { - const obj = { a: 1 }; - const result = hasProp(obj, 'b'); - expect(result).toBe(false); - }); - - it('should return false if the object is null', () => { - const obj = null; - const result = hasProp(obj, 'a'); - expect(result).toBe(false); - }); - - it('should return false if the object is undefined', () => { - const obj = undefined; - const result = hasProp(obj, 'a'); - expect(result).toBe(false); - }); -}); diff --git a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts index 349119d76a58..e66bd9c26849 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -1,4 +1,4 @@ -import { getBodyString, setTimeout } from '@sentry-internal/browser-utils'; +import { getBodyString, getFetchRequestArgBody, setTimeout } from '@sentry-internal/browser-utils'; import type { FetchHint, NetworkMetaWarning } from '@sentry-internal/browser-utils'; import type { Breadcrumb, FetchBreadcrumbData } from '@sentry/core'; @@ -55,7 +55,7 @@ export function enrichFetchBreadcrumb( ): void { const { input, response } = hint; - const body = input ? _getFetchRequestArgBody(input) : undefined; + const body = input ? getFetchRequestArgBody(input) : undefined; const reqSize = getBodySize(body); const resSize = response ? parseContentLengthHeader(response.headers.get('content-length')) : undefined; @@ -115,7 +115,7 @@ function _getRequestInfo( } // We only want to transmit string or string-like bodies - const requestBody = _getFetchRequestArgBody(input); + const requestBody = getFetchRequestArgBody(input); const [bodyStr, warning] = getBodyString(requestBody, logger); const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); @@ -216,15 +216,6 @@ async function _parseFetchResponseBody(response: Response): Promise<[string | un } } -function _getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined { - // We only support getting the body from the fetch options - if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') { - return undefined; - } - - return (fetchArgs[1] as RequestInit).body; -} - function getAllHeaders(headers: Headers, allowedHeaders: string[]): Record { const allHeaders: Record = {}; From 200d81040ffc1ae1353b6efcee0cdf9fe8ce10c2 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:13:40 -0500 Subject: [PATCH 53/65] ref(browser-utils): Use undefined assertion Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/test/networkUtils.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts index bc21d63caab4..ee4199b92379 100644 --- a/packages/browser-utils/test/networkUtils.test.ts +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -83,18 +83,14 @@ describe('getFetchRequestArgBody', () => { describe('does not work without body passed as the second option', () => { it.each([ - ['string URL only', ['http://example.com'], undefined], - ['URL object only', [new URL('http://example.com')], undefined], - ['Request URL only', [{ url: 'http://example.com' }], undefined], - [ - 'body in first arg', - [{ url: 'http://example.com', method: 'POST', body: JSON.stringify({ data: [1, 2, 3] }) }], - undefined, - ], - ])('%s', (_name, args, expected) => { + ['string URL only', ['http://example.com']], + ['URL object only', [new URL('http://example.com')]], + ['Request URL only', [{ url: 'http://example.com' }]], + ['body in first arg', [{ url: 'http://example.com', method: 'POST', body: JSON.stringify({ data: [1, 2, 3] }) }]], + ])('%s', (_name, args) => { const actual = getFetchRequestArgBody(args); - expect(actual).toEqual(expected); + expect(actual).toBeUndefined(); }); }); }); From 514ef471f6fb568acd1895c8ad57d3b069add70e Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 30 Jan 2025 18:39:20 -0500 Subject: [PATCH 54/65] ref(replay): Extend ReplayLogger from core Logger - Moved replay-specific `_serializeFormData` to `browser-utils` and added test. Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/src/index.ts | 2 +- packages/browser-utils/src/networkUtils.ts | 56 +++++-------------- .../browser-utils/test/networkUtils.test.ts | 13 ++++- .../src/coreHandlers/util/networkUtils.ts | 12 +--- packages/replay-internal/src/util/logger.ts | 5 +- 5 files changed, 32 insertions(+), 56 deletions(-) diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 7976aec58765..f66446ea5159 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -27,6 +27,6 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export { getBodyString, getFetchRequestArgBody } from './networkUtils'; +export { getBodyString, getFetchRequestArgBody, serializeFormData } from './networkUtils'; export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index 3a5c52d118b5..e8df09e75bad 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -1,45 +1,22 @@ -import type { ConsoleLevel, Logger } from '@sentry/core'; +import { logger } from '@sentry/core'; +import type { Logger } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import type { NetworkMetaWarning } from './types'; -type ReplayConsoleLevels = Extract; -type LoggerMethod = (...args: unknown[]) => void; -type LoggerConsoleMethods = Record; - -interface LoggerConfig { - captureExceptions: boolean; - traceInternals: boolean; -} - -// Duplicate from replay-internal -interface ReplayLogger extends LoggerConsoleMethods { - /** - * Calls `logger.info` but saves breadcrumb in the next tick due to race - * conditions before replay is initialized. - */ - infoTick: LoggerMethod; - /** - * Captures exceptions (`Error`) if "capture internal exceptions" is enabled - */ - exception: LoggerMethod; - /** - * Configures the logger with additional debugging behavior - */ - setConfig(config: Partial): void; -} - -function _serializeFormData(formData: FormData): string { - // This is a bit simplified, but gives us a decent estimate - // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' +/** + * Serializes FormData. + * + * This is a bit simplified, but gives us a decent estimate. + * This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13'. + * + */ +export function serializeFormData(formData: FormData): string { // @ts-expect-error passing FormData to URLSearchParams actually works return new URLSearchParams(formData).toString(); } /** Get the string representation of a body. */ -export function getBodyString( - body: unknown, - logger?: Logger | ReplayLogger, -): [string | undefined, NetworkMetaWarning?] { +export function getBodyString(body: unknown, _logger: Logger = logger): [string | undefined, NetworkMetaWarning?] { try { if (typeof body === 'string') { return [body]; @@ -50,23 +27,18 @@ export function getBodyString( } if (body instanceof FormData) { - return [_serializeFormData(body)]; + return [serializeFormData(body)]; } if (!body) { return [undefined]; } } catch (error) { - // RelayLogger - if (DEBUG_BUILD && logger && 'exception' in logger) { - logger.exception(error, 'Failed to serialize body', body); - } else if (DEBUG_BUILD && logger) { - logger.error(error, 'Failed to serialize body', body); - } + DEBUG_BUILD && logger.error(error, 'Failed to serialize body', body); return [undefined, 'BODY_PARSE_ERROR']; } - DEBUG_BUILD && logger?.info('Skipping network body because of body type', body); + DEBUG_BUILD && logger.info('Skipping network body because of body type', body); return [undefined, 'UNPARSEABLE_BODY_TYPE']; } diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts index ee4199b92379..adca7de1ac2d 100644 --- a/packages/browser-utils/test/networkUtils.test.ts +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from 'vitest'; -import { getBodyString, getFetchRequestArgBody } from '../src/networkUtils'; +import { getBodyString, getFetchRequestArgBody, serializeFormData } from '../src/networkUtils'; describe('getBodyString', () => { it('should work with a string', () => { @@ -94,3 +94,14 @@ describe('getFetchRequestArgBody', () => { }); }); }); + +describe('serializeFormData', () => { + it('works with FormData', () => { + const formData = new FormData(); + formData.append('name', 'Anne Smith'); + formData.append('age', '13'); + + const actual = serializeFormData(formData); + expect(actual).toBe('name=Anne+Smith&age=13'); + }); +}); diff --git a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts index 3897936af70c..c626b4a2b7d6 100644 --- a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts @@ -1,6 +1,7 @@ +import { serializeFormData } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import { dropUndefinedKeys, stringMatchesSomePattern } from '@sentry/core'; -import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import { NETWORK_BODY_MAX_SIZE, WINDOW } from '../../constants'; import type { NetworkBody, @@ -28,7 +29,7 @@ export function getBodySize(body: RequestInit['body']): number | undefined { } if (body instanceof FormData) { - const formDataStr = _serializeFormData(body); + const formDataStr = serializeFormData(body); return textEncoder.encode(formDataStr).length; } @@ -170,13 +171,6 @@ export function getAllowedHeaders(headers: Record, allowedHeader }, {}); } -function _serializeFormData(formData: FormData): string { - // This is a bit simplified, but gives us a decent estimate - // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' - // @ts-expect-error passing FormData to URLSearchParams actually works - return new URLSearchParams(formData).toString(); -} - function normalizeNetworkBody(body: string | undefined): { body: NetworkBody | undefined; warnings?: NetworkMetaWarning[]; diff --git a/packages/replay-internal/src/util/logger.ts b/packages/replay-internal/src/util/logger.ts index adf3130883dd..46da5b40ad70 100644 --- a/packages/replay-internal/src/util/logger.ts +++ b/packages/replay-internal/src/util/logger.ts @@ -1,4 +1,4 @@ -import type { ConsoleLevel, SeverityLevel } from '@sentry/core'; +import type { ConsoleLevel, Logger, SeverityLevel } from '@sentry/core'; import { addBreadcrumb, captureException, logger as coreLogger, severityLevelFromString } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; @@ -7,14 +7,13 @@ const CONSOLE_LEVELS: readonly ReplayConsoleLevels[] = ['info', 'warn', 'error', const PREFIX = '[Replay] '; type LoggerMethod = (...args: unknown[]) => void; -type LoggerConsoleMethods = Record; interface LoggerConfig { captureExceptions: boolean; traceInternals: boolean; } -interface ReplayLogger extends LoggerConsoleMethods { +interface ReplayLogger extends Logger { /** * Calls `logger.info` but saves breadcrumb in the next tick due to race * conditions before replay is initialized. From a3a36e3b7284212b398b44c08d08742858a0dfe7 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 30 Jan 2025 18:54:58 -0500 Subject: [PATCH 55/65] ref(browser-utils): Revert `getBodyString` tests to original case Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser-utils/src/networkUtils.ts | 1 + .../browser-utils/test/networkUtils.test.ts | 24 +++++++++---------- .../browser/src/integrations/graphqlClient.ts | 7 +++--- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index e8df09e75bad..26afa59f1591 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -45,6 +45,7 @@ export function getBodyString(body: unknown, _logger: Logger = logger): [string /** * Parses the fetch arguments to extract the request payload. + * * We only support getting the body from the fetch options. */ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined { diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts index adca7de1ac2d..84d1c635e844 100644 --- a/packages/browser-utils/test/networkUtils.test.ts +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -6,12 +6,12 @@ import { describe, expect, it } from 'vitest'; import { getBodyString, getFetchRequestArgBody, serializeFormData } from '../src/networkUtils'; describe('getBodyString', () => { - it('should work with a string', () => { + it('works with a string', () => { const actual = getBodyString('abc'); expect(actual).toEqual(['abc']); }); - it('should work with URLSearchParams', () => { + it('works with URLSearchParams', () => { const body = new URLSearchParams(); body.append('name', 'Anne'); body.append('age', '32'); @@ -19,21 +19,21 @@ describe('getBodyString', () => { expect(actual).toEqual(['name=Anne&age=32']); }); - it('should work with FormData', () => { + it('works with FormData', () => { const body = new FormData(); - body.append('name', 'Bob'); + body.append('name', 'Anne'); body.append('age', '32'); const actual = getBodyString(body); - expect(actual).toEqual(['name=Bob&age=32']); + expect(actual).toEqual(['name=Anne&age=32']); }); - it('should work with empty data', () => { + it('works with empty data', () => { const body = undefined; const actual = getBodyString(body); expect(actual).toEqual([undefined]); }); - it('should return unparsable with other types of data', () => { + it('works with other type of data', () => { const body = {}; const actual = getBodyString(body); expect(actual).toEqual([undefined, 'UNPARSEABLE_BODY_TYPE']); @@ -42,7 +42,7 @@ describe('getBodyString', () => { describe('getFetchRequestArgBody', () => { describe('valid types of body', () => { - it('should work with json string', () => { + it('works with json string', () => { const body = { data: [1, 2, 3] }; const jsonBody = JSON.stringify(body); @@ -50,7 +50,7 @@ describe('getFetchRequestArgBody', () => { expect(actual).toEqual(jsonBody); }); - it('should work with URLSearchParams', () => { + it('works with URLSearchParams', () => { const body = new URLSearchParams(); body.append('name', 'Anne'); body.append('age', '32'); @@ -59,7 +59,7 @@ describe('getFetchRequestArgBody', () => { expect(actual).toEqual(body); }); - it('should work with FormData', () => { + it('works with FormData', () => { const body = new FormData(); body.append('name', 'Bob'); body.append('age', '32'); @@ -68,13 +68,13 @@ describe('getFetchRequestArgBody', () => { expect(actual).toEqual(body); }); - it('should work with Blob', () => { + it('works with Blob', () => { const body = new Blob(['example'], { type: 'text/plain' }); const actual = getFetchRequestArgBody(['http://example.com', { method: 'POST', body }]); expect(actual).toEqual(body); }); - it('should work with BufferSource (ArrayBufferView | ArrayBuffer)', () => { + it('works with BufferSource (ArrayBufferView | ArrayBuffer)', () => { const body = new Uint8Array([1, 2, 3]); const actual = getFetchRequestArgBody(['http://example.com', { method: 'POST', body }]); expect(actual).toEqual(body); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 18cd79ec962a..adadd291fce0 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -15,7 +15,7 @@ interface GraphQLClientOptions { endpoints: Array; } -// Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body +/** Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body */ interface GraphQLRequestPayload { query: string; operationName?: string; @@ -118,7 +118,8 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { } /** - * Get the request body/payload based on the shape of the hint + * Get the request body/payload based on the shape of the hint. + * * Exported for tests only. */ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { @@ -139,8 +140,8 @@ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | /** * Extract the name and type of the operation from the GraphQL query. + * * Exported for tests only. - * @param query */ export function parseGraphQLQuery(query: string): GraphQLOperation { const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; From 22c2c923149024c1df031892908ffecfa06512d9 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 30 Jan 2025 19:11:56 -0500 Subject: [PATCH 56/65] fix(core): Make `endTimestamp` of `FetchBreadcrumbHint` optional Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- packages/browser/src/integrations/graphqlClient.ts | 4 ++-- packages/core/src/fetch.ts | 2 +- packages/core/src/types-hoist/breadcrumb.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index adadd291fce0..c7678b1759c4 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -119,7 +119,7 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { /** * Get the request body/payload based on the shape of the hint. - * + * * Exported for tests only. */ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { @@ -140,7 +140,7 @@ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | /** * Extract the name and type of the operation from the GraphQL query. - * + * * Exported for tests only. */ export function parseGraphQLQuery(query: string): GraphQLOperation { diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index bc6e54e3cc00..640e90236bf6 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -104,7 +104,7 @@ export function instrumentFetchRequest( input: handlerData.args, response: handlerData.response, startTimestamp: handlerData.startTimestamp, - endTimestamp: handlerData.endTimestamp ?? Date.now(), + endTimestamp: handlerData.endTimestamp, } satisfies FetchBreadcrumbHint; client.emit('beforeOutgoingRequestSpan', span, fetchHint); diff --git a/packages/core/src/types-hoist/breadcrumb.ts b/packages/core/src/types-hoist/breadcrumb.ts index 464df180e59d..1c5bef4bc49a 100644 --- a/packages/core/src/types-hoist/breadcrumb.ts +++ b/packages/core/src/types-hoist/breadcrumb.ts @@ -94,7 +94,7 @@ export interface FetchBreadcrumbHint { data?: unknown; response?: unknown; startTimestamp: number; - endTimestamp: number; + endTimestamp?: number; } export interface XhrBreadcrumbHint { From e7876d7de3a1d0b2fdef444229a15cdaa9edb28e Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 30 Jan 2025 20:01:14 -0500 Subject: [PATCH 57/65] feat(browser): Add support for queries with operation name Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 22 +++++++++++++------ .../test/integrations/graphqlClient.test.ts | 14 ++++++------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index c7678b1759c4..f7f44d77142a 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -24,8 +24,8 @@ interface GraphQLRequestPayload { } interface GraphQLOperation { - operationType: string | undefined; - operationName: string | undefined; + operationType?: string; + operationName?: string; } const INTEGRATION_NAME = 'GraphQLClient'; @@ -144,14 +144,22 @@ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | * Exported for tests only. */ export function parseGraphQLQuery(query: string): GraphQLOperation { - const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; + const namedQueryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; + const unnamedQueryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)[{(]/; - const matched = query.match(queryRe); + const namedMatch = query.match(namedQueryRe); + if (namedMatch) { + return { + operationType: namedMatch[1], + operationName: namedMatch[2], + }; + } - if (matched) { + const unnamedMatch = query.match(unnamedQueryRe); + if (unnamedMatch) { return { - operationType: matched[1], - operationName: matched[2], + operationType: unnamedMatch[1], + operationName: undefined, }; } return { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index d79ab1ea1800..144bcc808e1f 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -32,12 +32,11 @@ describe('GraphqlClient', () => { } }`; - // TODO: support name-less queries - // const queryFour = ` query { - // items { - // id - // } - // }`; + const queryFour = `query { + items { + id + } + }`; test.each([ ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], @@ -47,7 +46,7 @@ describe('GraphqlClient', () => { queryThree, { operationName: 'OnTestItemAdded', operationType: 'subscription' }, ], - // TODO: ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], ])('%s', (_, input, output) => { expect(parseGraphQLQuery(input)).toEqual(output); }); @@ -64,6 +63,7 @@ describe('GraphqlClient', () => { query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', operationName: 'Test', variables: {}, + extensions: {}, }; expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); From 694da93d6729fd3950dd92af336a00df888a0ea1 Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Thu, 30 Jan 2025 20:01:14 -0500 Subject: [PATCH 58/65] feat(browser): Add support for queries without operation name Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> --- .../browser/src/integrations/graphqlClient.ts | 22 +++++++++++++------ .../test/integrations/graphqlClient.test.ts | 14 ++++++------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index c7678b1759c4..f7f44d77142a 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -24,8 +24,8 @@ interface GraphQLRequestPayload { } interface GraphQLOperation { - operationType: string | undefined; - operationName: string | undefined; + operationType?: string; + operationName?: string; } const INTEGRATION_NAME = 'GraphQLClient'; @@ -144,14 +144,22 @@ export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | * Exported for tests only. */ export function parseGraphQLQuery(query: string): GraphQLOperation { - const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; + const namedQueryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; + const unnamedQueryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)[{(]/; - const matched = query.match(queryRe); + const namedMatch = query.match(namedQueryRe); + if (namedMatch) { + return { + operationType: namedMatch[1], + operationName: namedMatch[2], + }; + } - if (matched) { + const unnamedMatch = query.match(unnamedQueryRe); + if (unnamedMatch) { return { - operationType: matched[1], - operationName: matched[2], + operationType: unnamedMatch[1], + operationName: undefined, }; } return { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index d79ab1ea1800..144bcc808e1f 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -32,12 +32,11 @@ describe('GraphqlClient', () => { } }`; - // TODO: support name-less queries - // const queryFour = ` query { - // items { - // id - // } - // }`; + const queryFour = `query { + items { + id + } + }`; test.each([ ['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }], @@ -47,7 +46,7 @@ describe('GraphqlClient', () => { queryThree, { operationName: 'OnTestItemAdded', operationType: 'subscription' }, ], - // TODO: ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], + ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }], ])('%s', (_, input, output) => { expect(parseGraphQLQuery(input)).toEqual(output); }); @@ -64,6 +63,7 @@ describe('GraphqlClient', () => { query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', operationName: 'Test', variables: {}, + extensions: {}, }; expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); From 1136d8753da46296f9d1faeb9fd7e9f04180df19 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 4 Feb 2025 12:00:31 +0100 Subject: [PATCH 59/65] feat(svelte)!: Disable component update tracking by default (#15265) Due to a change in the lifecycle of Svelte components in Svelte 5 (using Rune mode), our SDK can no longer leverage the `(before|after)Update` hooks to track component update spans. For v9, this patch therefore disables update tracking by default. --- .../tests/performance.client.test.ts | 24 ----------- .../src/routes/components/+page.svelte | 2 +- .../src/routes/components/Component1.svelte | 2 +- .../src/routes/components/Component2.svelte | 2 +- .../src/routes/components/Component3.svelte | 2 +- packages/svelte/src/config.ts | 33 ++++++--------- packages/svelte/src/constants.ts | 5 --- packages/svelte/src/performance.ts | 19 +++++---- packages/svelte/src/preprocessors.ts | 2 +- packages/svelte/src/types.ts | 7 +++- packages/svelte/test/config.test.ts | 2 +- packages/svelte/test/performance.test.ts | 40 +++++++------------ packages/svelte/test/preprocessors.test.ts | 12 +++--- 13 files changed, 57 insertions(+), 95 deletions(-) delete mode 100644 packages/svelte/src/constants.ts diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts index c31e51bf9e99..a2131287ec2e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.client.test.ts @@ -109,30 +109,6 @@ test.describe('client-specific performance events', () => { op: 'ui.svelte.init', origin: 'auto.ui.svelte', }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.svelte.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.svelte.update', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.svelte.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.svelte.update', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.svelte.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.svelte.update', - origin: 'auto.ui.svelte', - }), - expect.objectContaining({ - data: { 'sentry.op': 'ui.svelte.update', 'sentry.origin': 'auto.ui.svelte' }, - description: '', - op: 'ui.svelte.update', - origin: 'auto.ui.svelte', - }), ]), ); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/+page.svelte index eff3fa3f2e8d..3c1052bbbe9c 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/+page.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/+page.svelte @@ -5,7 +5,7 @@ import Component2 from "./Component2.svelte"; import Component3 from "./Component3.svelte"; - Sentry.trackComponent({componentName: 'components/+page'}) + Sentry.trackComponent({componentName: 'components/+page', trackUpdates: true})

Demonstrating Component Tracking

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component1.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component1.svelte index a675711e4b68..dfcf01de0b07 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component1.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component1.svelte @@ -2,7 +2,7 @@ import Component2 from "./Component2.svelte"; import {trackComponent} from '@sentry/sveltekit'; - trackComponent({componentName: 'Component1'}); + trackComponent({componentName: 'Component1', trackUpdates: true});

Howdy, I'm component 1

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component2.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component2.svelte index 2b2f38308077..1b3ad103b3b7 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component2.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component2.svelte @@ -2,7 +2,7 @@ import Component3 from "./Component3.svelte"; import {trackComponent} from '@sentry/sveltekit'; - trackComponent({componentName: 'Component2'}); + trackComponent({componentName: 'Component2', trackUpdates: true});

Howdy, I'm component 2

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component3.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component3.svelte index 9b4e028f78e7..9b813ff2c744 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component3.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/components/Component3.svelte @@ -1,6 +1,6 @@

Howdy, I'm component 3

diff --git a/packages/svelte/src/config.ts b/packages/svelte/src/config.ts index b4a0ae7d4f35..4bd11e0f659e 100644 --- a/packages/svelte/src/config.ts +++ b/packages/svelte/src/config.ts @@ -3,7 +3,7 @@ import type { PreprocessorGroup } from 'svelte/types/compiler/preprocess'; import { componentTrackingPreprocessor, defaultComponentTrackingOptions } from './preprocessors'; import type { SentryPreprocessorGroup, SentrySvelteConfigOptions, SvelteConfig } from './types'; -const DEFAULT_SENTRY_OPTIONS: SentrySvelteConfigOptions = { +const defaultSentryOptions: SentrySvelteConfigOptions = { componentTracking: defaultComponentTrackingOptions, }; @@ -20,32 +20,25 @@ export function withSentryConfig( sentryOptions?: SentrySvelteConfigOptions, ): SvelteConfig { const mergedOptions = { - ...DEFAULT_SENTRY_OPTIONS, + ...defaultSentryOptions, ...sentryOptions, + componentTracking: { + ...defaultSentryOptions.componentTracking, + ...sentryOptions?.componentTracking, + }, }; const originalPreprocessors = getOriginalPreprocessorArray(originalConfig); - // Map is insertion-order-preserving. It's important to add preprocessors - // to this map in the right order we want to see them being executed. - // see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map - const sentryPreprocessors = new Map(); - - const shouldTrackComponents = mergedOptions.componentTracking?.trackComponents; - if (shouldTrackComponents) { - const firstPassPreproc: SentryPreprocessorGroup = componentTrackingPreprocessor(mergedOptions.componentTracking); - sentryPreprocessors.set(firstPassPreproc.sentryId || '', firstPassPreproc); + // Bail if users already added the preprocessor + if (originalPreprocessors.find((p: PreprocessorGroup) => !!(p as SentryPreprocessorGroup).sentryId)) { + return originalConfig; } - // We prioritize user-added preprocessors, so we don't insert sentry processors if they - // have already been added by users. - originalPreprocessors.forEach((p: SentryPreprocessorGroup) => { - if (p.sentryId) { - sentryPreprocessors.delete(p.sentryId); - } - }); - - const mergedPreprocessors = [...sentryPreprocessors.values(), ...originalPreprocessors]; + const mergedPreprocessors = [...originalPreprocessors]; + if (mergedOptions.componentTracking.trackComponents) { + mergedPreprocessors.unshift(componentTrackingPreprocessor(mergedOptions.componentTracking)); + } return { ...originalConfig, diff --git a/packages/svelte/src/constants.ts b/packages/svelte/src/constants.ts deleted file mode 100644 index cb8255040c03..000000000000 --- a/packages/svelte/src/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const UI_SVELTE_INIT = 'ui.svelte.init'; - -export const UI_SVELTE_UPDATE = 'ui.svelte.update'; - -export const DEFAULT_COMPONENT_NAME = 'Svelte Component'; diff --git a/packages/svelte/src/performance.ts b/packages/svelte/src/performance.ts index b50be258bc58..05f33fe1cfdf 100644 --- a/packages/svelte/src/performance.ts +++ b/packages/svelte/src/performance.ts @@ -2,8 +2,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser'; import type { Span } from '@sentry/core'; import { afterUpdate, beforeUpdate, onMount } from 'svelte'; -import { startInactiveSpan } from '@sentry/core'; -import { DEFAULT_COMPONENT_NAME, UI_SVELTE_INIT, UI_SVELTE_UPDATE } from './constants'; +import { logger, startInactiveSpan } from '@sentry/core'; import type { TrackComponentOptions } from './types'; const defaultTrackComponentOptions: { @@ -12,7 +11,7 @@ const defaultTrackComponentOptions: { componentName?: string; } = { trackInit: true, - trackUpdates: true, + trackUpdates: false, }; /** @@ -29,21 +28,27 @@ export function trackComponent(options?: TrackComponentOptions): void { const customComponentName = mergedOptions.componentName; - const componentName = `<${customComponentName || DEFAULT_COMPONENT_NAME}>`; + const componentName = `<${customComponentName || 'Svelte Component'}>`; if (mergedOptions.trackInit) { recordInitSpan(componentName); } if (mergedOptions.trackUpdates) { - recordUpdateSpans(componentName); + try { + recordUpdateSpans(componentName); + } catch { + logger.warn( + "Cannot track component updates. This is likely because you're using Svelte 5 in Runes mode. Set `trackUpdates: false` in `withSentryConfig` or `trackComponent` to disable this warning.", + ); + } } } function recordInitSpan(componentName: string): void { const initSpan = startInactiveSpan({ onlyIfParent: true, - op: UI_SVELTE_INIT, + op: 'ui.svelte.init', name: componentName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.svelte' }, }); @@ -58,7 +63,7 @@ function recordUpdateSpans(componentName: string): void { beforeUpdate(() => { updateSpan = startInactiveSpan({ onlyIfParent: true, - op: UI_SVELTE_UPDATE, + op: 'ui.svelte.update', name: componentName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.svelte' }, }); diff --git a/packages/svelte/src/preprocessors.ts b/packages/svelte/src/preprocessors.ts index c966c6e00eef..67936be39858 100644 --- a/packages/svelte/src/preprocessors.ts +++ b/packages/svelte/src/preprocessors.ts @@ -6,7 +6,7 @@ import type { ComponentTrackingInitOptions, SentryPreprocessorGroup, TrackCompon export const defaultComponentTrackingOptions: Required = { trackComponents: true, trackInit: true, - trackUpdates: true, + trackUpdates: false, }; export const FIRST_PASS_COMPONENT_TRACKING_PREPROC_ID = 'FIRST_PASS_COMPONENT_TRACKING_PREPROCESSOR'; diff --git a/packages/svelte/src/types.ts b/packages/svelte/src/types.ts index 8079019d8568..ff79920ab9a4 100644 --- a/packages/svelte/src/types.ts +++ b/packages/svelte/src/types.ts @@ -29,7 +29,7 @@ export type SpanOptions = { * onMount lifecycle hook. This span tells how long it takes a component * to be created and inserted into the DOM. * - * Defaults to true if component tracking is enabled + * @default `true` if component tracking is enabled */ trackInit?: boolean; @@ -37,7 +37,10 @@ export type SpanOptions = { * If true, a span is recorded between a component's beforeUpdate and afterUpdate * lifecycle hooks. * - * Defaults to true if component tracking is enabled + * Caution: Component updates can only be tracked in Svelte versions prior to version 5 + * or in Svelte 5 in legacy mode (i.e. without Runes). + * + * @default `false` if component tracking is enabled */ trackUpdates?: boolean; }; diff --git a/packages/svelte/test/config.test.ts b/packages/svelte/test/config.test.ts index a8c84297082a..21f51dc66518 100644 --- a/packages/svelte/test/config.test.ts +++ b/packages/svelte/test/config.test.ts @@ -60,7 +60,7 @@ describe('withSentryConfig', () => { const wrappedConfig = withSentryConfig(originalConfig); - expect(wrappedConfig).toEqual({ ...originalConfig, preprocess: [sentryPreproc] }); + expect(wrappedConfig).toEqual({ ...originalConfig }); }); it('handles multiple wraps correctly by only adding our preprocessors once', () => { diff --git a/packages/svelte/test/performance.test.ts b/packages/svelte/test/performance.test.ts index fdd2ed089a87..64e38599cdda 100644 --- a/packages/svelte/test/performance.test.ts +++ b/packages/svelte/test/performance.test.ts @@ -9,7 +9,6 @@ import { getClient, getCurrentScope, getIsolationScope, init, startSpan } from ' import type { TransactionEvent } from '@sentry/core'; -// @ts-expect-error svelte import import DummyComponent from './components/Dummy.svelte'; const PUBLIC_DSN = 'https://username@domain/123'; @@ -37,7 +36,7 @@ describe('Sentry.trackComponent()', () => { }); }); - it('creates init and update spans on component initialization', async () => { + it('creates init spans on component initialization by default', async () => { startSpan({ name: 'outer' }, span => { expect(span).toBeDefined(); render(DummyComponent, { props: { options: {} } }); @@ -47,7 +46,7 @@ describe('Sentry.trackComponent()', () => { expect(transactions).toHaveLength(1); const transaction = transactions[0]!; - expect(transaction.spans).toHaveLength(2); + expect(transaction.spans).toHaveLength(1); const rootSpanId = transaction.contexts?.trace?.span_id; expect(rootSpanId).toBeDefined(); @@ -68,29 +67,14 @@ describe('Sentry.trackComponent()', () => { timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); - - expect(transaction.spans![1]).toEqual({ - data: { - 'sentry.op': 'ui.svelte.update', - 'sentry.origin': 'auto.ui.svelte', - }, - description: '', - op: 'ui.svelte.update', - origin: 'auto.ui.svelte', - parent_span_id: rootSpanId, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); }); - it('creates an update span, when the component is updated', async () => { + it('creates an update span, if `trackUpdates` is `true`', async () => { startSpan({ name: 'outer' }, async span => { expect(span).toBeDefined(); // first we create the component - const { component } = render(DummyComponent, { props: { options: {} } }); + const { component } = render(DummyComponent, { props: { options: { trackUpdates: true } } }); // then trigger an update // (just changing the trackUpdates prop so that we trigger an update. # @@ -175,7 +159,7 @@ describe('Sentry.trackComponent()', () => { startSpan({ name: 'outer' }, span => { expect(span).toBeDefined(); - render(DummyComponent, { props: { options: { trackInit: false } } }); + render(DummyComponent, { props: { options: { trackInit: false, trackUpdates: true } } }); }); await getClient()?.flush(); @@ -206,7 +190,13 @@ describe('Sentry.trackComponent()', () => { expect(span).toBeDefined(); render(DummyComponent, { - props: { options: { componentName: 'CustomComponentName' } }, + props: { + options: { + componentName: 'CustomComponentName', + // enabling updates to check for both span names in one test + trackUpdates: true, + }, + }, }); }); @@ -220,7 +210,7 @@ describe('Sentry.trackComponent()', () => { expect(transaction.spans![1]?.description).toEqual(''); }); - it("doesn't do anything, if there's no ongoing transaction", async () => { + it("doesn't do anything, if there's no ongoing parent span", async () => { render(DummyComponent, { props: { options: { componentName: 'CustomComponentName' } }, }); @@ -230,11 +220,11 @@ describe('Sentry.trackComponent()', () => { expect(transactions).toHaveLength(0); }); - it("doesn't record update spans, if there's no ongoing root span at that time", async () => { + it("doesn't record update spans, if there's no ongoing parent span at that time", async () => { const component = startSpan({ name: 'outer' }, span => { expect(span).toBeDefined(); - const { component } = render(DummyComponent, { props: { options: {} } }); + const { component } = render(DummyComponent, { props: { options: { trackUpdates: true } } }); return component; }); diff --git a/packages/svelte/test/preprocessors.test.ts b/packages/svelte/test/preprocessors.test.ts index f816c67e706c..b4d607e35a40 100644 --- a/packages/svelte/test/preprocessors.test.ts +++ b/packages/svelte/test/preprocessors.test.ts @@ -24,7 +24,7 @@ function expectComponentCodeToBeModified( preprocessedComponents.forEach(cmp => { const expectedFunctionCallOptions = { trackInit: options?.trackInit ?? true, - trackUpdates: options?.trackUpdates ?? true, + trackUpdates: options?.trackUpdates ?? false, componentName: cmp.name, }; const expectedFunctionCall = `trackComponent(${JSON.stringify(expectedFunctionCallOptions)});\n`; @@ -115,7 +115,7 @@ describe('componentTrackingPreprocessor', () => { expect(cmp2?.newCode).toEqual(cmp2?.originalCode); - expectComponentCodeToBeModified([cmp1!, cmp3!], { trackInit: true, trackUpdates: true }); + expectComponentCodeToBeModified([cmp1!, cmp3!], { trackInit: true, trackUpdates: false }); }); it('doesnt inject the function call to the same component more than once', () => { @@ -149,7 +149,7 @@ describe('componentTrackingPreprocessor', () => { return { ...cmp, newCode: res.code, map: res.map }; }); - expectComponentCodeToBeModified([cmp11!, cmp2!], { trackInit: true, trackUpdates: true }); + expectComponentCodeToBeModified([cmp11!, cmp2!], { trackInit: true }); expect(cmp12!.newCode).toEqual(cmp12!.originalCode); }); @@ -228,7 +228,7 @@ describe('componentTrackingPreprocessor', () => { expect(processedCode.code).toEqual( '\n' + "

I'm just a plain component

\n" + '', @@ -248,7 +248,7 @@ describe('componentTrackingPreprocessor', () => { expect(processedCode.code).toEqual( '\n" + "

I'm a component with a script

\n" + '', @@ -267,7 +267,7 @@ describe('componentTrackingPreprocessor', () => { expect(processedCode.code).toEqual( '", ); }); From 9c2ed9e8de6cdcfd0d835881dfcc9c4281ea6347 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 4 Feb 2025 12:01:16 +0100 Subject: [PATCH 60/65] ref(sveltekit): Clean up sub-request check (#15251) Removes a no longer necessary fallback check that we only needed in SvelteKit 1.26.0 or older. For Kit 2.x, we can rely on the `event.isSubRequest` flag to identify sub vs. actual requests in our request handler. fixes #15244 --- packages/sveltekit/src/server/handle.ts | 9 +--- packages/sveltekit/test/server/handle.test.ts | 49 +------------------ 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index 9bb9de9ce394..3a26ee64fd2a 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -3,7 +3,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, continueTrace, - getActiveSpan, getCurrentScope, getDefaultIsolationScope, getIsolationScope, @@ -100,19 +99,13 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { - // event.isSubRequest was added in SvelteKit 1.21.0 and we can use it to check - // if we should create a new execution context or not. // In case of a same-origin `fetch` call within a server`load` function, // SvelteKit will actually just re-enter the `handle` function and set `isSubRequest` // to `true` so that no additional network call is made. // We want the `http.server` span of that nested call to be a child span of the // currently active span instead of a new root span to correctly reflect this // behavior. - // As a fallback for Kit < 1.21.0, we check if there is an active span only if there's none, - // we create a new execution context. - const isSubRequest = typeof input.event.isSubRequest === 'boolean' ? input.event.isSubRequest : !!getActiveSpan(); - - if (isSubRequest) { + if (input.event.isSubRequest) { return instrumentHandle(input, options); } diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index 150f59ae9bc8..cde6a78f1378 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -149,53 +149,6 @@ describe('sentryHandle', () => { expect(spans).toHaveLength(1); }); - it('[kit>=1.21.0] creates a child span for nested server calls (i.e. if there is an active span)', async () => { - let _span: Span | undefined = undefined; - let txnCount = 0; - client.on('spanEnd', span => { - if (span === getRootSpan(span)) { - _span = span; - ++txnCount; - } - }); - - try { - await sentryHandle()({ - event: mockEvent(), - resolve: async _ => { - // simulating a nested load call: - await sentryHandle()({ - event: mockEvent({ route: { id: 'api/users/details/[id]', isSubRequest: true } }), - resolve: resolve(type, isError), - }); - return mockResponse; - }, - }); - } catch (e) { - // - } - - expect(txnCount).toEqual(1); - expect(_span!).toBeDefined(); - - expect(spanToJSON(_span!).description).toEqual('GET /users/[id]'); - expect(spanToJSON(_span!).op).toEqual('http.server'); - expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok'); - expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); - - expect(spanToJSON(_span!).timestamp).toBeDefined(); - - const spans = getSpanDescendants(_span!).map(spanToJSON); - - expect(spans).toHaveLength(2); - expect(spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ op: 'http.server', description: 'GET /users/[id]' }), - expect.objectContaining({ op: 'http.server', description: 'GET api/users/details/[id]' }), - ]), - ); - }); - it('creates a child span for nested server calls (i.e. if there is an active span)', async () => { let _span: Span | undefined = undefined; let txnCount = 0; @@ -212,7 +165,7 @@ describe('sentryHandle', () => { resolve: async _ => { // simulating a nested load call: await sentryHandle()({ - event: mockEvent({ route: { id: 'api/users/details/[id]' } }), + event: mockEvent({ route: { id: 'api/users/details/[id]', isSubRequest: true } }), resolve: resolve(type, isError), }); return mockResponse; From f490c9159231c7bc8a000e20e2e01d24fd63dbbe Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 4 Feb 2025 15:29:01 +0100 Subject: [PATCH 61/65] feat(core): Add `inheritOrSampleWith` helper to `traceSampler` (#15277) --- .../tracesSampler/server.js | 8 ++---- .../tracesSampler/test.ts | 26 ++++++++++++++++++- packages/core/src/tracing/sampling.ts | 19 +++++++++++++- packages/core/src/types-hoist/options.ts | 4 +-- .../core/src/types-hoist/samplingcontext.ts | 14 +++++++--- packages/core/test/lib/tracing/trace.test.ts | 1 + packages/opentelemetry/test/trace.test.ts | 4 +++ 7 files changed, 63 insertions(+), 13 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js index 5dc1d17588e5..5f616438fe90 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js @@ -4,12 +4,8 @@ const Sentry = require('@sentry/node'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', transport: loggingTransport, - tracesSampler: ({ parentSampleRate }) => { - if (parentSampleRate) { - return parentSampleRate; - } - - return 0.69; + tracesSampler: ({ inheritOrSampleWith }) => { + return inheritOrSampleWith(0.69); }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts index 304725268f03..f97773711941 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts @@ -11,7 +11,7 @@ describe('parentSampleRate propagation with tracesSampler', () => { expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.69/); }); - test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate', async () => { + test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (1 -> because there is a positive sampling decision and inheritOrSampleWith was used)', async () => { const runner = createRunner(__dirname, 'server.js').start(); const response = await runner.makeRequest('get', '/check', { headers: { @@ -20,6 +20,30 @@ describe('parentSampleRate propagation with tracesSampler', () => { }, }); + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=1/); + }); + + test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (0 -> because there is a negative sampling decision and inheritOrSampleWith was used)', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: '', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0/); + }); + + test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (the fallback value -> because there is no sampling decision and inheritOrSampleWith was used)', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', + baggage: '', + }, + }); + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.69/); }); diff --git a/packages/core/src/tracing/sampling.ts b/packages/core/src/tracing/sampling.ts index 70c62cd20992..0820b7be2cf0 100644 --- a/packages/core/src/tracing/sampling.ts +++ b/packages/core/src/tracing/sampling.ts @@ -27,7 +27,24 @@ export function sampleSpan( // work; prefer the hook if so let sampleRate; if (typeof options.tracesSampler === 'function') { - sampleRate = options.tracesSampler(samplingContext); + sampleRate = options.tracesSampler({ + ...samplingContext, + inheritOrSampleWith: fallbackSampleRate => { + // If we have an incoming parent sample rate, we'll just use that one. + // The sampling decision will be inherited because of the sample_rand that was generated when the trace reached the incoming boundaries of the SDK. + if (typeof samplingContext.parentSampleRate === 'number') { + return samplingContext.parentSampleRate; + } + + // Fallback if parent sample rate is not on the incoming trace (e.g. if there is no baggage) + // This is to provide backwards compatibility if there are incoming traces from older SDKs that don't send a parent sample rate or a sample rand. In these cases we just want to force either a sampling decision on the downstream traces via the sample rate. + if (typeof samplingContext.parentSampled === 'boolean') { + return Number(samplingContext.parentSampled); + } + + return fallbackSampleRate; + }, + }); localSampleRateWasApplied = true; } else if (samplingContext.parentSampled !== undefined) { sampleRate = samplingContext.parentSampled; diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 58634930c993..8e52b32eacf7 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -2,7 +2,7 @@ import type { CaptureContext } from '../scope'; import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; import type { ErrorEvent, EventHint, TransactionEvent } from './event'; import type { Integration } from './integration'; -import type { SamplingContext } from './samplingcontext'; +import type { TracesSamplerSamplingContext } from './samplingcontext'; import type { SdkMetadata } from './sdkmetadata'; import type { SpanJSON } from './span'; import type { StackLineParser, StackParser } from './stacktrace'; @@ -243,7 +243,7 @@ export interface ClientOptions number | boolean; + tracesSampler?: (samplingContext: TracesSamplerSamplingContext) => number | boolean; /** * An event-processing callback for error and message events, guaranteed to be invoked after all other event diff --git a/packages/core/src/types-hoist/samplingcontext.ts b/packages/core/src/types-hoist/samplingcontext.ts index 6f0d2a0800cf..b0a52862870c 100644 --- a/packages/core/src/types-hoist/samplingcontext.ts +++ b/packages/core/src/types-hoist/samplingcontext.ts @@ -10,9 +10,7 @@ export interface CustomSamplingContext { } /** - * Data passed to the `tracesSampler` function, which forms the basis for whatever decisions it might make. - * - * Adds default data to data provided by the user. + * Auxiliary data for various sampling mechanisms in the Sentry SDK. */ export interface SamplingContext extends CustomSamplingContext { /** @@ -42,3 +40,13 @@ export interface SamplingContext extends CustomSamplingContext { /** Initial attributes that have been passed to the span being sampled. */ attributes?: SpanAttributes; } + +/** + * Auxiliary data passed to the `tracesSampler` function. + */ +export interface TracesSamplerSamplingContext extends SamplingContext { + /** + * Returns a sample rate value that matches the sampling decision from the incoming trace, or falls back to the provided `fallbackSampleRate`. + */ + inheritOrSampleWith: (fallbackSampleRate: number) => number; +} diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 0eee7338a93d..c33b50c01a85 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -608,6 +608,7 @@ describe('startSpan', () => { test2: 'aa', test3: 'bb', }, + inheritOrSampleWith: expect.any(Function), }); }); diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index a841bec6ffb6..184b93b1e71b 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -1350,6 +1350,7 @@ describe('trace (sampling)', () => { parentSampled: undefined, name: 'outer', attributes: {}, + inheritOrSampleWith: expect.any(Function), }); // Now return `false`, it should not sample @@ -1416,6 +1417,7 @@ describe('trace (sampling)', () => { attr2: 1, 'sentry.op': 'test.op', }, + inheritOrSampleWith: expect.any(Function), }); // Now return `0`, it should not sample @@ -1457,6 +1459,7 @@ describe('trace (sampling)', () => { parentSampled: undefined, name: 'outer3', attributes: {}, + inheritOrSampleWith: expect.any(Function), }); }); @@ -1490,6 +1493,7 @@ describe('trace (sampling)', () => { parentSampled: true, name: 'outer', attributes: {}, + inheritOrSampleWith: expect.any(Function), }); }); From 3a03766dc5a95110dbc9c28883aca3379d47727f Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Tue, 4 Feb 2025 12:51:04 -0500 Subject: [PATCH 62/65] feat(user feedback): Adds toolbar for cropping and annotating (#15282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adds a toolbar for cropping and annotations - changes from inline styles to multiple class names in BEM format With annotation option: ![Screenshot 2025-02-03 at 3 51 04 PM](https://github.com/user-attachments/assets/97e4ac38-4926-49e5-a6f3-d474174e3c38) Without annotation option (to confirm that it looks the same as before): ![Screenshot 2025-02-03 at 5 09 01 PM](https://github.com/user-attachments/assets/8b614c38-3c1b-4d7e-986e-ead86a3f4349) Closes https://github.com/getsentry/sentry-javascript/issues/15252 --- .../src/screenshot/components/CropIcon.tsx | 23 +++ .../src/screenshot/components/PenIcon.tsx | 2 +- .../components/ScreenshotEditor.tsx | 180 ++++++++++-------- .../components/ScreenshotInput.css.ts | 48 ++++- 4 files changed, 166 insertions(+), 87 deletions(-) create mode 100644 packages/feedback/src/screenshot/components/CropIcon.tsx diff --git a/packages/feedback/src/screenshot/components/CropIcon.tsx b/packages/feedback/src/screenshot/components/CropIcon.tsx new file mode 100644 index 000000000000..091179d86004 --- /dev/null +++ b/packages/feedback/src/screenshot/components/CropIcon.tsx @@ -0,0 +1,23 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function CropIconFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function CropIcon(): VNode { + return ( + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/PenIcon.tsx b/packages/feedback/src/screenshot/components/PenIcon.tsx index ec50862c1dd4..75a0faedf480 100644 --- a/packages/feedback/src/screenshot/components/PenIcon.tsx +++ b/packages/feedback/src/screenshot/components/PenIcon.tsx @@ -9,7 +9,7 @@ export default function PenIconFactory({ }: FactoryParams) { return function PenIcon(): VNode { return ( - + ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); @@ -86,6 +88,7 @@ export function ScreenshotEditorFactory({ const [croppingRect, setCroppingRect] = hooks.useState({ startX: 0, startY: 0, endX: 0, endY: 0 }); const [confirmCrop, setConfirmCrop] = hooks.useState(false); const [isResizing, setIsResizing] = hooks.useState(false); + const [isCropping, setIsCropping] = hooks.useState(true); const [isAnnotating, setIsAnnotating] = hooks.useState(false); hooks.useEffect(() => { @@ -142,6 +145,10 @@ export function ScreenshotEditorFactory({ const croppingBox = constructRect(croppingRect); ctx.clearRect(0, 0, imageDimensions.width, imageDimensions.height); + if (!isCropping) { + return; + } + // draw gray overlay around the selection ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(0, 0, imageDimensions.width, imageDimensions.height); @@ -154,7 +161,7 @@ export function ScreenshotEditorFactory({ ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; ctx.strokeRect(croppingBox.x + 3, croppingBox.y + 3, croppingBox.width - 6, croppingBox.height - 6); - }, [croppingRect]); + }, [croppingRect, isCropping]); function onGrabButton(e: Event, corner: string): void { setIsAnnotating(false); @@ -398,102 +405,115 @@ export function ScreenshotEditorFactory({ return (