From 15230f1aa048e0118d5aca1ba5838cdacea44233 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 5 Jul 2024 11:29:11 +0200 Subject: [PATCH 01/22] feat(opentelemetry): Allow skipping of span data inference (#12779) --- .../src/utils/parseSpanDescription.ts | 15 ++++++++++++- .../test/utils/parseSpanDescription.test.ts | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts index f77478ee3b8e..c9e732683c38 100644 --- a/packages/opentelemetry/src/utils/parseSpanDescription.ts +++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts @@ -23,7 +23,7 @@ interface SpanDescription { op: string | undefined; description: string; source: TransactionSource; - data?: Record; + data?: Record; } /** @@ -35,6 +35,19 @@ export function parseSpanDescription(span: AbstractSpan): SpanDescription { const attributes = spanHasAttributes(span) ? span.attributes : {}; const name = spanHasName(span) ? span.name : ''; + // This attribute is intentionally exported as a SEMATTR constant because it should stay intimite API + if (attributes['sentry.skip_span_data_inference']) { + return { + op: undefined, + description: name, + source: 'custom', + data: { + // Suggest to callers of `parseSpanDescription` to wipe the hint because it is unnecessary data in the end. + 'sentry.skip_span_data_inference': undefined, + }, + }; + } + // if http.method exists, this is an http request span // // TODO: Referencing `http.request.method` is a temporary workaround until the semantic diff --git a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts index 1e7178871ff1..cfa1a43094c4 100644 --- a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts +++ b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts @@ -132,6 +132,27 @@ describe('parseSpanDescription', () => { source: 'route', }, ], + [ + "should not do any data parsing when the 'sentry.skip_span_data_inference' attribute is set", + { + 'sentry.skip_span_data_inference': true, + + // All of these should be ignored + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + }, + 'test name', + undefined, + { + op: undefined, + description: 'test name', + source: 'custom', + data: { + 'sentry.skip_span_data_inference': undefined, + }, + }, + ], ])('%s', (_, attributes, name, kind, expected) => { const actual = parseSpanDescription({ attributes, kind, name } as unknown as Span); expect(actual).toEqual(expected); From f4e720347c4d45bc67efa5b2f0e3144895ed1057 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 5 Jul 2024 12:16:14 +0200 Subject: [PATCH 02/22] chore(e2e): Add react-17 e2e test app (#12778) Adds a react 17 test app so we can hopefully catch breaking react 17, as we did recently (https://github.com/getsentry/sentry-javascript/issues/12608) with https://github.com/getsentry/sentry-javascript/pull/12204 and https://github.com/getsentry/sentry-javascript/pull/12740. --- .github/workflows/build.yml | 1 + .../test-applications/react-17/.gitignore | 29 ++++++ .../test-applications/react-17/.npmrc | 2 + .../test-applications/react-17/package.json | 52 ++++++++++ .../react-17/playwright.config.mjs | 7 ++ .../react-17/public/index.html | 24 +++++ .../test-applications/react-17/src/index.tsx | 58 +++++++++++ .../react-17/src/pages/Index.tsx | 23 +++++ .../react-17/src/pages/User.tsx | 8 ++ .../react-17/src/react-app-env.d.ts | 1 + .../react-17/start-event-proxy.mjs | 6 ++ .../react-17/tests/errors.test.ts | 59 +++++++++++ .../react-17/tests/transactions.test.ts | 98 +++++++++++++++++++ .../test-applications/react-17/tsconfig.json | 20 ++++ .../test-applications/solid/src/index.tsx | 4 +- 15 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-17/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-17/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/react-17/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-17/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-17/public/index.html create mode 100644 dev-packages/e2e-tests/test-applications/react-17/src/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-17/src/pages/Index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-17/src/pages/User.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-17/src/react-app-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-17/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-17/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ef8d14b6d3e..a849469b69e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1017,6 +1017,7 @@ jobs: 'nextjs-app-dir', 'nextjs-14', 'nextjs-15', + 'react-17', 'react-19', 'react-create-hash-router', 'react-router-6-use-routes', diff --git a/dev-packages/e2e-tests/test-applications/react-17/.gitignore b/dev-packages/e2e-tests/test-applications/react-17/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/.gitignore @@ -0,0 +1,29 @@ +# 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 diff --git a/dev-packages/e2e-tests/test-applications/react-17/.npmrc b/dev-packages/e2e-tests/test-applications/react-17/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/.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-17/package.json b/dev-packages/e2e-tests/test-applications/react-17/package.json new file mode 100644 index 000000000000..db60c16938dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/package.json @@ -0,0 +1,52 @@ +{ + "name": "react-17", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "latest || *", + "@types/react": "17.0.2", + "@types/react-dom": "17.0.2", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-router-dom": "~6.3.0", + "react-scripts": "5.0.1", + "typescript": "4.9.5" + }, + "scripts": { + "build": "react-scripts build", + "dev": "react-scripts start", + "start": "serve -s build", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && npx playwright install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && npx playwright install && pnpm build", + "test:assert": "pnpm 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" + ] + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "serve": "14.0.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-17/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-17/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-17/public/index.html b/dev-packages/e2e-tests/test-applications/react-17/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + Codestin Search App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-17/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-17/src/index.tsx new file mode 100644 index 000000000000..49609a988202 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/src/index.tsx @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { + BrowserRouter, + Route, + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + tunnel: 'http://localhost:3031', +}); + +const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); + +function App() { + return ( + + + + } /> + } /> + + + + ); +} + +ReactDOM.render(, document.getElementById('root')); diff --git a/dev-packages/e2e-tests/test-applications/react-17/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-17/src/pages/Index.tsx new file mode 100644 index 000000000000..c7aa909c3c6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/src/pages/Index.tsx @@ -0,0 +1,23 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate to user + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-17/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-17/src/pages/User.tsx new file mode 100644 index 000000000000..62f0c2d17533 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/src/pages/User.tsx @@ -0,0 +1,8 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-17/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-17/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-17/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-17/start-event-proxy.mjs new file mode 100644 index 000000000000..6b825e527516 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-17', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts new file mode 100644 index 000000000000..444e30fc0067 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-17', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const transactionPromise = waitForTransaction('react-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorEventPromise = waitForError('react-17', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const transactionEvent = await transactionPromise; + + // Only capture error once transaction was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: transactionEvent.contexts?.trace?.trace_id, + span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts new file mode 100644 index 000000000000..665b5c02aafe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('react-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + }, + }, + transaction: '/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadTxnPromise = waitForTransaction('react-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + }, + }, + transaction: '/user/:id', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends an INP span', async ({ page }) => { + const inpSpanPromise = waitForEnvelopeItem('react-17', item => { + return item[0].type === 'span'; + }); + + await page.goto(`/`); + + await page.click('#exception-button'); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan[1]).toEqual({ + data: { + 'sentry.origin': 'auto.http.browser.inp', + 'sentry.op': 'ui.interaction.click', + release: 'e2e-test', + environment: 'qa', + transaction: '/', + 'sentry.exclusive_time': expect.any(Number), + replay_id: expect.any(String), + }, + description: 'body > div#root > input#exception-button[type="button"]', + op: 'ui.interaction.click', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.http.browser.inp', + exclusive_time: expect.any(Number), + measurements: { inp: { unit: 'millisecond', value: expect.any(Number) } }, + segment_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-17/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-17/tsconfig.json new file mode 100644 index 000000000000..76ffed0e7ed2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/solid/src/index.tsx b/dev-packages/e2e-tests/test-applications/solid/src/index.tsx index df121347daeb..882bb32d853a 100644 --- a/dev-packages/e2e-tests/test-applications/solid/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/solid/src/index.tsx @@ -5,9 +5,7 @@ import App from './app'; import './index.css'; Sentry.init({ - dsn: - import.meta.env.PUBLIC_E2E_TEST_DSN || - 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, debug: true, environment: 'qa', // dynamic sampling bias to keep transactions integrations: [Sentry.browserTracingIntegration()], From 55a5cefa727859a1804daed7ca8c3ae2c801add7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 5 Jul 2024 12:41:40 +0200 Subject: [PATCH 03/22] ci: Fix permissions for external contributors PRs (for real?) (#12776) Another day, another try to fix this! Now it failed like this https://github.com/getsentry/sentry-javascript/actions/runs/9804811101/job/27073251966, hopefully this change fixes this... we'll see! --- .github/workflows/external-contributors.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 294f8fe10af7..3682aaa88e2a 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -9,8 +9,12 @@ on: jobs: external_contributor: name: External Contributors + permissions: + pull-requests: write + contents: write runs-on: ubuntu-20.04 if: | + github.event.pull_request.merged == true && github.event.pull_request.author_association != 'COLLABORATOR' && github.event.pull_request.author_association != 'MEMBER' && github.event.pull_request.author_association != 'OWNER' From a2dcb2869616e9b6dbba03996c90aa555f378b9b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 5 Jul 2024 12:41:56 +0200 Subject: [PATCH 04/22] feat(node): Allow to pass instrumentation config to `httpIntegration` (#12761) Today, it is not (easily) possible to use custom config for the OTEL HttpInstrumentation. We depend on our own integration for various things, so overwriting this will lead to lots of problems. With this PR, you can now pass some config through directly, in an "allowlisted" way, + there is an escape hatch to add arbitrary other config: ```js Sentry.init({ integrations: [ Sentry.httpIntegration({ instrumentation: { // these three are "vetted" requestHook: (span, req) => span.setAttribute('custom', req.method), responseHook: (span, res) => span.setAttribute('custom', res.method), applyCustomAttributesOnSpan: (span, req, res) => span.setAttribute('custom', req.method), // escape hatch: Can add arbitrary other config that is passed through _experimentalConfig: { serverName: 'xxx' } }) ] }); ``` Closes https://github.com/getsentry/sentry-javascript/issues/12672 --- .../suites/express/tracing/test.ts | 2 +- .../httpIntegration/server-experimental.js | 38 +++++++++ .../suites/tracing/httpIntegration/server.js | 58 ++++++++++++++ .../suites/tracing/httpIntegration/test.ts | 80 +++++++++++++++++++ packages/nextjs/src/server/httpIntegration.ts | 14 +--- packages/node/src/integrations/http.ts | 31 ++++++- packages/remix/src/utils/integrations/http.ts | 14 +--- 7 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-experimental.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index c05849f443ce..e590520838c2 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -1,6 +1,6 @@ import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -describe('express tracing experimental', () => { +describe('express tracing', () => { afterAll(() => { cleanupChildProcesses(); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-experimental.js b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-experimental.js new file mode 100644 index 000000000000..9b4e62766f4e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-experimental.js @@ -0,0 +1,38 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + // disable attaching headers to /test/* endpoints + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, + + integrations: [ + Sentry.httpIntegration({ + instrumentation: { + _experimentalConfig: { + serverName: 'sentry-test-server-name', + }, + }, + }), + ], +}); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.js b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.js new file mode 100644 index 000000000000..d10c24db500d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.js @@ -0,0 +1,58 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + // disable attaching headers to /test/* endpoints + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, + + integrations: [ + Sentry.httpIntegration({ + instrumentation: { + requestHook: (span, req) => { + span.setAttribute('attr1', 'yes'); + Sentry.setExtra('requestHookCalled', { + url: req.url, + method: req.method, + }); + }, + responseHook: (span, res) => { + span.setAttribute('attr2', 'yes'); + Sentry.setExtra('responseHookCalled', { + url: res.req.url, + method: res.req.method, + }); + }, + applyCustomAttributesOnSpan: (span, req, res) => { + span.setAttribute('attr3', 'yes'); + Sentry.setExtra('applyCustomAttributesOnSpanCalled', { + reqUrl: req.url, + reqMethod: req.method, + resUrl: res.req.url, + resMethod: res.req.method, + }); + }, + }, + }), + ], +}); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts new file mode 100644 index 000000000000..69551480ef25 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -0,0 +1,80 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('httpIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('allows to pass instrumentation options to integration', done => { + // response shape seems different on Node 14, so we skip this there + const nodeMajorVersion = Number(process.versions.node.split('.')[0]); + if (nodeMajorVersion <= 14) { + done(); + return; + } + + createRunner(__dirname, 'server.js') + .ignore('session', 'sessions') + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + url: expect.stringMatching(/\/test$/), + 'http.response.status_code': 200, + attr1: 'yes', + attr2: 'yes', + attr3: 'yes', + }, + op: 'http.server', + status: 'ok', + }, + }, + extra: { + requestHookCalled: { + url: expect.stringMatching(/\/test$/), + method: 'GET', + }, + responseHookCalled: { + url: expect.stringMatching(/\/test$/), + method: 'GET', + }, + applyCustomAttributesOnSpanCalled: { + reqUrl: expect.stringMatching(/\/test$/), + reqMethod: 'GET', + resUrl: expect.stringMatching(/\/test$/), + resMethod: 'GET', + }, + }, + }, + }) + .start(done) + .makeRequest('get', '/test'); + }); + + test('allows to pass experimental config through to integration', done => { + createRunner(__dirname, 'server-experimental.js') + .ignore('session', 'sessions') + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + url: expect.stringMatching(/\/test$/), + 'http.response.status_code': 200, + 'http.server_name': 'sentry-test-server-name', + }, + op: 'http.server', + status: 'ok', + }, + }, + }, + }) + .start(done) + .makeRequest('get', '/test'); + }); +}); diff --git a/packages/nextjs/src/server/httpIntegration.ts b/packages/nextjs/src/server/httpIntegration.ts index 4fdc615deb92..49f7318826c9 100644 --- a/packages/nextjs/src/server/httpIntegration.ts +++ b/packages/nextjs/src/server/httpIntegration.ts @@ -22,19 +22,7 @@ class CustomNextjsHttpIntegration extends HttpInstrumentation { } } -interface HttpOptions { - /** - * Whether breadcrumbs should be recorded for requests. - * Defaults to true - */ - breadcrumbs?: boolean; - - /** - * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. - * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. - */ - ignoreOutgoingRequests?: (url: string) => boolean; -} +type HttpOptions = Parameters[0]; /** * The http integration instruments Node's internal http and https modules. diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index c135c32816a2..418fa8aa7853 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -43,6 +43,25 @@ interface HttpOptions { */ ignoreIncomingRequests?: (url: string) => boolean; + /** + * Additional instrumentation options that are passed to the underlying HttpInstrumentation. + */ + instrumentation?: { + requestHook?: (span: Span, req: ClientRequest | HTTPModuleRequestIncomingMessage) => void; + responseHook?: (span: Span, response: HTTPModuleRequestIncomingMessage | ServerResponse) => void; + applyCustomAttributesOnSpan?: ( + span: Span, + request: ClientRequest | HTTPModuleRequestIncomingMessage, + response: HTTPModuleRequestIncomingMessage | ServerResponse, + ) => void; + + /** + * You can pass any configuration through to the underlying instrumention. + * Note that there are no semver guarantees for this! + */ + _experimentalConfig?: ConstructorParameters[0]; + }; + /** Allows to pass a custom version of HttpInstrumentation. We use this for Next.js. */ _instrumentation?: typeof HttpInstrumentation; } @@ -63,6 +82,7 @@ export const instrumentHttp = Object.assign( const _InstrumentationClass = _httpOptions._instrumentation || HttpInstrumentation; _httpInstrumentation = new _InstrumentationClass({ + ..._httpOptions.instrumentation?._experimentalConfig, ignoreOutgoingRequestHook: request => { const url = getRequestUrl(request); @@ -107,6 +127,7 @@ export const instrumentHttp = Object.assign( // both, incoming requests and "client" requests made within the app trigger the requestHook // we only want to isolate and further annotate incoming requests (IncomingMessage) if (_isClientRequest(req)) { + _httpOptions.instrumentation?.requestHook?.(span, req); return; } @@ -134,17 +155,21 @@ export const instrumentHttp = Object.assign( const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; isolationScope.setTransactionName(bestEffortTransactionName); + + _httpOptions.instrumentation?.requestHook?.(span, req); }, - responseHook: () => { + responseHook: (span, res) => { const client = getClient(); if (client && client.getOptions().autoSessionTracking) { setImmediate(() => { client['_captureRequestSession'](); }); } + + _httpOptions.instrumentation?.responseHook?.(span, res); }, applyCustomAttributesOnSpan: ( - _span: Span, + span: Span, request: ClientRequest | HTTPModuleRequestIncomingMessage, response: HTTPModuleRequestIncomingMessage | ServerResponse, ) => { @@ -152,6 +177,8 @@ export const instrumentHttp = Object.assign( if (_breadcrumbs) { _addRequestBreadcrumb(request, response); } + + _httpOptions.instrumentation?.applyCustomAttributesOnSpan?.(span, request, response); }, }); diff --git a/packages/remix/src/utils/integrations/http.ts b/packages/remix/src/utils/integrations/http.ts index 7c4b80f44fe7..d3a7ae03e351 100644 --- a/packages/remix/src/utils/integrations/http.ts +++ b/packages/remix/src/utils/integrations/http.ts @@ -16,19 +16,7 @@ class RemixHttpIntegration extends HttpInstrumentation { } } -interface HttpOptions { - /** - * Whether breadcrumbs should be recorded for requests. - * Defaults to true - */ - breadcrumbs?: boolean; - - /** - * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. - * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. - */ - ignoreOutgoingRequests?: (url: string) => boolean; -} +type HttpOptions = Parameters[0]; /** * The http integration instruments Node's internal http and https modules. From ecc95e75fec7ea22bc95e981bcaf886da1e777d3 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 5 Jul 2024 19:32:33 +0200 Subject: [PATCH 05/22] feat(nuxt): Inject sentry config with Nuxt `addPluginTemplate` (#12760) --- packages/nuxt/README.md | 28 ++++--- packages/nuxt/rollup.npm.config.mjs | 2 +- packages/nuxt/src/common/snippets.ts | 47 ----------- packages/nuxt/src/index.server.ts | 2 +- packages/nuxt/src/index.types.ts | 18 +++- packages/nuxt/src/module.ts | 51 ++++++----- packages/nuxt/src/server/index.ts | 3 + packages/nuxt/src/server/sdk.ts | 19 +++++ packages/nuxt/test/common/snippets.test.ts | 98 ---------------------- 9 files changed, 89 insertions(+), 179 deletions(-) delete mode 100644 packages/nuxt/src/common/snippets.ts create mode 100644 packages/nuxt/src/server/index.ts create mode 100644 packages/nuxt/src/server/sdk.ts delete mode 100644 packages/nuxt/test/common/snippets.test.ts diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index e33201b21518..f329f569216d 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -62,25 +62,31 @@ export default defineNuxtConfig({ }); ``` -2. Add a `sentry.client.config.(js|ts)` file to the root of your project: +### 3. Client-side setup + +Add a `sentry.client.config.(js|ts)` file to the root of your project: ```javascript import * as Sentry from '@sentry/nuxt'; -if (!import.meta.env.SSR) { - Sentry.init({ - dsn: env.DSN, - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, - }); -} +Sentry.init({ + dsn: env.DSN, +}); ``` -### 3. Server-side Setup +### 4. Server-side setup + +Add a `sentry.server.config.(js|ts)` file to the root of your project: + +```javascript +import * as Sentry from '@sentry/nuxt'; -todo: add server-side setup +Sentry.init({ + dsn: env.DSN, +}); +``` -### 4. Vite Setup +### 5. Vite Setup todo: add vite setup diff --git a/packages/nuxt/rollup.npm.config.mjs b/packages/nuxt/rollup.npm.config.mjs index e800fdbba474..a672e9e43eb3 100644 --- a/packages/nuxt/rollup.npm.config.mjs +++ b/packages/nuxt/rollup.npm.config.mjs @@ -2,6 +2,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.client.ts', 'src/client/index.ts'], + entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/client/index.ts', 'src/server/index.ts'], }), ); diff --git a/packages/nuxt/src/common/snippets.ts b/packages/nuxt/src/common/snippets.ts deleted file mode 100644 index 5b8a3f1f3ea1..000000000000 --- a/packages/nuxt/src/common/snippets.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -/** Returns an import snippet */ -export function buildSdkInitFileImportSnippet(filePath: string): string { - const posixPath = filePath.split(path.sep).join(path.posix.sep); - - // normalize to forward slashed for Windows-based systems - const normalizedPath = posixPath.replace(/\\/g, '/'); - - return `import '${normalizedPath}';`; -} - -/** - * Script tag inside `nuxt-root.vue` (root component we get from NuxtApp) - */ -export const SCRIPT_TAG = '