From bb12dff6339f0559271862b1f710dcbaadd5bbec Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Feb 2025 13:27:01 +0100 Subject: [PATCH 01/14] fix(sveltekit): Avoid request body double read errors (#15368) Remove `request.clone()` calls from our request handler. This is safe because our SDK does not consume the request body. Cloning here was a precautionary measure that apparently backfired. --- packages/sveltekit/src/server/handle.ts | 12 ++++++++++-- packages/sveltekit/test/server/handle.test.ts | 2 -- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index 3a26ee64fd2a..84f29a2c70c5 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -113,7 +113,11 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { // We only call continueTrace in the initial top level request to avoid // creating a new root span for the sub request. isolationScope.setSDKProcessingMetadata({ - normalizedRequest: winterCGRequestToRequestData(input.event.request.clone()), + // We specifically avoid cloning the request here to avoid double read errors. + // We only read request headers so we're not consuming the body anyway. + // Note to future readers: This sounds counter-intuitive but please read + // https://github.com/getsentry/sentry-javascript/issues/14583 + normalizedRequest: winterCGRequestToRequestData(input.event.request), }); return continueTrace(getTracePropagationData(input.event), () => instrumentHandle(input, options)); }); @@ -163,7 +167,11 @@ async function instrumentHandle( }, async (span?: Span) => { getCurrentScope().setSDKProcessingMetadata({ - normalizedRequest: winterCGRequestToRequestData(event.request.clone()), + // We specifically avoid cloning the request here to avoid double read errors. + // We only read request headers so we're not consuming the body anyway. + // Note to future readers: This sounds counter-intuitive but please read + // https://github.com/getsentry/sentry-javascript/issues/14583 + normalizedRequest: winterCGRequestToRequestData(event.request), }); const res = await resolve(event, { transformPageChunk: addSentryCodeToPage({ injectFetchProxyScript: options.injectFetchProxyScript ?? true }), diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index cde6a78f1378..b2adb50d91b8 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -46,8 +46,6 @@ function mockEvent(override: Record = {}): Parameters[0 ...override, }; - event.request.clone = () => event.request; - return event; } From 8af72f3ca0202f6db591679560d934f59a4f0629 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Feb 2025 16:06:07 +0100 Subject: [PATCH 02/14] fix(sveltekit): Avoid top-level `vite` import (#15371) Avoid the top-level `vite` import/require statement by using a dynamic import of `vite` instead. This patch fixes an issue reported with more recent node versions that supports `require` in ESM. --- packages/sveltekit/src/vite/sourceMaps.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts index be0334348e70..799688b33845 100644 --- a/packages/sveltekit/src/vite/sourceMaps.ts +++ b/packages/sveltekit/src/vite/sourceMaps.ts @@ -6,7 +6,7 @@ import { consoleSandbox, escapeStringForRegex, uuid4 } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import { sentryVitePlugin } from '@sentry/vite-plugin'; -import { type Plugin, type UserConfig, loadConfigFromFile } from 'vite'; +import type { Plugin, UserConfig } from 'vite'; import MagicString from 'magic-string'; import { WRAPPED_MODULE_SUFFIX } from './autoInstrument'; @@ -76,7 +76,26 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug const defaultFileDeletionGlob = ['./.*/**/*.map', `./${adapterOutputDir}/**/*.map`]; if (!globalWithSourceMapSetting._sentry_sourceMapSetting) { - const configFile = await loadConfigFromFile({ command: 'build', mode: 'production' }); + let configFile: { + path: string; + config: UserConfig; + dependencies: string[]; + } | null = null; + + try { + // @ts-expect-error - the dynamic import here works fine + const Vite = await import('vite'); + configFile = await Vite.loadConfigFromFile({ command: 'build', mode: 'production' }); + } catch { + if (options?.debug) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry] Could not import Vite to load your vite config. Please set `build.sourcemap` to `true` or `hidden` to enable source map generation.', + ); + }); + } + } if (configFile) { globalWithSourceMapSetting._sentry_sourceMapSetting = getUpdatedSourceMapSetting(configFile.config); From cea9484f47fb0405e60043a5080e5414c59a294f Mon Sep 17 00:00:00 2001 From: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Date: Tue, 11 Feb 2025 10:37:14 -0500 Subject: [PATCH 03/14] feat(browser): Add `graphqlClientIntegration` (#13783) Signed-off-by: Kaung Zin Hein Signed-off-by: Kaung Zin Hein <83657429+Zen-cronic@users.noreply.github.com> Co-authored-by: Lukas Stracke Co-authored-by: Luca Forstner Co-authored-by: Catherine Lee <55311782+c298lee@users.noreply.github.com> Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Co-authored-by: Francesco Novy --- .../graphqlClient/fetch/subject.js | 17 ++ .../integrations/graphqlClient/fetch/test.ts | 103 +++++++++ .../suites/integrations/graphqlClient/init.js | 16 ++ .../integrations/graphqlClient/xhr/subject.js | 15 ++ .../integrations/graphqlClient/xhr/test.ts | 102 +++++++++ .../utils/generatePlugin.ts | 1 + packages/browser-utils/jest.config.js | 1 - packages/browser-utils/package.json | 6 +- packages/browser-utils/src/index.ts | 4 + packages/browser-utils/src/networkUtils.ts | 57 +++++ packages/browser-utils/src/types.ts | 25 +++ .../browser-utils/test/networkUtils.test.ts | 107 ++++++++++ packages/browser-utils/tsconfig.test.json | 4 +- packages/browser-utils/vite.config.ts | 10 + packages/browser/rollup.bundle.config.mjs | 1 + packages/browser/src/index.ts | 1 + .../index.graphqlclient.ts | 1 + .../browser/src/integrations/breadcrumbs.ts | 67 +++--- .../browser/src/integrations/graphqlClient.ts | 199 ++++++++++++++++++ packages/browser/src/tracing/request.ts | 7 + .../browser/src/utils/lazyLoadIntegration.ts | 1 + .../test/integrations/graphqlClient.test.ts | 140 ++++++++++++ packages/core/src/client.ts | 34 +++ packages/core/src/fetch.ts | 16 +- packages/core/src/types-hoist/breadcrumb.ts | 2 +- packages/core/src/utils-hoist/index.ts | 2 + .../coreHandlers/handleNetworkBreadcrumbs.ts | 3 +- .../src/coreHandlers/util/fetchUtils.ts | 21 +- .../src/coreHandlers/util/networkUtils.ts | 42 +--- .../src/coreHandlers/util/xhrUtils.ts | 14 +- packages/replay-internal/src/types/replay.ts | 23 +- packages/replay-internal/src/types/request.ts | 10 +- packages/replay-internal/src/util/logger.ts | 5 +- .../coreHandlers/util/networkUtils.test.ts | 36 ---- 34 files changed, 920 insertions(+), 173 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 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 delete mode 100644 packages/browser-utils/jest.config.js 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 create mode 100644 packages/browser/src/integrations-bundle/index.graphqlclient.ts create mode 100644 packages/browser/src/integrations/graphqlClient.ts create mode 100644 packages/browser/test/integrations/graphqlClient.test.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..c200e891f674 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -0,0 +1,103 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +// Duplicate from subject.js +const query = `query Test{ + people { + name + pet + } +}`; +const queryPayload = JSON.stringify({ query }); + +sentryTest('should update spans for GraphQL fetch requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ 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', + 'graphql.document': queryPayload, + }), + }); +}); + +sentryTest('should update breadcrumbs for GraphQL fetch requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ 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.document': query, + 'graphql.operation': 'query Test', + }, + }); +}); 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..ec5f5b76cd44 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; +// Must import this like this to ensure the test transformation for CDN bundles works +import { graphqlClientIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration(), + 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..85645f645635 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/subject.js @@ -0,0 +1,15 @@ +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..1beaf001d5a2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -0,0 +1,102 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +// Duplicate from subject.js +const query = `query Test{ + people { + name + pet + } +}`; +const queryPayload = JSON.stringify({ query }); + +sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ 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: { + 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', + 'graphql.document': queryPayload, + }, + }); +}); + +sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ 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.document': query, + 'graphql.operation': 'query Test', + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index 77792d02b19c..1cb3fea77705 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -36,6 +36,7 @@ const IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS: Record = { reportingObserverIntegration: 'reportingobserver', feedbackIntegration: 'feedback', moduleMetadataIntegration: 'modulemetadata', + graphqlClientIntegration: 'graphqlclient', // technically, this is not an integration, but let's add it anyway for simplicity makeMultiplexedTransport: 'multiplexedtransport', }; 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/package.json b/packages/browser-utils/package.json index 5f293e390441..79debb709725 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 30bc3a29888e..f66446ea5159 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -26,3 +26,7 @@ export { addHistoryInstrumentationHandler } from './instrument/history'; export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } from './getNativeImplementation'; export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; + +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 new file mode 100644 index 000000000000..db8bb36fe357 --- /dev/null +++ b/packages/browser-utils/src/networkUtils.ts @@ -0,0 +1,57 @@ +import { logger } from '@sentry/core'; +import type { Logger } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import type { NetworkMetaWarning } from './types'; + +/** + * 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 = logger): [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.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']; +} + +/** + * 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/src/types.ts b/packages/browser-utils/src/types.ts index fd8f997907fc..f2d19dc2e561 100644 --- a/packages/browser-utils/src/types.ts +++ b/packages/browser-utils/src/types.ts @@ -1,6 +1,31 @@ +import type { + FetchBreadcrumbHint, + HandlerDataFetch, + SentryWrappedXMLHttpRequest, + XhrBreadcrumbHint, +} from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; 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'; + +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-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts new file mode 100644 index 000000000000..84d1c635e844 --- /dev/null +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import { getBodyString, getFetchRequestArgBody, serializeFormData } 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']); + }); +}); + +describe('getFetchRequestArgBody', () => { + describe('valid types of body', () => { + it('works 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('works 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('works 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('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('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); + }); + }); + + describe('does not work without body passed as the second option', () => { + it.each([ + ['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).toBeUndefined(); + }); + }); +}); + +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/browser-utils/tsconfig.test.json b/packages/browser-utils/tsconfig.test.json index 87f6afa06b86..b2ccc6d8b08c 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", "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/rollup.bundle.config.mjs b/packages/browser/rollup.bundle.config.mjs index 20ee3a0b9646..5c8ba8c31a37 100644 --- a/packages/browser/rollup.bundle.config.mjs +++ b/packages/browser/rollup.bundle.config.mjs @@ -11,6 +11,7 @@ const reexportedPluggableIntegrationFiles = [ 'rewriteframes', 'feedback', 'modulemetadata', + 'graphqlclient', 'spotlight', ]; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 2abe7beb55fb..63da52dfd30e 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, diff --git a/packages/browser/src/integrations-bundle/index.graphqlclient.ts b/packages/browser/src/integrations-bundle/index.graphqlclient.ts new file mode 100644 index 000000000000..d1a1b1e792f4 --- /dev/null +++ b/packages/browser/src/integrations-bundle/index.graphqlclient.ts @@ -0,0 +1 @@ +export { graphqlClientIntegration } from '../integrations/graphqlClient'; diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index a45048ce2640..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, @@ -36,6 +37,7 @@ import { safeJoin, severityLevelFromString, } from '@sentry/core'; + import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; @@ -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('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as XhrHint); + + addBreadcrumb(breadcrumb, hint); }; } @@ -292,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, @@ -299,17 +301,22 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe endTimestamp, }; - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); + const breadcrumb = { + category: 'fetch', + data, + level: 'error', + type: 'http', + } satisfies Breadcrumb; + + client.emit('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as FetchHint); + + addBreadcrumb(breadcrumb, hint); } else { const response = handlerData.response as Response | undefined; + const data: FetchBreadcrumbData = { + ...handlerData.fetchData, + status_code: response?.status, + }; breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; @@ -321,17 +328,17 @@ 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('beforeOutgoingRequestBreadcrumb', breadcrumb, hint as FetchHint); + + addBreadcrumb(breadcrumb, hint); } }; } diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts new file mode 100644 index 000000000000..2c9ce06b7794 --- /dev/null +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -0,0 +1,199 @@ +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, + isString, + spanToJSON, + stringMatchesSomePattern, +} from '@sentry/core'; +import type { Client, IntegrationFn } from '@sentry/core'; + +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; + extensions?: Record; +} + +interface GraphQLOperation { + operationType?: string; + operationName?: string; +} + +const INTEGRATION_NAME = 'GraphQLClient'; + +const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { + return { + name: INTEGRATION_NAME, + setup(client) { + _updateSpanWithGraphQLData(client, options); + _updateBreadcrumbWithGraphQLData(client, options); + }, + }; +}) satisfies IntegrationFn; + +function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void { + client.on('beforeOutgoingRequestSpan', (span, hint) => { + const spanJSON = spanToJSON(span); + + const spanAttributes = spanJSON.data || {}; + const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]; + + const isHttpClientSpan = spanOp === 'http.client'; + + if (!isHttpClientSpan) { + return; + } + + 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 = stringMatchesSomePattern(httpUrl, endpoints); + const payload = getRequestPayloadXhrOrFetch(hint as XhrHint | FetchHint); + + if (isTracedGraphqlEndpoint && payload) { + const graphqlBody = getGraphQLRequestPayload(payload); + + if (graphqlBody) { + const operationInfo = _getGraphQLOperation(graphqlBody); + span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + span.setAttribute('graphql.document', payload); + } + } + }); +} + +function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClientOptions): void { + client.on('beforeOutgoingRequestBreadcrumb', (breadcrumb, handlerData) => { + const { category, type, data } = breadcrumb; + + const isFetch = category === 'fetch'; + const isXhr = category === 'xhr'; + const isHttpBreadcrumb = type === 'http'; + + if (isHttpBreadcrumb && (isFetch || isXhr)) { + const httpUrl = data?.url; + const { endpoints } = options; + + const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints); + const payload = getRequestPayloadXhrOrFetch(handlerData as XhrHint | FetchHint); + + if (isTracedGraphqlEndpoint && data && payload) { + const graphqlBody = getGraphQLRequestPayload(payload); + + if (!data.graphql && graphqlBody) { + const operationInfo = _getGraphQLOperation(graphqlBody); + data['graphql.document'] = graphqlBody.query; + data['graphql.operation'] = operationInfo; + } + } + } + }); +} + +/** + * @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 { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); + const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; + + return operationInfo; +} + +/** + * Get the request body/payload based on the shape of the hint. + * + * Exported for tests only. + */ +export function getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined { + const isXhr = 'xhr' in hint; + + let body: string | undefined; + + if (isXhr) { + const sentryXhrData = hint.xhr[SENTRY_XHR_DATA_KEY]; + body = sentryXhrData && getBodyString(sentryXhrData.body)[0]; + } else { + const sentryFetchData = getFetchRequestArgBody(hint.input); + body = getBodyString(sentryFetchData)[0]; + } + + return body; +} + +/** + * Extract the name and type of the operation from the GraphQL query. + * + * Exported for tests only. + */ +export function parseGraphQLQuery(query: string): GraphQLOperation { + const namedQueryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/; + const unnamedQueryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)[{(]/; + + const namedMatch = query.match(namedQueryRe); + if (namedMatch) { + return { + operationType: namedMatch[1], + operationName: namedMatch[2], + }; + } + + const unnamedMatch = query.match(unnamedQueryRe); + if (unnamedMatch) { + return { + operationType: unnamedMatch[1], + operationName: undefined, + }; + } + 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): GraphQLRequestPayload | undefined { + let graphqlBody = undefined; + try { + 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; + } + } finally { + // Fallback to undefined if payload is an invalid JSON (SyntaxError) + + /* eslint-disable no-unsafe-finally */ + return graphqlBody; + } +} + +/** + * This integration ensures that GraphQL requests made in the browser + * have their GraphQL-specific data captured and attached to spans and breadcrumbs. + */ +export const graphqlClientIntegration = defineIntegration(_graphqlClientIntegration); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 749ee6fde0dc..144aec73c977 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -4,6 +4,7 @@ import { addXhrInstrumentationHandler, extractNetworkProtocol, } from '@sentry-internal/browser-utils'; +import type { XhrHint } from '@sentry-internal/browser-utils'; import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -13,6 +14,7 @@ import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin, getActiveSpan, + getClient, getLocationHref, getTraceData, hasSpansEnabled, @@ -374,6 +376,11 @@ export function xhrCallback( ); } + const client = getClient(); + if (client) { + client.emit('beforeOutgoingRequestSpan', span, handlerData as XhrHint); + } + return span; } diff --git a/packages/browser/src/utils/lazyLoadIntegration.ts b/packages/browser/src/utils/lazyLoadIntegration.ts index e6fea13c4e2a..5ffbd31adff5 100644 --- a/packages/browser/src/utils/lazyLoadIntegration.ts +++ b/packages/browser/src/utils/lazyLoadIntegration.ts @@ -15,6 +15,7 @@ const LazyLoadableIntegrations = { linkedErrorsIntegration: 'linkederrors', dedupeIntegration: 'dedupe', extraErrorDataIntegration: 'extraerrordata', + graphqlClientIntegration: 'graphqlclient', httpClientIntegration: 'httpclient', reportingObserverIntegration: 'reportingobserver', rewriteFramesIntegration: 'rewriteframes', diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts new file mode 100644 index 000000000000..144bcc808e1f --- /dev/null +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -0,0 +1,140 @@ +/** + * @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/browser-utils'; +import { + getGraphQLRequestPayload, + getRequestPayloadXhrOrFetch, + parseGraphQLQuery, +} from '../../src/integrations/graphqlClient'; + +describe('GraphqlClient', () => { + 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 + } + }`; + + 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: {}, + extensions: {}, + }; + + 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(); + }); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 0c74625e31a4..b3021ce087c9 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -14,6 +14,7 @@ import type { EventHint, EventProcessor, FeedbackEvent, + FetchBreadcrumbHint, Integration, MonitorConfig, Outcome, @@ -30,6 +31,7 @@ import type { TransactionEvent, Transport, TransportMakeRequestResponse, + XhrBreadcrumbHint, } from './types-hoist'; import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; @@ -584,6 +586,24 @@ export abstract class Client { */ public 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. + */ + 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. + * @returns A function that, when executed, removes the registered callback. + */ + public on( + hook: 'beforeOutgoingRequestBreadcrumb', + callback: (breadcrumb: Breadcrumb, hint: XhrBreadcrumbHint | FetchBreadcrumbHint) => void, + ): () => void; + /** * A hook that is called when the client is flushing * @returns {() => void} A function that, when executed, removes the registered callback. @@ -719,6 +739,20 @@ export abstract class Client { */ public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void; + /** + * Emit a hook event for GraphQL client integration to enhance a span with request data. + */ + 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: 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 54bd4c672dfc..3c43584b3951 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,7 +1,8 @@ +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'; @@ -96,6 +97,19 @@ export function instrumentFetchRequest( } } + const client = getClient(); + + if (client) { + const fetchHint = { + input: handlerData.args, + response: handlerData.response, + startTimestamp: handlerData.startTimestamp, + endTimestamp: handlerData.endTimestamp, + } satisfies FetchBreadcrumbHint; + + client.emit('beforeOutgoingRequestSpan', span, fetchHint); + } + return span; } 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 { 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/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 fe7b5656baa9..e66bd9c26849 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -1,10 +1,9 @@ -import { 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'; import { DEBUG_BUILD } from '../../debug-build'; import type { - FetchHint, - NetworkMetaWarning, ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, @@ -17,7 +16,6 @@ import { buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, - getBodyString, makeNetworkReplayBreadcrumb, mergeWarning, parseContentLengthHeader, @@ -57,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; @@ -117,8 +115,8 @@ function _getRequestInfo( } // We only want to transmit string or string-like bodies - const requestBody = _getFetchRequestArgBody(input); - const [bodyStr, warning] = getBodyString(requestBody); + const requestBody = getFetchRequestArgBody(input); + const [bodyStr, warning] = getBodyString(requestBody, logger); const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); if (warning) { @@ -218,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 = {}; diff --git a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts index 3197b6839e74..c626b4a2b7d6 100644 --- a/packages/replay-internal/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/networkUtils.ts @@ -1,16 +1,15 @@ +import { serializeFormData } from '@sentry-internal/browser-utils'; +import type { NetworkMetaWarning } from '@sentry-internal/browser-utils'; import { dropUndefinedKeys, stringMatchesSomePattern } from '@sentry/core'; 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 { @@ -30,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; } @@ -60,34 +59,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, @@ -200,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/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index e05dda8e29eb..6028a09232ba 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, XhrHint } 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 } 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/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 6ac77d7b672f..280db17db57a 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'; @@ -502,17 +492,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; 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/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. 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 7c8f0cdb12294009d644a2532ab825bc6f1fc758 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 12 Feb 2025 11:30:35 +0100 Subject: [PATCH 04/14] feat(node): Extract Sentry-specific node-fetch instrumentation (#15231) Co-authored-by: Luca Forstner --- .../fetch-no-tracing-no-spans/scenario.ts | 24 ++ .../fetch-no-tracing-no-spans/test.ts | 47 +++ packages/core/src/utils-hoist/baggage.ts | 2 +- packages/core/src/utils-hoist/index.ts | 1 + packages/node/src/integrations/node-fetch.ts | 154 --------- .../SentryNodeFetchInstrumentation.ts | 296 ++++++++++++++++++ .../node/src/integrations/node-fetch/index.ts | 108 +++++++ .../node/src/integrations/node-fetch/types.ts | 47 +++ packages/node/src/nodeVersion.ts | 1 + packages/node/src/utils/baggage.ts | 31 ++ 10 files changed, 556 insertions(+), 155 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts delete mode 100644 packages/node/src/integrations/node-fetch.ts create mode 100644 packages/node/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts create mode 100644 packages/node/src/integrations/node-fetch/index.ts create mode 100644 packages/node/src/integrations/node-fetch/types.ts create mode 100644 packages/node/src/utils/baggage.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts new file mode 100644 index 000000000000..8c2ed31ee1f8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts @@ -0,0 +1,24 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [Sentry.nativeNodeFetchIntegration({ spans: false })], + transport: loggingTransport, +}); + +async function run(): Promise { + // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented + await new Promise(resolve => setTimeout(resolve, 100)); + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts new file mode 100644 index 000000000000..98fc6bd38c52 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts @@ -0,0 +1,47 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing fetch', () => { + test('outgoing fetch requests are correctly instrumented with tracing & spans are disabled', done => { + expect.assertions(11); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .ensureNoErrorOutput() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(closeTestServer); + }); + }); +}); diff --git a/packages/core/src/utils-hoist/baggage.ts b/packages/core/src/utils-hoist/baggage.ts index 075dbf4389df..84d1078b7583 100644 --- a/packages/core/src/utils-hoist/baggage.ts +++ b/packages/core/src/utils-hoist/baggage.ts @@ -130,7 +130,7 @@ function baggageHeaderToObject(baggageHeader: string): Record { * @returns a baggage header string, or `undefined` if the object didn't have any values, since an empty baggage header * is not spec compliant. */ -function objectToBaggageHeader(object: Record): string | undefined { +export function objectToBaggageHeader(object: Record): string | undefined { if (Object.keys(object).length === 0) { // An empty baggage header is not spec compliant: We return undefined. return undefined; diff --git a/packages/core/src/utils-hoist/index.ts b/packages/core/src/utils-hoist/index.ts index 4141b4583e35..189c2ee363aa 100644 --- a/packages/core/src/utils-hoist/index.ts +++ b/packages/core/src/utils-hoist/index.ts @@ -130,6 +130,7 @@ export { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader, parseBaggageHeader, + objectToBaggageHeader, } from './baggage'; export { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from './url'; diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch.ts deleted file mode 100644 index 9655ea5641c5..000000000000 --- a/packages/node/src/integrations/node-fetch.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { registerInstrumentations } from '@opentelemetry/instrumentation'; -import type { UndiciRequest, UndiciResponse } from '@opentelemetry/instrumentation-undici'; -import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; -import type { IntegrationFn, SanitizedRequestData } from '@sentry/core'; -import { - LRUMap, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - addBreadcrumb, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getSanitizedUrlString, - getTraceData, - hasSpansEnabled, - parseUrl, -} from '@sentry/core'; -import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; - -interface NodeFetchOptions { - /** - * Whether breadcrumbs should be recorded for requests. - * Defaults to true - */ - breadcrumbs?: boolean; - - /** - * Do not capture spans or breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`. - * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. - */ - ignoreOutgoingRequests?: (url: string) => boolean; -} - -const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { - const _breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs; - const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; - - return { - name: 'NodeFetch', - setupOnce() { - const propagationDecisionMap = new LRUMap(100); - - const instrumentation = new UndiciInstrumentation({ - requireParentforSpans: false, - ignoreRequestHook: request => { - const url = getAbsoluteUrl(request.origin, request.path); - const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); - - if (shouldIgnore) { - return true; - } - - // If span recording is disabled, we still want to propagate traces - // So we do that manually here, matching what the instrumentation does otherwise - if (!hasSpansEnabled()) { - const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets; - const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) - ? getTraceData() - : {}; - - const requestHeaders = request.headers; - if (Array.isArray(requestHeaders)) { - Object.entries(addedHeaders).forEach(headers => requestHeaders.push(...headers)); - } else { - request.headers += Object.entries(addedHeaders) - .map(([k, v]) => `${k}: ${v}\r\n`) - .join(''); - } - - // Prevent starting a span for this request - return true; - } - - return false; - }, - startSpanHook: () => { - return { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', - }; - }, - responseHook: (_, { request, response }) => { - if (_breadcrumbs) { - addRequestBreadcrumb(request, response); - } - }, - }); - - registerInstrumentations({ instrumentations: [instrumentation] }); - }, - }; -}) satisfies IntegrationFn; - -export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration); - -/** Add a breadcrumb for outgoing requests. */ -function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void { - const data = getBreadcrumbData(request); - const statusCode = response.statusCode; - const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); - - addBreadcrumb( - { - category: 'http', - data: { - status_code: statusCode, - ...data, - }, - type: 'http', - level, - }, - { - event: 'response', - request, - response, - }, - ); -} - -function getBreadcrumbData(request: UndiciRequest): Partial { - try { - const url = new URL(request.path, request.origin); - const parsedUrl = parseUrl(url.toString()); - - const data: Partial = { - url: getSanitizedUrlString(parsedUrl), - 'http.method': request.method || 'GET', - }; - - if (parsedUrl.search) { - data['http.query'] = parsedUrl.search; - } - if (parsedUrl.hash) { - data['http.fragment'] = parsedUrl.hash; - } - - return data; - } catch { - return {}; - } -} - -// Matching the behavior of the base instrumentation -function getAbsoluteUrl(origin: string, path: string = '/'): string { - const url = `${origin}`; - - if (url.endsWith('/') && path.startsWith('/')) { - return `${url}${path.slice(1)}`; - } - - if (!url.endsWith('/') && !path.startsWith('/')) { - return `${url}/${path.slice(1)}`; - } - - return `${url}${path}`; -} diff --git a/packages/node/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts b/packages/node/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts new file mode 100644 index 000000000000..f7327b095359 --- /dev/null +++ b/packages/node/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts @@ -0,0 +1,296 @@ +import { VERSION } from '@opentelemetry/core'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { InstrumentationBase } from '@opentelemetry/instrumentation'; +import type { SanitizedRequestData } from '@sentry/core'; +import { LRUMap, getClient, getTraceData } from '@sentry/core'; +import { addBreadcrumb, getBreadcrumbLogLevelFromHttpStatusCode, getSanitizedUrlString, parseUrl } from '@sentry/core'; +import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; +import * as diagch from 'diagnostics_channel'; +import { NODE_MAJOR, NODE_MINOR } from '../../nodeVersion'; +import { mergeBaggageHeaders } from '../../utils/baggage'; +import type { UndiciRequest, UndiciResponse } from './types'; + +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const SENTRY_BAGGAGE_HEADER = 'baggage'; + +// For baggage, we make sure to merge this into a possibly existing header +const BAGGAGE_HEADER_REGEX = /baggage: (.*)\r\n/; + +export type SentryNodeFetchInstrumentationOptions = InstrumentationConfig & { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Do not capture breadcrumbs or inject headers for outgoing fetch requests to URLs where the given callback returns `true`. + * The same option can be passed to the top-level httpIntegration where it controls both, breadcrumb and + * span creation. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + */ + ignoreOutgoingRequests?: (url: string) => boolean; +}; + +interface ListenerRecord { + name: string; + unsubscribe: () => void; +} + +/** + * This custom node-fetch instrumentation is used to instrument outgoing fetch requests. + * It does not emit any spans. + * + * The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this, + * which would lead to Sentry not working as expected. + * + * This is heavily inspired & adapted from: + * https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/undici.ts + */ +export class SentryNodeFetchInstrumentation extends InstrumentationBase { + // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for + // unsubscribing. + private _channelSubs: Array; + private _propagationDecisionMap: LRUMap; + + public constructor(config: SentryNodeFetchInstrumentationOptions = {}) { + super('@sentry/instrumentation-node-fetch', VERSION, config); + this._channelSubs = []; + this._propagationDecisionMap = new LRUMap(100); + } + + /** No need to instrument files/modules. */ + public init(): void { + return undefined; + } + + /** Disable the instrumentation. */ + public disable(): void { + super.disable(); + this._channelSubs.forEach(sub => sub.unsubscribe()); + this._channelSubs = []; + } + + /** Enable the instrumentation. */ + public enable(): void { + // "enabled" handling is currently a bit messy with InstrumentationBase. + // If constructed with `{enabled: false}`, this `.enable()` is still called, + // and `this.getConfig().enabled !== this.isEnabled()`, creating confusion. + // + // For now, this class will setup for instrumenting if `.enable()` is + // called, but use `this.getConfig().enabled` to determine if + // instrumentation should be generated. This covers the more likely common + // case of config being given a construction time, rather than later via + // `instance.enable()`, `.disable()`, or `.setConfig()` calls. + super.enable(); + + // This method is called by the super-class constructor before ours is + // called. So we need to ensure the property is initalized. + this._channelSubs = this._channelSubs || []; + + // Avoid to duplicate subscriptions + if (this._channelSubs.length > 0) { + return; + } + + this._subscribeToChannel('undici:request:create', this._onRequestCreated.bind(this)); + this._subscribeToChannel('undici:request:headers', this._onResponseHeaders.bind(this)); + } + + /** + * This method is called when a request is created. + * You can still mutate the request here before it is sent. + */ + private _onRequestCreated({ request }: { request: UndiciRequest }): void { + const config = this.getConfig(); + const enabled = config.enabled !== false; + + if (!enabled) { + return; + } + + // Add trace propagation headers + const url = getAbsoluteUrl(request.origin, request.path); + const _ignoreOutgoingRequests = config.ignoreOutgoingRequests; + const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); + + if (shouldIgnore) { + return; + } + + // Manually add the trace headers, if it applies + // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span + // Which we do not have in this case + // The propagator _may_ overwrite this, but this should be fine as it is the same data + const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets; + const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap) + ? getTraceData() + : undefined; + + if (!addedHeaders) { + return; + } + + const { 'sentry-trace': sentryTrace, baggage } = addedHeaders; + + // We do not want to overwrite existing headers here + // If the core UndiciInstrumentation is registered, it will already have set the headers + // We do not want to add any then + if (Array.isArray(request.headers)) { + const requestHeaders = request.headers; + + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !requestHeaders.includes(SENTRY_TRACE_HEADER)) { + requestHeaders.push(SENTRY_TRACE_HEADER, sentryTrace); + } + + // For baggage, we make sure to merge this into a possibly existing header + const existingBaggagePos = requestHeaders.findIndex(header => header === SENTRY_BAGGAGE_HEADER); + if (baggage && existingBaggagePos === -1) { + requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage); + } else if (baggage) { + const existingBaggage = requestHeaders[existingBaggagePos + 1]; + const merged = mergeBaggageHeaders(existingBaggage, baggage); + if (merged) { + requestHeaders[existingBaggagePos + 1] = merged; + } + } + } else { + const requestHeaders = request.headers; + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !requestHeaders.includes(`${SENTRY_TRACE_HEADER}:`)) { + request.headers += `${SENTRY_TRACE_HEADER}: ${sentryTrace}\r\n`; + } + + const existingBaggage = request.headers.match(BAGGAGE_HEADER_REGEX)?.[1]; + if (baggage && !existingBaggage) { + request.headers += `${SENTRY_BAGGAGE_HEADER}: ${baggage}\r\n`; + } else if (baggage) { + const merged = mergeBaggageHeaders(existingBaggage, baggage); + if (merged) { + request.headers = request.headers.replace(BAGGAGE_HEADER_REGEX, `baggage: ${merged}\r\n`); + } + } + } + } + + /** + * This method is called when a response is received. + */ + private _onResponseHeaders({ request, response }: { request: UndiciRequest; response: UndiciResponse }): void { + const config = this.getConfig(); + const enabled = config.enabled !== false; + + if (!enabled) { + return; + } + + const _breadcrumbs = config.breadcrumbs; + const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; + + const _ignoreOutgoingRequests = config.ignoreOutgoingRequests; + const shouldCreateBreadcrumb = + typeof _ignoreOutgoingRequests === 'function' + ? !_ignoreOutgoingRequests(getAbsoluteUrl(request.origin, request.path)) + : true; + + if (breadCrumbsEnabled && shouldCreateBreadcrumb) { + addRequestBreadcrumb(request, response); + } + } + + /** Subscribe to a diagnostics channel. */ + private _subscribeToChannel( + diagnosticChannel: string, + onMessage: (message: unknown, name: string | symbol) => void, + ): void { + // `diagnostics_channel` had a ref counting bug until v18.19.0. + // https://github.com/nodejs/node/pull/47520 + const useNewSubscribe = NODE_MAJOR > 18 || (NODE_MAJOR === 18 && NODE_MINOR >= 19); + + let unsubscribe: () => void; + if (useNewSubscribe) { + diagch.subscribe?.(diagnosticChannel, onMessage); + unsubscribe = () => diagch.unsubscribe?.(diagnosticChannel, onMessage); + } else { + const channel = diagch.channel(diagnosticChannel); + channel.subscribe(onMessage); + unsubscribe = () => channel.unsubscribe(onMessage); + } + + this._channelSubs.push({ + name: diagnosticChannel, + unsubscribe, + }); + } +} + +/** Add a breadcrumb for outgoing requests. */ +function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void { + const data = getBreadcrumbData(request); + + const statusCode = response.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + + addBreadcrumb( + { + category: 'http', + data: { + status_code: statusCode, + ...data, + }, + type: 'http', + level, + }, + { + event: 'response', + request, + response, + }, + ); +} + +function getBreadcrumbData(request: UndiciRequest): Partial { + try { + const url = getAbsoluteUrl(request.origin, request.path); + const parsedUrl = parseUrl(url); + + const data: Partial = { + url: getSanitizedUrlString(parsedUrl), + 'http.method': request.method || 'GET', + }; + + if (parsedUrl.search) { + data['http.query'] = parsedUrl.search; + } + if (parsedUrl.hash) { + data['http.fragment'] = parsedUrl.hash; + } + + return data; + } catch { + return {}; + } +} + +function getAbsoluteUrl(origin: string, path: string = '/'): string { + try { + const url = new URL(path, origin); + return url.toString(); + } catch { + // fallback: Construct it on our own + const url = `${origin}`; + + if (url.endsWith('/') && path.startsWith('/')) { + return `${url}${path.slice(1)}`; + } + + if (!url.endsWith('/') && !path.startsWith('/')) { + return `${url}/${path.slice(1)}`; + } + + return `${url}${path}`; + } +} diff --git a/packages/node/src/integrations/node-fetch/index.ts b/packages/node/src/integrations/node-fetch/index.ts new file mode 100644 index 000000000000..dc0df9b5ef57 --- /dev/null +++ b/packages/node/src/integrations/node-fetch/index.ts @@ -0,0 +1,108 @@ +import type { UndiciInstrumentationConfig } from '@opentelemetry/instrumentation-undici'; +import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; +import type { IntegrationFn } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration, getClient } from '@sentry/core'; +import { generateInstrumentOnce } from '../../otel/instrument'; +import type { NodeClient } from '../../sdk/client'; +import type { NodeClientOptions } from '../../types'; +import type { SentryNodeFetchInstrumentationOptions } from './SentryNodeFetchInstrumentation'; +import { SentryNodeFetchInstrumentation } from './SentryNodeFetchInstrumentation'; + +const INTEGRATION_NAME = 'NodeFetch'; + +interface NodeFetchOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * Defaults to true + */ + breadcrumbs?: boolean; + + /** + * If set to false, do not emit any spans. + * This will ensure that the default UndiciInstrumentation from OpenTelemetry is not setup, + * only the Sentry-specific instrumentation for breadcrumbs & trace propagation is applied. + * + * If `skipOpenTelemetrySetup: true` is configured, this defaults to `false`, otherwise it defaults to `true`. + */ + spans?: boolean; + + /** + * Do not capture spans or breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + */ + ignoreOutgoingRequests?: (url: string) => boolean; +} + +const instrumentOtelNodeFetch = generateInstrumentOnce(INTEGRATION_NAME, config => { + return new UndiciInstrumentation(config); +}); + +const instrumentSentryNodeFetch = generateInstrumentOnce( + `${INTEGRATION_NAME}.sentry`, + config => { + return new SentryNodeFetchInstrumentation(config); + }, +); + +const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { + return { + name: 'NodeFetch', + setupOnce() { + const instrumentSpans = _shouldInstrumentSpans(options, getClient()?.getOptions()); + + // This is the "regular" OTEL instrumentation that emits spans + if (instrumentSpans) { + const instrumentationConfig = getConfigWithDefaults(options); + instrumentOtelNodeFetch(instrumentationConfig); + } + + // This is the Sentry-specific instrumentation that creates breadcrumbs & propagates traces + // This must be registered after the OTEL one, to ensure that the core trace propagation logic takes presedence + // Otherwise, the sentry-trace header may be set multiple times + instrumentSentryNodeFetch(options); + }, + }; +}) satisfies IntegrationFn; + +export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration); + +// Matching the behavior of the base instrumentation +function getAbsoluteUrl(origin: string, path: string = '/'): string { + const url = `${origin}`; + + if (url.endsWith('/') && path.startsWith('/')) { + return `${url}${path.slice(1)}`; + } + + if (!url.endsWith('/') && !path.startsWith('/')) { + return `${url}/${path.slice(1)}`; + } + + return `${url}${path}`; +} + +function _shouldInstrumentSpans(options: NodeFetchOptions, clientOptions: Partial = {}): boolean { + // If `spans` is passed in, it takes precedence + // Else, we by default emit spans, unless `skipOpenTelemetrySetup` is set to `true` + return typeof options.spans === 'boolean' ? options.spans : !clientOptions.skipOpenTelemetrySetup; +} + +function getConfigWithDefaults(options: Partial = {}): UndiciInstrumentationConfig { + const instrumentationConfig = { + requireParentforSpans: false, + ignoreRequestHook: request => { + const url = getAbsoluteUrl(request.origin, request.path); + const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; + const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); + + return !!shouldIgnore; + }, + startSpanHook: () => { + return { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', + }; + }, + } satisfies UndiciInstrumentationConfig; + + return instrumentationConfig; +} diff --git a/packages/node/src/integrations/node-fetch/types.ts b/packages/node/src/integrations/node-fetch/types.ts new file mode 100644 index 000000000000..0139dadde413 --- /dev/null +++ b/packages/node/src/integrations/node-fetch/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Vendored from https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/types.ts + */ + +export interface UndiciRequest { + origin: string; + method: string; + path: string; + /** + * Serialized string of headers in the form `name: value\r\n` for v5 + * Array of strings v6 + */ + headers: string | string[]; + /** + * Helper method to add headers (from v6) + */ + addHeader: (name: string, value: string) => void; + throwOnError: boolean; + completed: boolean; + aborted: boolean; + idempotent: boolean; + contentLength: number | null; + contentType: string | null; + body: unknown; +} + +export interface UndiciResponse { + headers: Buffer[]; + statusCode: number; + statusText: string; +} diff --git a/packages/node/src/nodeVersion.ts b/packages/node/src/nodeVersion.ts index 1ec745743620..86c761543de7 100644 --- a/packages/node/src/nodeVersion.ts +++ b/packages/node/src/nodeVersion.ts @@ -2,3 +2,4 @@ import { parseSemver } from '@sentry/core'; export const NODE_VERSION = parseSemver(process.versions.node) as { major: number; minor: number; patch: number }; export const NODE_MAJOR = NODE_VERSION.major; +export const NODE_MINOR = NODE_VERSION.minor; diff --git a/packages/node/src/utils/baggage.ts b/packages/node/src/utils/baggage.ts new file mode 100644 index 000000000000..be8e62b9497b --- /dev/null +++ b/packages/node/src/utils/baggage.ts @@ -0,0 +1,31 @@ +import { objectToBaggageHeader, parseBaggageHeader } from '@sentry/core'; + +/** + * Merge two baggage headers into one, where the existing one takes precedence. + * The order of the existing baggage will be preserved, and new entries will be added to the end. + */ +export function mergeBaggageHeaders( + existing: Existing, + baggage: string, +): string | undefined | Existing { + if (!existing) { + return baggage; + } + + const existingBaggageEntries = parseBaggageHeader(existing); + const newBaggageEntries = parseBaggageHeader(baggage); + + if (!newBaggageEntries) { + return existing; + } + + // Existing entries take precedence, ensuring order remains stable for minimal changes + const mergedBaggageEntries = { ...existingBaggageEntries }; + Object.entries(newBaggageEntries).forEach(([key, value]) => { + if (!mergedBaggageEntries[key]) { + mergedBaggageEntries[key] = value; + } + }); + + return objectToBaggageHeader(mergedBaggageEntries); +} From 3d16a079c6fe6d9cb381acfcc804addfdee21b3b Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 12 Feb 2025 13:37:44 +0100 Subject: [PATCH 05/14] feat(core): Allow for nested trpc context (#15379) As reported in https://discord.com/channels/621778831602221064/1339165240482463816/1339165240482463816 we are not capturing trpc input deeply. --- .../test-applications/node-express/src/app.ts | 8 +++++--- .../test-applications/node-express/tests/trpc.test.ts | 11 ++++++++++- packages/core/src/trpc.ts | 8 ++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index de240b761df0..d756c0e08372 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -115,9 +115,11 @@ export const appRouter = t.router({ await new Promise(resolve => setTimeout(resolve, 400)); return { success: true }; }), - crashSomething: procedure.mutation(() => { - throw new Error('I crashed in a trpc handler'); - }), + crashSomething: procedure + .input(z.object({ nested: z.object({ nested: z.object({ nested: z.string() }) }) })) + .mutation(() => { + throw new Error('I crashed in a trpc handler'); + }), dontFindSomething: procedure.mutation(() => { throw new TRPCError({ code: 'NOT_FOUND', cause: new Error('Page not found') }); }), diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts index fcdd9b39a103..633306ae713a 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts @@ -87,13 +87,22 @@ test('Should record transaction and error for a crashing trpc handler', async ({ ], }); - await expect(trpcClient.crashSomething.mutate()).rejects.toBeDefined(); + await expect(trpcClient.crashSomething.mutate({ nested: { nested: { nested: 'foobar' } } })).rejects.toBeDefined(); await expect(transactionEventPromise).resolves.toBeDefined(); await expect(errorEventPromise).resolves.toBeDefined(); expect((await errorEventPromise).contexts?.trpc?.['procedure_type']).toBe('mutation'); expect((await errorEventPromise).contexts?.trpc?.['procedure_path']).toBe('crashSomething'); + + // Should record nested context + expect((await errorEventPromise).contexts?.trpc?.['input']).toEqual({ + nested: { + nested: { + nested: 'foobar', + }, + }, + }); }); test('Should record transaction and error for a trpc handler that returns a status code', async ({ baseURL }) => { diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index e9b4f733078a..571425deb51e 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -2,6 +2,7 @@ import { getClient, withScope } from './currentScopes'; import { captureException } from './exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { startSpanManual } from './tracing'; +import { addNonEnumerableProperty } from './utils-hoist'; import { normalize } from './utils-hoist/normalize'; interface SentryTrpcMiddlewareOptions { @@ -51,6 +52,13 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { procedure_type: type, }; + addNonEnumerableProperty( + trpcContext, + '__sentry_override_normalization_depth__', + 1 + // 1 for context.input + the normal normalization depth + (clientOptions?.normalizeDepth ?? 5), // 5 is a sane depth + ); + if (options.attachRpcInput !== undefined ? options.attachRpcInput : clientOptions?.sendDefaultPii) { if (rawInput !== undefined) { trpcContext.input = normalize(rawInput); From e21bcc261cd606c6204435ea27f7e49f32f6e3f1 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:12:22 +0100 Subject: [PATCH 06/14] feat(vue): Support Pinia v3 (#15383) Pinia had a boring major release yesterday: [Migration Guide on their website](https://pinia.vuejs.org/cookbook/migration-v2-v3.html#Migrating-from-v2-to-v3). As they mainly removed deprecations, I just upgraded the pinia to v3 in the E2E test. closes https://github.com/getsentry/sentry-javascript/issues/15378 --- dev-packages/e2e-tests/test-applications/vue-3/package.json | 2 +- packages/vue/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 54cbc679e0f4..c968f558673c 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@sentry/vue": "latest || *", - "pinia": "^2.2.3", + "pinia": "^3.0.0", "vue": "^3.4.15", "vue-router": "^4.2.5" }, diff --git a/packages/vue/package.json b/packages/vue/package.json index 56c84c4176ea..4a6f42247e4b 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -43,7 +43,7 @@ "@sentry/core": "9.0.1" }, "peerDependencies": { - "pinia": "2.x", + "pinia": "2.x || 3.x", "vue": "2.x || 3.x" }, "peerDependenciesMeta": { From 50942ce5c960be8690613257bc956b9be1b6a346 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 12 Feb 2025 15:33:12 +0100 Subject: [PATCH 07/14] ci: Disable v8 compile cache functionality (#15385) --- .github/workflows/build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 77c94ab78a11..aae090f76188 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,11 @@ env: nx-Linux-${{ github.ref }} nx-Linux + # https://bsky.app/profile/joyeecheung.bsky.social/post/3lhy6o54fo22h + # Apparently some of our CI failures are attributable to a corrupt v8 cache, causing v8 failures with: "Check failed: current == end_slot_index.". + # This option both controls the `v8-compile-cache-lib` and `v8-compile-cache` packages. + DISABLE_V8_COMPILE_CACHE: '1' + jobs: job_get_metadata: name: Get Metadata From 5b665b86440a3888b7ce0535d7007f67800769ca Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:16:30 -0500 Subject: [PATCH 08/14] ref(feedback): Refactor screenshot editor into multiple files (#15362) We are splitting up screenshot editor into 3 additional parts: cropping, annotations, toolbar --- .../src/screenshot/components/Annotations.tsx | 91 ++++ .../src/screenshot/components/Crop.tsx | 334 +++++++++++++++ .../components/ScreenshotEditor.tsx | 405 ++---------------- .../src/screenshot/components/Toolbar.tsx | 56 +++ 4 files changed, 510 insertions(+), 376 deletions(-) create mode 100644 packages/feedback/src/screenshot/components/Annotations.tsx create mode 100644 packages/feedback/src/screenshot/components/Crop.tsx create mode 100644 packages/feedback/src/screenshot/components/Toolbar.tsx diff --git a/packages/feedback/src/screenshot/components/Annotations.tsx b/packages/feedback/src/screenshot/components/Annotations.tsx new file mode 100644 index 000000000000..eb897b40f166 --- /dev/null +++ b/packages/feedback/src/screenshot/components/Annotations.tsx @@ -0,0 +1,91 @@ +import type { VNode, h as hType } from 'preact'; +import type * as Hooks from 'preact/hooks'; +import { DOCUMENT } from '../../constants'; + +interface FactoryParams { + h: typeof hType; +} + +export default function AnnotationsFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function Annotations({ + action, + imageBuffer, + annotatingRef, + }: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + annotatingRef: Hooks.Ref; + }): VNode { + const onAnnotateStart = (): void => { + if (action !== 'annotate') { + return; + } + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const annotateCanvas = annotatingRef.current; + if (annotateCanvas) { + const rect = annotateCanvas.getBoundingClientRect(); + const x = moveEvent.clientX - rect.x; + const y = moveEvent.clientY - rect.y; + + const ctx = annotateCanvas.getContext('2d'); + if (ctx) { + ctx.lineTo(x, y); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y); + } + } + }; + + const handleMouseUp = (): void => { + const ctx = annotatingRef.current?.getContext('2d'); + if (ctx) { + ctx.beginPath(); + } + + // Add your apply annotation logic here + applyAnnotation(); + + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const applyAnnotation = (): void => { + // Logic to apply the annotation + const imageCtx = imageBuffer.getContext('2d'); + const annotateCanvas = annotatingRef.current; + if (imageCtx && annotateCanvas) { + imageCtx.drawImage( + annotateCanvas, + 0, + 0, + annotateCanvas.width, + annotateCanvas.height, + 0, + 0, + imageBuffer.width, + imageBuffer.height, + ); + + const annotateCtx = annotateCanvas.getContext('2d'); + if (annotateCtx) { + annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height); + } + } + }; + return ( + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/Crop.tsx b/packages/feedback/src/screenshot/components/Crop.tsx new file mode 100644 index 000000000000..e019d8c510e0 --- /dev/null +++ b/packages/feedback/src/screenshot/components/Crop.tsx @@ -0,0 +1,334 @@ +import type { FeedbackInternalOptions } from '@sentry/core'; +import type { VNode, h as hType } from 'preact'; +import type * as Hooks from 'preact/hooks'; +import { DOCUMENT, WINDOW } from '../../constants'; +import CropCornerFactory from './CropCorner'; + +const CROP_BUTTON_SIZE = 30; +const CROP_BUTTON_BORDER = 3; +const CROP_BUTTON_OFFSET = CROP_BUTTON_SIZE + CROP_BUTTON_BORDER; +const DPI = WINDOW.devicePixelRatio; + +interface Box { + startX: number; + startY: number; + endX: number; + endY: number; +} + +interface Rect { + x: number; + y: number; + height: number; + width: number; +} + +const constructRect = (box: Box): Rect => ({ + x: Math.min(box.startX, box.endX), + y: Math.min(box.startY, box.endY), + width: Math.abs(box.startX - box.endX), + height: Math.abs(box.startY - box.endY), +}); + +const getContainedSize = (img: HTMLCanvasElement): Rect => { + const imgClientHeight = img.clientHeight; + const imgClientWidth = img.clientWidth; + const ratio = img.width / img.height; + let width = imgClientHeight * ratio; + let height = imgClientHeight; + if (width > imgClientWidth) { + width = imgClientWidth; + height = imgClientWidth / ratio; + } + const x = (imgClientWidth - width) / 2; + const y = (imgClientHeight - height) / 2; + return { x: x, y: y, width: width, height: height }; +}; + +interface FactoryParams { + h: typeof hType; + hooks: typeof Hooks; + options: FeedbackInternalOptions; +} + +export default function CropFactory({ h, hooks, options }: FactoryParams): (props: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + croppingRef: Hooks.Ref; + cropContainerRef: Hooks.Ref; + croppingRect: Box; + setCroppingRect: Hooks.StateUpdater; + resize: () => void; +}) => VNode { + const CropCorner = CropCornerFactory({ h }); + return function Crop({ + action, + imageBuffer, + croppingRef, + cropContainerRef, + croppingRect, + setCroppingRect, + resize, + }: { + action: 'crop' | 'annotate' | ''; + imageBuffer: HTMLCanvasElement; + croppingRef: Hooks.Ref; + cropContainerRef: Hooks.Ref; + croppingRect: Box; + setCroppingRect: Hooks.StateUpdater; + resize: () => void; + }): VNode { + const initialPositionRef = hooks.useRef({ initialX: 0, initialY: 0 }); + + const [isResizing, setIsResizing] = hooks.useState(false); + const [confirmCrop, setConfirmCrop] = hooks.useState(false); + + hooks.useEffect(() => { + const cropper = croppingRef.current; + if (!cropper) { + return; + } + + const ctx = cropper.getContext('2d'); + if (!ctx) { + return; + } + + const imageDimensions = getContainedSize(imageBuffer); + const croppingBox = constructRect(croppingRect); + ctx.clearRect(0, 0, imageDimensions.width, imageDimensions.height); + + if (action !== 'crop') { + return; + } + + // draw gray overlay around the selection + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillRect(0, 0, imageDimensions.width, imageDimensions.height); + ctx.clearRect(croppingBox.x, croppingBox.y, croppingBox.width, croppingBox.height); + + // draw selection border + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 3; + ctx.strokeRect(croppingBox.x + 1, croppingBox.y + 1, croppingBox.width - 2, croppingBox.height - 2); + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.strokeRect(croppingBox.x + 3, croppingBox.y + 3, croppingBox.width - 6, croppingBox.height - 6); + }, [croppingRect, action]); + + // Resizing logic + const makeHandleMouseMove = hooks.useCallback((corner: string) => { + return (e: MouseEvent) => { + if (!croppingRef.current) { + return; + } + + const cropCanvas = croppingRef.current; + const cropBoundingRect = cropCanvas.getBoundingClientRect(); + const mouseX = e.clientX - cropBoundingRect.x; + const mouseY = e.clientY - cropBoundingRect.y; + + switch (corner) { + case 'top-left': + setCroppingRect(prev => ({ + ...prev, + startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), + startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), + })); + break; + case 'top-right': + setCroppingRect(prev => ({ + ...prev, + endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), + startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), + })); + break; + case 'bottom-left': + setCroppingRect(prev => ({ + ...prev, + startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), + endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), + })); + break; + case 'bottom-right': + setCroppingRect(prev => ({ + ...prev, + endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), + endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), + })); + break; + } + }; + }, []); + + // Dragging logic + const onDragStart = (e: MouseEvent): void => { + if (isResizing) { + return; + } + + initialPositionRef.current = { initialX: e.clientX, initialY: e.clientY }; + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const cropCanvas = croppingRef.current; + if (!cropCanvas) { + return; + } + + const deltaX = moveEvent.clientX - initialPositionRef.current.initialX; + const deltaY = moveEvent.clientY - initialPositionRef.current.initialY; + + setCroppingRect(prev => { + const newStartX = Math.max( + 0, + Math.min(prev.startX + deltaX, cropCanvas.width / DPI - (prev.endX - prev.startX)), + ); + const newStartY = Math.max( + 0, + Math.min(prev.startY + deltaY, cropCanvas.height / DPI - (prev.endY - prev.startY)), + ); + + const newEndX = newStartX + (prev.endX - prev.startX); + const newEndY = newStartY + (prev.endY - prev.startY); + + initialPositionRef.current.initialX = moveEvent.clientX; + initialPositionRef.current.initialY = moveEvent.clientY; + + return { startX: newStartX, startY: newStartY, endX: newEndX, endY: newEndY }; + }); + }; + + const handleMouseUp = (): void => { + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const onGrabButton = (e: Event, corner: string): void => { + setIsResizing(true); + const handleMouseMove = makeHandleMouseMove(corner); + const handleMouseUp = (): void => { + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + setConfirmCrop(true); + setIsResizing(false); + }; + + DOCUMENT.addEventListener('mouseup', handleMouseUp); + DOCUMENT.addEventListener('mousemove', handleMouseMove); + }; + + function applyCrop(): void { + const cutoutCanvas = DOCUMENT.createElement('canvas'); + const imageBox = getContainedSize(imageBuffer); + const croppingBox = constructRect(croppingRect); + cutoutCanvas.width = croppingBox.width * DPI; + cutoutCanvas.height = croppingBox.height * DPI; + + const cutoutCtx = cutoutCanvas.getContext('2d'); + if (cutoutCtx && imageBuffer) { + cutoutCtx.drawImage( + imageBuffer, + (croppingBox.x / imageBox.width) * imageBuffer.width, + (croppingBox.y / imageBox.height) * imageBuffer.height, + (croppingBox.width / imageBox.width) * imageBuffer.width, + (croppingBox.height / imageBox.height) * imageBuffer.height, + 0, + 0, + cutoutCanvas.width, + cutoutCanvas.height, + ); + } + + const ctx = imageBuffer.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, imageBuffer.width, imageBuffer.height); + imageBuffer.width = cutoutCanvas.width; + imageBuffer.height = cutoutCanvas.height; + imageBuffer.style.width = `${croppingBox.width}px`; + imageBuffer.style.height = `${croppingBox.height}px`; + ctx.drawImage(cutoutCanvas, 0, 0); + + resize(); + } + } + + return ( +
+ + {action === 'crop' && ( +
+ + + + +
+ )} + {action === 'crop' && ( +
+ + +
+ )} +
+ ); + }; +} diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index 1de5759efad0..9f49abf60e6f 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -1,19 +1,15 @@ -/* eslint-disable max-lines */ import type { FeedbackInternalOptions, FeedbackModalIntegration } from '@sentry/core'; import type { ComponentType, VNode, h as hType } from 'preact'; // biome-ignore lint/nursery/noUnusedImports: reason import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars import type * as Hooks from 'preact/hooks'; -import { DOCUMENT, WINDOW } from '../../constants'; -import CropCornerFactory from './CropCorner'; -import CropIconFactory from './CropIcon'; -import PenIconFactory from './PenIcon'; +import { WINDOW } from '../../constants'; +import AnnotationsFactory from './Annotations'; +import CropFactory from './Crop'; import { createScreenshotInputStyles } from './ScreenshotInput.css'; +import ToolbarFactory from './Toolbar'; import { useTakeScreenshotFactory } from './useTakeScreenshot'; -const CROP_BUTTON_SIZE = 30; -const CROP_BUTTON_BORDER = 3; -const CROP_BUTTON_OFFSET = CROP_BUTTON_SIZE + CROP_BUTTON_BORDER; const DPI = WINDOW.devicePixelRatio; interface FactoryParams { @@ -42,16 +38,7 @@ interface Rect { width: number; } -const constructRect = (box: Box): Rect => { - return { - x: Math.min(box.startX, box.endX), - y: Math.min(box.startY, box.endY), - width: Math.abs(box.startX - box.endX), - height: Math.abs(box.startY - box.endY), - }; -}; - -const getContainedSize = (img: HTMLCanvasElement): Box => { +const getContainedSize = (img: HTMLCanvasElement): Rect => { const imgClientHeight = img.clientHeight; const imgClientWidth = img.clientWidth; const ratio = img.width / img.height; @@ -63,7 +50,7 @@ const getContainedSize = (img: HTMLCanvasElement): Box => { } const x = (imgClientWidth - width) / 2; const y = (imgClientHeight - height) / 2; - return { startX: x, startY: y, endX: width + x, endY: height + y }; + return { x: x, y: y, width: width, height: height }; }; export function ScreenshotEditorFactory({ @@ -74,22 +61,24 @@ export function ScreenshotEditorFactory({ options, }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); - const CropCorner = CropCornerFactory({ h }); - const PenIcon = PenIconFactory({ h }); - const CropIcon = CropIconFactory({ h }); + const Toolbar = ToolbarFactory({ h }); + const Annotations = AnnotationsFactory({ h }); + const Crop = CropFactory({ h, hooks, options }); return function ScreenshotEditor({ onError }: Props): VNode { const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); const canvasContainerRef = hooks.useRef(null); const cropContainerRef = hooks.useRef(null); - const croppingRef = hooks.useRef(null); const annotatingRef = hooks.useRef(null); - 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); + const croppingRef = hooks.useRef(null); + const [action, setAction] = hooks.useState<'annotate' | 'crop' | ''>('crop'); + const [croppingRect, setCroppingRect] = hooks.useState({ + startX: 0, + startY: 0, + endX: 0, + endY: 0, + }); hooks.useEffect(() => { WINDOW.addEventListener('resize', resize); @@ -116,7 +105,7 @@ export function ScreenshotEditorFactory({ } function resize(): void { - const imageDimensions = constructRect(getContainedSize(imageBuffer)); + const imageDimensions = getContainedSize(imageBuffer); resizeCanvas(croppingRef, imageDimensions); resizeCanvas(annotatingRef, imageDimensions); @@ -130,248 +119,6 @@ export function ScreenshotEditorFactory({ setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height }); } - hooks.useEffect(() => { - const cropper = croppingRef.current; - if (!cropper) { - return; - } - - const ctx = cropper.getContext('2d'); - if (!ctx) { - return; - } - - const imageDimensions = constructRect(getContainedSize(imageBuffer)); - 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); - ctx.clearRect(croppingBox.x, croppingBox.y, croppingBox.width, croppingBox.height); - - // draw selection border - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 3; - ctx.strokeRect(croppingBox.x + 1, croppingBox.y + 1, croppingBox.width - 2, croppingBox.height - 2); - ctx.strokeStyle = '#000000'; - ctx.lineWidth = 1; - ctx.strokeRect(croppingBox.x + 3, croppingBox.y + 3, croppingBox.width - 6, croppingBox.height - 6); - }, [croppingRect, isCropping]); - - function onGrabButton(e: Event, corner: string): void { - setIsAnnotating(false); - setConfirmCrop(false); - setIsResizing(true); - const handleMouseMove = makeHandleMouseMove(corner); - const handleMouseUp = (): void => { - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - setConfirmCrop(true); - setIsResizing(false); - }; - - DOCUMENT.addEventListener('mouseup', handleMouseUp); - DOCUMENT.addEventListener('mousemove', handleMouseMove); - } - - const makeHandleMouseMove = hooks.useCallback((corner: string) => { - return function (e: MouseEvent) { - if (!croppingRef.current) { - return; - } - const cropCanvas = croppingRef.current; - const cropBoundingRect = cropCanvas.getBoundingClientRect(); - const mouseX = e.clientX - cropBoundingRect.x; - const mouseY = e.clientY - cropBoundingRect.y; - switch (corner) { - case 'top-left': - setCroppingRect(prev => ({ - ...prev, - startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), - startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), - })); - break; - case 'top-right': - setCroppingRect(prev => ({ - ...prev, - endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), - startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), - })); - break; - case 'bottom-left': - setCroppingRect(prev => ({ - ...prev, - startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), - endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), - })); - break; - case 'bottom-right': - setCroppingRect(prev => ({ - ...prev, - endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), - endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), - })); - break; - } - }; - }, []); - - // DRAGGING FUNCTIONALITY. - const initialPositionRef = hooks.useRef({ initialX: 0, initialY: 0 }); - - function onDragStart(e: MouseEvent): void { - if (isResizing) return; - - initialPositionRef.current = { initialX: e.clientX, initialY: e.clientY }; - - const handleMouseMove = (moveEvent: MouseEvent): void => { - const cropCanvas = croppingRef.current; - if (!cropCanvas) return; - - const deltaX = moveEvent.clientX - initialPositionRef.current.initialX; - const deltaY = moveEvent.clientY - initialPositionRef.current.initialY; - - setCroppingRect(prev => { - // Math.max stops it from going outside of the borders - const newStartX = Math.max( - 0, - Math.min(prev.startX + deltaX, cropCanvas.width / DPI - (prev.endX - prev.startX)), - ); - const newStartY = Math.max( - 0, - Math.min(prev.startY + deltaY, cropCanvas.height / DPI - (prev.endY - prev.startY)), - ); - // Don't want to change size, just position - const newEndX = newStartX + (prev.endX - prev.startX); - const newEndY = newStartY + (prev.endY - prev.startY); - - initialPositionRef.current.initialX = moveEvent.clientX; - initialPositionRef.current.initialY = moveEvent.clientY; - - return { - startX: newStartX, - startY: newStartY, - endX: newEndX, - endY: newEndY, - }; - }); - }; - - const handleMouseUp = (): void => { - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - } - - function onAnnotateStart(): void { - if (!isAnnotating) { - return; - } - - const handleMouseMove = (moveEvent: MouseEvent): void => { - const annotateCanvas = annotatingRef.current; - if (annotateCanvas) { - const rect = annotateCanvas.getBoundingClientRect(); - - const x = moveEvent.clientX - rect.x; - const y = moveEvent.clientY - rect.y; - - const ctx = annotateCanvas.getContext('2d'); - if (ctx) { - ctx.lineTo(x, y); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(x, y); - } - } - }; - - const handleMouseUp = (): void => { - const ctx = annotatingRef.current?.getContext('2d'); - // starts a new path so on next mouse down, the lines won't connect - if (ctx) { - ctx.beginPath(); - } - - // draws the annotation onto the image buffer - // TODO: move this to a better place - applyAnnotation(); - - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - } - - function applyCrop(): void { - const cutoutCanvas = DOCUMENT.createElement('canvas'); - const imageBox = constructRect(getContainedSize(imageBuffer)); - const croppingBox = constructRect(croppingRect); - cutoutCanvas.width = croppingBox.width * DPI; - cutoutCanvas.height = croppingBox.height * DPI; - - const cutoutCtx = cutoutCanvas.getContext('2d'); - if (cutoutCtx && imageBuffer) { - cutoutCtx.drawImage( - imageBuffer, - (croppingBox.x / imageBox.width) * imageBuffer.width, - (croppingBox.y / imageBox.height) * imageBuffer.height, - (croppingBox.width / imageBox.width) * imageBuffer.width, - (croppingBox.height / imageBox.height) * imageBuffer.height, - 0, - 0, - cutoutCanvas.width, - cutoutCanvas.height, - ); - } - - const ctx = imageBuffer.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, imageBuffer.width, imageBuffer.height); - imageBuffer.width = cutoutCanvas.width; - imageBuffer.height = cutoutCanvas.height; - imageBuffer.style.width = `${croppingBox.width}px`; - imageBuffer.style.height = `${croppingBox.height}px`; - ctx.drawImage(cutoutCanvas, 0, 0); - resize(); - } - } - - function applyAnnotation(): void { - // draw the annotations onto the image (ie "squash" the canvases) - const imageCtx = imageBuffer.getContext('2d'); - const annotateCanvas = annotatingRef.current; - if (imageCtx && annotateCanvas) { - imageCtx.drawImage( - annotateCanvas, - 0, - 0, - annotateCanvas.width, - annotateCanvas.height, - 0, - 0, - imageBuffer.width, - imageBuffer.height, - ); - - // clear the annotation canvas - const annotateCtx = annotateCanvas.getContext('2d'); - if (annotateCtx) { - annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height); - } - } - } - useTakeScreenshot({ onBeforeScreenshot: hooks.useCallback(() => { (dialog.el as HTMLElement).style.display = 'none'; @@ -407,113 +154,19 @@ export function ScreenshotEditorFactory({