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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f5225f8d557b..d91d3da02552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.1.0 + +- feat(browser): Add `graphqlClientIntegration` ([#13783](https://github.com/getsentry/sentry-javascript/pull/13783)) +- feat(core): Allow for nested trpc context ([#15379](https://github.com/getsentry/sentry-javascript/pull/15379)) +- feat(core): Create types and utilities for span links ([#15375](https://github.com/getsentry/sentry-javascript/pull/15375)) +- feat(deps): bump @opentelemetry/instrumentation-pg from 0.50.0 to 0.51.0 ([#15273](https://github.com/getsentry/sentry-javascript/pull/15273)) +- feat(node): Extract Sentry-specific node-fetch instrumentation ([#15231](https://github.com/getsentry/sentry-javascript/pull/15231)) +- feat(vue): Support Pinia v3 ([#15383](https://github.com/getsentry/sentry-javascript/pull/15383)) +- fix(sveltekit): Avoid request body double read errors ([#15368](https://github.com/getsentry/sentry-javascript/pull/15368)) +- fix(sveltekit): Avoid top-level `vite` import ([#15371](https://github.com/getsentry/sentry-javascript/pull/15371)) + +Work in this release was contributed by @Zen-cronic and @filips-alpe. Thank you for your contribution! + ## 9.0.1 - ref(flags): rename unleash integration param ([#15343](https://github.com/getsentry/sentry-javascript/pull/15343)) 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/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 8a0eb8010128..039a9eb8760b 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -16,7 +16,7 @@ "clean": "rimraf tmp node_modules && yarn clean:test-applications && yarn clean:pnpm", "ci:build-matrix": "ts-node ./lib/getTestMatrix.ts", "ci:build-matrix-optional": "ts-node ./lib/getTestMatrix.ts --optional=true", - "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,pnpm-lock.yaml,.last-run.json,test-results}", + "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,pnpm-lock.yaml,.last-run.json,test-results}", "clean:pnpm": "pnpm store prune" }, "devDependencies": { 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/dev-packages/e2e-tests/test-applications/react-router-7-framework/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework/.gitignore new file mode 100644 index 000000000000..ebb991370034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts + +# react router +.react-router diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-framework/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/app.css new file mode 100644 index 000000000000..b31c3a9d0ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/app.css @@ -0,0 +1,6 @@ +html, +body { + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx new file mode 100644 index 000000000000..2200fcea97c3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/react-router'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // todo: get this from env + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, // proxy server + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx new file mode 100644 index 000000000000..faa62bd97197 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx @@ -0,0 +1,73 @@ +import { PassThrough } from 'node:stream'; + +import { createReadableStreamFromReadable } from '@react-router/node'; +import * as Sentry from '@sentry/react-router'; +import { isbot } from 'isbot'; +import type { RenderToPipeableStreamOptions } from 'react-dom/server'; +import { renderToPipeableStream } from 'react-dom/server'; +import type { AppLoadContext, EntryContext } from 'react-router'; +import { ServerRouter } from 'react-router'; +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + let userAgent = request.headers.get('user-agent'); + + // Ensure requests from bots and SPA Mode renders wait for all content to load before responding + // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation + let readyOption: keyof RenderToPipeableStreamOptions = + (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady'; + + const { pipe, abort } = renderToPipeableStream(, { + [readyOption]() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }); + + setTimeout(abort, ABORT_DELAY); + }); +} + +import { type HandleErrorFunction } from 'react-router'; + +export const handleError: HandleErrorFunction = (error, { request }) => { + // React Router may abort some interrupted requests, don't log those + if (!request.signal.aborted) { + Sentry.captureException(error); + + // make sure to still log the error so you can see it + console.error(error); + } +}; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx new file mode 100644 index 000000000000..227c08f7730c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx @@ -0,0 +1,69 @@ +import * as Sentry from '@sentry/react-router'; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + { rel: 'stylesheet', href: stylesheet }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (error && error instanceof Error) { + Sentry.captureException(error); + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts new file mode 100644 index 000000000000..bb7472366681 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -0,0 +1,18 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + ...prefix('errors', [ + route('client', 'routes/errors/client.tsx'), + route('client/:client-param', 'routes/errors/client-param.tsx'), + route('client-loader', 'routes/errors/client-loader.tsx'), + route('server-loader', 'routes/errors/server-loader.tsx'), + route('client-action', 'routes/errors/client-action.tsx'), + route('server-action', 'routes/errors/server-action.tsx'), + ]), + ...prefix('performance', [ + index('routes/performance/index.tsx'), + route('with/:param', 'routes/performance/dynamic-param.tsx'), + route('static', 'routes/performance/static.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-action.tsx new file mode 100644 index 000000000000..d3b2d08eef2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function clientAction() { + throw new Error('Madonna mia! Che casino nella Client Action!'); +} + +export default function ClientActionErrorPage() { + return ( +
+

Client Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-loader.tsx new file mode 100644 index 000000000000..72d9e62a99dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function clientLoader() { + throw new Error('¡Madre mía del client loader!'); + return { data: 'sad' }; +} + +export default function ClientLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Client Loader Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-param.tsx new file mode 100644 index 000000000000..a2e423391f03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/client-param'; + +export default function ClientErrorParamPage({ params }: Route.ComponentProps) { + return ( +
+

Client Error Param Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client.tsx new file mode 100644 index 000000000000..190074a5ef09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/client.tsx @@ -0,0 +1,15 @@ +export default function ClientErrorPage() { + return ( +
+

Client Error Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/server-action.tsx new file mode 100644 index 000000000000..863c320f3557 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/server-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function action() { + throw new Error('Madonna mia! Che casino nella Server Action!'); +} + +export default function ServerActionErrorPage() { + return ( +
+

Server Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/server-loader.tsx new file mode 100644 index 000000000000..cb777686d540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/errors/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function loader() { + throw new Error('¡Madre mía del server!'); + return { data: 'sad' }; +} + +export default function ServerLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/home.tsx new file mode 100644 index 000000000000..4498e7a0d017 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/home.tsx @@ -0,0 +1,9 @@ +import type { Route } from './+types/home'; + +export function meta({}: Route.MetaArgs) { + return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }]; +} + +export default function Home() { + return
home
; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..39cf7bd5dbf6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,12 @@ +import type { Route } from './+types/dynamic-param'; + +export default function DynamicParamPage({ params }: Route.ComponentProps) { + const { param } = params; + + return ( +
+

Dynamic Parameter Page

+

The parameter value is: {param}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx new file mode 100644 index 000000000000..9d55975e61a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx @@ -0,0 +1,3 @@ +export default function PerformancePage() { + return

Performance Page

; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/static.tsx new file mode 100644 index 000000000000..3dea24381fdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/static.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return

Static Page

; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs new file mode 100644 index 000000000000..70768dd2a6b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/react-router'; + +Sentry.init({ + // todo: grab from env + dsn: 'https://username@domain/123', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json new file mode 100644 index 000000000000..cdd96f39569e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json @@ -0,0 +1,58 @@ +{ + "name": "react-router-7-framework", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.1.5", + "@react-router/node": "^7.1.5", + "@react-router/serve": "^7.1.5", + "@sentry/react-router": "latest || *", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "@types/node": "^20", + "@react-router/dev": "^7.1.5", + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "scripts": { + "build": "react-router build", + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", + "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:ts": "pnpm typecheck", + "test:playwright": "playwright test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/playwright.config.mjs new file mode 100644 index 000000000000..3ed5721107a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `PORT=3030 pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-7-framework/public/favicon.ico new file mode 100644 index 000000000000..5dbdfcddcb14 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/react-router-7-framework/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts new file mode 100644 index 000000000000..73b647e4eea6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/react-router.config.ts @@ -0,0 +1,7 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + // todo: check why this messes up client tracing in tests + // prerender: ['/performance/static'], +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/start-event-proxy.mjs new file mode 100644 index 000000000000..7a8110ee5ccb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-framework', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/constants.ts new file mode 100644 index 000000000000..3f70e5327bd6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-framework'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts new file mode 100644 index 000000000000..d6c80924c121 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client-side errors', () => { + const errorMessage = '¡Madre mía!'; + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/client`); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + }, + }, + ], + }, + transaction: '/errors/client', + request: { + url: expect.stringContaining('errors/client'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'javascript', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'browser' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + breadcrumbs: [ + { + category: 'ui.click', + message: 'body > div > button#throw-on-click', + }, + ], + }); + }); + + test('captures error thrown on click from a parameterized route', async ({ page }) => { + const errorMessage = '¡Madre mía de churros!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client/churros'); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: '¡Madre mía de churros!', + mechanism: { + handled: false, + }, + }, + ], + }, + // todo: should be '/errors/client/:client-param' + transaction: '/errors/client/churros', + }); + }); + + test('captures error thrown in a clientLoader', async ({ page }) => { + const errorMessage = '¡Madre mía del client loader!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-loader'); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + transaction: '/errors/client-loader', + }); + }); + + test('captures error thrown in a clientAction', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Client Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-action'); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + transaction: '/errors/client-action', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts new file mode 100644 index 000000000000..d702f8cee597 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server-side errors', () => { + test('captures error thrown in server loader', async ({ page }) => { + const errorMessage = '¡Madre mía del server!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-loader`); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + // todo: should be 'GET /errors/server-loader' + transaction: 'GET *', + request: { + url: expect.stringContaining('errors/server-loader'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('captures error thrown in server action', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Server Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-action`); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: true, + }, + }, + ], + }, + // todo: should be 'POST /errors/server-action' + transaction: 'POST *', + request: { + url: expect.stringContaining('errors/server-action'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts new file mode 100644 index 000000000000..c53494c723b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - pageload performance', () => { + test('should send pageload transaction', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.browser', + 'sentry.op': 'pageload', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.browser', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance', + type: 'transaction', + transaction_info: { source: 'url' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + // todo: this page is currently not prerendered (see react-router.config.ts) + test('should send pageload transaction for prerendered pages', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/static'; + }); + + await page.goto(`/performance/static`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + transaction: '/performance/static', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.browser', + 'sentry.op': 'pageload', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.browser', + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..f080d01064ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('servery - performance', () => { + test('should send server transaction on pageload', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + // todo: should be GET /performance + return transactionEvent.transaction === 'GET *'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + // todo: should be GET /performance + transaction: 'GET *', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); + + test('should send server transaction on parameterized route', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + // todo: should be GET /performance/with/:param + return transactionEvent.transaction === 'GET *'; + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + // todo: should be GET /performance/with/:param + transaction: 'GET *', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance/with/some-param'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tsconfig.json new file mode 100644 index 000000000000..4b7a52f6bddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*", + ], +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/vite.config.ts new file mode 100644 index 000000000000..68ba30d69397 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/vite.config.ts @@ -0,0 +1,6 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [reactRouter()], +}); 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/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/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/index.ts b/packages/core/src/index.ts index f03f6b9779e9..e0e9097bbc53 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -76,6 +76,7 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize } from './utils/parameterize'; export { addAutoIpAddressToSession, addAutoIpAddressToUser } from './utils/ipAddress'; export { + convertSpanLinksForEnvelope, spanToTraceHeader, spanToJSON, spanIsSampled, diff --git a/packages/core/src/tracing/sentryNonRecordingSpan.ts b/packages/core/src/tracing/sentryNonRecordingSpan.ts index e7c9a8e9ac41..b4a5a7c8cd61 100644 --- a/packages/core/src/tracing/sentryNonRecordingSpan.ts +++ b/packages/core/src/tracing/sentryNonRecordingSpan.ts @@ -69,24 +69,12 @@ export class SentryNonRecordingSpan implements Span { return this; } - /** - * This should generally not be used, - * but we need it for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLink(_link: unknown): this { return this; } - /** - * This should generally not be used, - * but we need it for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLinks(_links: unknown[]): this { return this; } diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 74478f79903f..ddf036f88cdb 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -109,24 +109,12 @@ export class SentrySpan implements Span { } } - /** - * This should generally not be used, - * but it is needed for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLink(_link: unknown): this { return this; } - /** - * This should generally not be used, - * but it is needed for being compliant with the OTEL Span interface. - * - * @hidden - * @internal - */ + /** @inheritDoc */ public addLinks(_links: unknown[]): this { return this; } 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); 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/types-hoist/link.ts b/packages/core/src/types-hoist/link.ts new file mode 100644 index 000000000000..a330dc108b00 --- /dev/null +++ b/packages/core/src/types-hoist/link.ts @@ -0,0 +1,30 @@ +import type { SpanAttributeValue, SpanContextData } from './span'; + +type SpanLinkAttributes = { + /** + * Setting the link type to 'previous_trace' helps the Sentry product linking to the previous trace + */ + 'sentry.link.type'?: string | 'previous_trace'; +} & Record; + +export interface SpanLink { + /** + * Contains the SpanContext of the span to link to + */ + context: SpanContextData; + /** + * A key-value pair with primitive values or an array of primitive values + */ + attributes?: SpanLinkAttributes; +} + +/** + * Link interface for the event envelope item. It's a flattened representation of `SpanLink`. + * Can include additional fields defined by OTel. + */ +export interface SpanLinkJSON extends Record { + span_id: string; + trace_id: string; + sampled?: boolean; + attributes?: SpanLinkAttributes; +} diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index c74d00e54f97..2b82aab74934 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -1,3 +1,4 @@ +import type { SpanLink, SpanLinkJSON } from './link'; import type { Measurements } from './measurement'; import type { HrTime } from './opentelemetry'; import type { SpanStatus } from './spanStatus'; @@ -50,6 +51,7 @@ export interface SpanJSON { measurements?: Measurements; is_segment?: boolean; segment_id?: string; + links?: SpanLinkJSON[]; } // These are aligned with OpenTelemetry trace flags @@ -249,14 +251,21 @@ export interface Span { addEvent(name: string, attributesOrStartTime?: SpanAttributes | SpanTimeInput, startTime?: SpanTimeInput): this; /** - * NOT USED IN SENTRY, only added for compliance with OTEL Span interface + * Associates this span with a related span. Links can reference spans from the same or different trace + * and are typically used for batch operations, cross-trace scenarios, or scatter/gather patterns. + * + * Prefer setting links directly when starting a span (e.g. `Sentry.startSpan()`) as some context information is only available during span creation. + * @param link - The link containing the context of the span to link to and optional attributes */ - addLink(link: unknown): this; + addLink(link: SpanLink): this; /** - * NOT USED IN SENTRY, only added for compliance with OTEL Span interface + * Associates this span with multiple related spans. See {@link addLink} for more details. + * + * Prefer setting links directly when starting a span (e.g. `Sentry.startSpan()`) as some context information is only available during span creation. + * @param links - Array of links to associate with this span */ - addLinks(links: unknown): this; + addLinks(links: SpanLink[]): this; /** * NOT USED IN SENTRY, only added for compliance with OTEL Span interface 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 a593b72e73ad..189c2ee363aa 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, @@ -128,6 +130,7 @@ export { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader, parseBaggageHeader, + objectToBaggageHeader, } from './baggage'; export { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from './url'; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 41435e8be373..fcf4aa1857e3 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -19,6 +19,7 @@ import type { SpanTimeInput, TraceContext, } from '../types-hoist'; +import type { SpanLink, SpanLinkJSON } from '../types-hoist/link'; import { consoleSandbox } from '../utils-hoist/logger'; import { addNonEnumerableProperty, dropUndefinedKeys } from '../utils-hoist/object'; import { generateSpanId } from '../utils-hoist/propagationContext'; @@ -81,6 +82,25 @@ export function spanToTraceHeader(span: Span): string { return generateSentryTraceHeader(traceId, spanId, sampled); } +/** + * Converts the span links array to a flattened version to be sent within an envelope. + * + * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent. + */ +export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[] | undefined { + if (links && links.length > 0) { + return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({ + span_id: spanId, + trace_id: traceId, + sampled: traceFlags === TRACE_FLAG_SAMPLED, + attributes, + ...restContext, + })); + } else { + return undefined; + } +} + /** * Convert a span time input into a timestamp in seconds. */ diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index 8139460e8304..acbac2b35dbd 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -7,6 +7,7 @@ import { SPAN_STATUS_UNSET, SentrySpan, TRACEPARENT_REGEXP, + convertSpanLinksForEnvelope, setCurrentClient, spanToTraceHeader, startInactiveSpan, @@ -14,7 +15,9 @@ import { timestampInSeconds, } from '../../../src'; import type { Span, SpanAttributes, SpanStatus, SpanTimeInput } from '../../../src/types-hoist'; +import type { SpanLink } from '../../../src/types-hoist/link'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; +import { TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; import { getRootSpan, spanIsSampled, @@ -156,6 +159,115 @@ describe('spanToTraceContext', () => { }); }); +describe('convertSpanLinksForEnvelope', () => { + it('returns undefined for undefined input', () => { + expect(convertSpanLinksForEnvelope(undefined)).toBeUndefined(); + }); + + it('returns undefined for empty array input', () => { + expect(convertSpanLinksForEnvelope([])).toBeUndefined(); + }); + + it('converts a single span link to a flattened envelope item', () => { + const links: SpanLink[] = [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + ]; + + const result = convertSpanLinksForEnvelope(links); + + result?.forEach(item => expect(item).not.toHaveProperty('context')); + expect(result).toEqual([ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + ]); + }); + + it('converts multiple span links to a flattened envelope item', () => { + const links: SpanLink[] = [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + { + context: { + spanId: 'span2', + traceId: 'trace2', + traceFlags: TRACE_FLAG_NONE, + }, + attributes: { + 'sentry.link.type': 'another_trace', + }, + }, + ]; + + const result = convertSpanLinksForEnvelope(links); + + result?.forEach(item => expect(item).not.toHaveProperty('context')); + expect(result).toEqual([ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }, + { + span_id: 'span2', + trace_id: 'trace2', + sampled: false, + attributes: { + 'sentry.link.type': 'another_trace', + }, + }, + ]); + }); + + it('handles span links without attributes', () => { + const links: SpanLink[] = [ + { + context: { + spanId: 'span1', + traceId: 'trace1', + traceFlags: TRACE_FLAG_SAMPLED, + }, + }, + ]; + + const result = convertSpanLinksForEnvelope(links); + + result?.forEach(item => expect(item).not.toHaveProperty('context')); + expect(result).toEqual([ + { + span_id: 'span1', + trace_id: 'trace1', + sampled: true, + }, + ]); + }); +}); + describe('spanTimeInputToSeconds', () => { it('works with undefined', () => { const now = timestampInSeconds(); 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({