diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 773e13ac54fe..68842b96e3e7 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -39,10 +39,12 @@ body: - '@sentry/ember' - '@sentry/gatsby' - '@sentry/google-cloud-serverless' + - '@sentry/nestjs' - '@sentry/nextjs' - '@sentry/node' - '@sentry/react' - '@sentry/remix' + - '@sentry/solid' - '@sentry/svelte' - '@sentry/sveltekit' - '@sentry/vue' @@ -56,7 +58,7 @@ body: attributes: label: SDK Version description: What version of the SDK are you using? - placeholder: ex. 7.8.0 + placeholder: ex. 8.10.0 validations: required: true - type: input @@ -66,7 +68,7 @@ body: description: If you're using one of our framework-specific SDKs (`@sentry/react`, for example), what version of the _framework_ are you using? - placeholder: ex. React 17.0.0 + placeholder: ex. React 18.3.0 or Next 14.0.0 - type: input id: link-to-sentry attributes: @@ -78,8 +80,10 @@ body: - type: textarea id: sdk-setup attributes: - label: SDK Setup - description: How do you set up your Sentry SDK? Please show us your `Sentry.init` options. + label: SDK Setup/Reproduction Example + description: + How do you set up your Sentry SDK? Please show us your `Sentry.init` code. + Or even better—share a link to a reproduction example. placeholder: |- ```javascript Sentry.init({ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 445c57284056..ba49c52fef94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -229,44 +229,6 @@ jobs: message: | ⚠️ This PR is opened against **master**. You probably want to open it against **develop**. - job_external_contributor: - name: External Contributors - needs: job_install_deps - runs-on: ubuntu-20.04 - if: | - github.event_name == 'pull_request' - && (github.event.action == 'opened' || github.event.action == 'reopened') - && github.event.pull_request.author_association != 'COLLABORATOR' - && github.event.pull_request.author_association != 'MEMBER' - && github.event.pull_request.author_association != 'OWNER' - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version-file: 'package.json' - - name: Check dependency cache - uses: actions/cache/restore@v4 - with: - path: ${{ env.CACHED_DEPENDENCY_PATHS }} - key: ${{ needs.job_install_deps.outputs.dependency_cache_key }} - fail-on-cache-miss: true - - - name: Add external contributor to CHANGELOG.md - uses: ./dev-packages/external-contributor-gh-action - with: - name: ${{ github.event.pull_request.user.login }} - - name: Create PR with changes - uses: peter-evans/create-pull-request@v6 - with: - commit-message: "ref: Add external contributor to CHANGELOG.md" - title: "ref: Add external contributor to CHANGELOG.md" - branch: 'external-contributor/patch-${{ github.event.pull_request.user.login }}' - delete-branch: true - body: This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. - job_build: name: Build needs: [job_get_metadata, job_install_deps] @@ -502,7 +464,7 @@ jobs: with: node-version-file: 'package.json' - name: Set up Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 - name: Restore caches uses: ./.github/actions/restore-cache env: @@ -761,6 +723,7 @@ jobs: - loader_debug - loader_tracing - loader_replay + - loader_replay_buffer - loader_tracing_replay steps: @@ -1050,6 +1013,7 @@ jobs: 'node-express-esm-preload', 'node-express-esm-without-loader', 'node-express-cjs-preload', + 'node-otel-sdk-node', 'nextjs-app-dir', 'nextjs-14', 'nextjs-15', @@ -1112,7 +1076,7 @@ jobs: node-version-file: 'dev-packages/e2e-tests/package.json' - name: Set up Bun if: matrix.test-application == 'node-exports-test-app' - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 - name: Restore caches uses: ./.github/actions/restore-cache env: diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml new file mode 100644 index 000000000000..a797d93732eb --- /dev/null +++ b/.github/workflows/external-contributors.yml @@ -0,0 +1,44 @@ +name: "CI: Mention external contributors" +on: + pull_request: + types: + - closed + branches: + - develop + +jobs: + external_contributor: + name: External Contributors + runs-on: ubuntu-20.04 + if: | + github.event.pull_request.author_association != 'COLLABORATOR' + && github.event.pull_request.author_association != 'MEMBER' + && github.event.pull_request.author_association != 'OWNER' + && github.actor != 'dependabot[bot]' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: 'package.json' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Add external contributor to CHANGELOG.md + uses: ./dev-packages/external-contributor-gh-action + with: + name: ${{ github.event.pull_request.user.login }} + - name: Create PR with changes + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c + with: + commit-message: "ref: Add external contributor to CHANGELOG.md" + title: "ref: Add external contributor to CHANGELOG.md" + branch: 'external-contributor/patch-${{ github.event.pull_request.user.login }}' + base: 'develop' + delete-branch: true + body: This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 46cc4f96f2c5..469cb8258063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,36 @@ # Changelog +> [!IMPORTANT] +> If you are upgrading to the `8.x` versions of the SDK from `7.x` or below, make sure you follow our +> [migration guide](https://docs.sentry.io/platforms/javascript/migration/) first. + ## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.14.0 + +### Important Changes + +- **feat(nestjs): Filter 4xx errors (#12695)** + +The `@sentry/nestjs` SDK no longer captures 4xx errors automatically. + +### Other Changes + +- chore(react): Remove private namespace `JSX` (#12691) +- feat(deps): bump @opentelemetry/propagator-aws-xray from 1.25.0 to 1.25.1 (#12719) +- feat(deps): bump @prisma/instrumentation from 5.16.0 to 5.16.1 (#12718) +- feat(node): Add `registerEsmLoaderHooks` option (#12684) +- feat(opentelemetry): Expose sampling helper (#12674) +- fix(browser): Make sure measure spans have valid start timestamps (#12648) +- fix(hapi): Widen type definitions (#12710) +- fix(nextjs): Attempt to ignore critical dependency warnings (#12694) +- fix(react): Fix React jsx runtime import for esm (#12740) +- fix(replay): Start replay in `afterAllSetup` instead of next tick (#12709) + +Work in this release was contributed by @quisido. Thank you for your contribution! + ## 8.13.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts index c162db40a9e5..25bcebcd074c 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts @@ -3,8 +3,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; +const bundle = process.env.PW_BUNDLE || ''; + sentryTest('should capture a replay', async ({ getLocalTestUrl, page }) => { - if (shouldSkipReplayTest()) { + // When in buffer mode, there will not be a replay by default + if (shouldSkipReplayTest() || bundle === 'loader_replay_buffer') { sentryTest.skip(); } diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replayError/subject.js b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replayError/subject.js new file mode 100644 index 000000000000..544cbfad3179 --- /dev/null +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replayError/subject.js @@ -0,0 +1 @@ +window.doSomethingWrong(); diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replayError/test.ts b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replayError/test.ts new file mode 100644 index 000000000000..379697881165 --- /dev/null +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replayError/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../../utils/helpers'; +import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; + +sentryTest('should capture a replay & attach an error', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const req = waitForReplayRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqError = await waitForErrorRequestOnUrl(page, url); + + const errorEventData = envelopeRequestParser(reqError); + expect(errorEventData.exception?.values?.length).toBe(1); + expect(errorEventData.exception?.values?.[0]?.value).toContain('window.doSomethingWrong is not a function'); + + const eventData = getReplayEvent(await req); + + expect(eventData).toBeDefined(); + expect(eventData.segment_id).toBe(0); + + expect(errorEventData.tags?.replayId).toEqual(eventData.replay_id); +}); diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/customReplay/init.js b/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/customReplay/init.js index 150a9f6a20ae..f37879cc19db 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/customReplay/init.js +++ b/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/customReplay/init.js @@ -6,5 +6,7 @@ Sentry.onLoad(function () { useCompression: false, }), ], + + replaysSessionSampleRate: 1, }); }); diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/replay/init.js b/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/replay/init.js index e63705186b2f..e55a8aefdc0b 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/replay/init.js +++ b/dev-packages/browser-integration-tests/loader-suites/loader/onLoad/replay/init.js @@ -1,3 +1,5 @@ Sentry.onLoad(function () { - Sentry.init({}); + Sentry.init({ + replaysSessionSampleRate: 1, + }); }); diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 5000f5be97f7..9be2adc9b744 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -32,6 +32,7 @@ "test:loader:eager": "PW_BUNDLE=loader_eager yarn test:loader", "test:loader:tracing": "PW_BUNDLE=loader_tracing yarn test:loader", "test:loader:replay": "PW_BUNDLE=loader_replay yarn test:loader", + "test:loader:replay_buffer": "PW_BUNDLE=loader_replay_buffer yarn test:loader", "test:loader:full": "PW_BUNDLE=loader_tracing_replay yarn test:loader", "test:loader:debug": "PW_BUNDLE=loader_debug yarn test:loader", "test:ci": "yarn test:all --reporter='line'", diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferMode/init.js b/dev-packages/browser-integration-tests/suites/replay/bufferModeManual/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/replay/bufferMode/init.js rename to dev-packages/browser-integration-tests/suites/replay/bufferModeManual/init.js diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferMode/subject.js b/dev-packages/browser-integration-tests/suites/replay/bufferModeManual/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/replay/bufferMode/subject.js rename to dev-packages/browser-integration-tests/suites/replay/bufferModeManual/subject.js diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferMode/template.html b/dev-packages/browser-integration-tests/suites/replay/bufferModeManual/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/replay/bufferMode/template.html rename to dev-packages/browser-integration-tests/suites/replay/bufferModeManual/template.html diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferModeManual/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/replay/bufferMode/test.ts rename to dev-packages/browser-integration-tests/suites/replay/bufferModeManual/test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/errors/immediateError/subject.js b/dev-packages/browser-integration-tests/suites/replay/errors/immediateError/subject.js new file mode 100644 index 000000000000..544cbfad3179 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/errors/immediateError/subject.js @@ -0,0 +1 @@ +window.doSomethingWrong(); diff --git a/dev-packages/browser-integration-tests/suites/replay/errors/immediateError/test.ts b/dev-packages/browser-integration-tests/suites/replay/errors/immediateError/test.ts new file mode 100644 index 000000000000..7c82f29256d9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/errors/immediateError/test.ts @@ -0,0 +1,38 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../../utils/helpers'; +import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; + +sentryTest( + '[error-mode] should capture error that happens immediately after init', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const req = waitForReplayRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqError = await waitForErrorRequestOnUrl(page, url); + + const errorEventData = envelopeRequestParser(reqError); + expect(errorEventData.exception?.values?.length).toBe(1); + expect(errorEventData.exception?.values?.[0]?.value).toContain('window.doSomethingWrong is not a function'); + + const eventData = getReplayEvent(await req); + + expect(eventData).toBeDefined(); + expect(eventData.segment_id).toBe(0); + + expect(errorEventData.tags?.replayId).toEqual(eventData.replay_id); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js new file mode 100644 index 000000000000..d34167f7b256 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js @@ -0,0 +1,21 @@ +// Add measure before SDK initializes +const end = performance.now(); +performance.measure('Next.js-before-hydration', { + duration: 1000, + end, +}); + +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 9000, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts new file mode 100644 index 000000000000..9209e8ca5c32 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +// Validation test for https://github.com/getsentry/sentry-javascript/issues/12281 +sentryTest('should add browser-related spans to pageload transaction', async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + const browserSpans = eventData.spans?.filter(({ op }) => op === 'browser'); + + // Spans `domContentLoadedEvent`, `connect`, `cache` and `DNS` are not + // always inside `pageload` transaction. + expect(browserSpans?.length).toBeGreaterThanOrEqual(4); + + const requestSpan = browserSpans!.find(({ description }) => description === 'request'); + expect(requestSpan).toBeDefined(); + + const measureSpan = eventData.spans?.find(({ op }) => op === 'measure'); + expect(measureSpan).toBeDefined(); + + expect(requestSpan!.start_timestamp).toBeLessThanOrEqual(measureSpan!.start_timestamp); + expect(measureSpan?.data).toEqual({ + 'sentry.browser.measure_happened_before_request': true, + 'sentry.browser.measure_start_time': expect.any(Number), + 'sentry.op': 'measure', + 'sentry.origin': 'auto.resource.browser.metrics', + }); +}); diff --git a/dev-packages/browser-integration-tests/tsconfig.json b/dev-packages/browser-integration-tests/tsconfig.json index 2587befcce14..ecc3b11d5e32 100644 --- a/dev-packages/browser-integration-tests/tsconfig.json +++ b/dev-packages/browser-integration-tests/tsconfig.json @@ -6,7 +6,8 @@ "moduleResolution": "node", "noEmit": true, "strict": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "noUncheckedIndexedAccess": false }, "include": ["**/*.ts"], "exclude": ["node_modules"] diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index 7b46faa1fd7d..94f3dd20ae81 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -55,6 +55,7 @@ const BUNDLE_PATHS: Record> = { loader_debug: 'build/bundles/bundle.debug.min.js', loader_tracing: 'build/bundles/bundle.tracing.min.js', loader_replay: 'build/bundles/bundle.replay.min.js', + loader_replay_buffer: 'build/bundles/bundle.replay.min.js', loader_tracing_replay: 'build/bundles/bundle.tracing.replay.debug.min.js', }, integrations: { @@ -96,6 +97,10 @@ export const LOADER_CONFIGS: Record; options: { replaysSessionSampleRate: 1, replaysOnErrorSampleRate: 1 }, lazy: false, }, + loader_replay_buffer: { + options: { replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 1 }, + lazy: false, + }, loader_tracing_replay: { options: { tracesSampleRate: 1, replaysSessionSampleRate: 1, replaysOnErrorSampleRate: 1, debug: true }, lazy: false, @@ -128,8 +133,9 @@ function generateSentryAlias(): Record { const modulePath = path.resolve(PACKAGES_DIR, packageName); - if (useCompiledModule && bundleKey && BUNDLE_PATHS[packageName]?.[bundleKey]) { - const bundlePath = path.resolve(modulePath, BUNDLE_PATHS[packageName][bundleKey]); + const bundleKeyPath = bundleKey && BUNDLE_PATHS[packageName]?.[bundleKey]; + if (useCompiledModule && bundleKeyPath) { + const bundlePath = path.resolve(modulePath, bundleKeyPath); return [packageJSON['name'], bundlePath]; } @@ -175,8 +181,8 @@ class SentryScenarioGenerationPlugin { (statement: { specifiers: [{ imported: { name: string } }] }, source: string) => { const imported = statement.specifiers?.[0]?.imported?.name; - if (imported && IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS[imported]) { - const bundleName = IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS[imported]; + const bundleName = imported && IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS[imported]; + if (bundleName) { this.requiredIntegrations.push(bundleName); } else if (source === '@sentry/wasm') { this.requiresWASMIntegration = true; @@ -190,7 +196,7 @@ class SentryScenarioGenerationPlugin { HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync(this._name, (data, cb) => { if (useBundleOrLoader) { const bundleName = 'browser'; - const bundlePath = BUNDLE_PATHS[bundleName][bundleKey]; + const bundlePath = BUNDLE_PATHS[bundleName]?.[bundleKey]; if (!bundlePath) { throw new Error(`Could not find bundle or loader for key ${bundleKey}`); @@ -215,10 +221,10 @@ class SentryScenarioGenerationPlugin { '__LOADER_OPTIONS__', JSON.stringify({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - ...loaderConfig.options, + ...loaderConfig?.options, }), ) - .replace('__LOADER_LAZY__', loaderConfig.lazy ? 'true' : 'false'); + .replace('__LOADER_LAZY__', loaderConfig?.lazy ? 'true' : 'false'); }); } @@ -240,36 +246,41 @@ class SentryScenarioGenerationPlugin { path.resolve( PACKAGES_DIR, 'feedback', - BUNDLE_PATHS['feedback'][integrationBundleKey].replace('[INTEGRATION_NAME]', integration), + BUNDLE_PATHS['feedback']?.[integrationBundleKey]?.replace('[INTEGRATION_NAME]', integration) || '', ), fileName, ); }); } - this.requiredIntegrations.forEach(integration => { - const fileName = `${integration}.bundle.js`; - addStaticAssetSymlink( - this.localOutPath, - path.resolve( - PACKAGES_DIR, - 'browser', - BUNDLE_PATHS['integrations'][integrationBundleKey].replace('[INTEGRATION_NAME]', integration), - ), - fileName, - ); + const baseIntegrationFileName = BUNDLE_PATHS['integrations']?.[integrationBundleKey]; - const integrationObject = createHtmlTagObject('script', { - src: fileName, - }); + if (baseIntegrationFileName) { + this.requiredIntegrations.forEach(integration => { + const fileName = `${integration}.bundle.js`; + addStaticAssetSymlink( + this.localOutPath, + path.resolve( + PACKAGES_DIR, + 'browser', + baseIntegrationFileName.replace('[INTEGRATION_NAME]', integration), + ), + fileName, + ); - data.assetTags.scripts.unshift(integrationObject); - }); + const integrationObject = createHtmlTagObject('script', { + src: fileName, + }); + + data.assetTags.scripts.unshift(integrationObject); + }); + } - if (this.requiresWASMIntegration && BUNDLE_PATHS['wasm'][integrationBundleKey]) { + const baseWasmFileName = BUNDLE_PATHS['wasm']?.[integrationBundleKey]; + if (this.requiresWASMIntegration && baseWasmFileName) { addStaticAssetSymlink( this.localOutPath, - path.resolve(PACKAGES_DIR, 'wasm', BUNDLE_PATHS['wasm'][integrationBundleKey]), + path.resolve(PACKAGES_DIR, 'wasm', baseWasmFileName), 'wasm.bundle.js', ); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index ab55da449ad2..e0f72bdfacbc 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -65,13 +65,18 @@ const properFullEnvelopeParser = (request: Request | null): }; function getEventAndTraceHeader(envelope: EventEnvelope): EventAndTraceHeader { - const event = envelope[1][0][1] as Event; - const trace = envelope[0].trace; + const event = envelope[1][0]?.[1] as Event | undefined; + const trace = envelope[0]?.trace; + + if (!event || !trace) { + throw new Error('Could not get event or trace from envelope'); + } + return [event, trace]; } export const properEnvelopeRequestParser = (request: Request | null, envelopeIndex = 1): T => { - return properEnvelopeParser(request)[0][envelopeIndex] as T; + return properEnvelopeParser(request)[0]?.[envelopeIndex] as T; }; export const properFullEnvelopeRequestParser = (request: Request | null): T => { @@ -361,7 +366,14 @@ async function getFirstSentryEnvelopeRequest( url?: string, requestParser: (req: Request) => T = envelopeRequestParser as (req: Request) => T, ): Promise { - return (await getMultipleSentryEnvelopeRequests(page, 1, { url }, requestParser))[0]; + const reqs = await getMultipleSentryEnvelopeRequests(page, 1, { url }, requestParser); + + const req = reqs[0]; + if (!req) { + throw new Error('No request found'); + } + + return req; } export { runScriptInSandbox, getMultipleSentryEnvelopeRequests, getFirstSentryEnvelopeRequest, getSentryEvents }; diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json index 2ff890293166..38e946577def 100644 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ b/dev-packages/bundle-analyzer-scenarios/package.json @@ -8,10 +8,9 @@ "license": "MIT", "private": true, "dependencies": { - "html-webpack-plugin": "^5.5.0", - "inquirer": "^8.2.0", - "webpack": "^5.76.0", - "webpack-bundle-analyzer": "^4.5.0" + "html-webpack-plugin": "^5.6.0", + "webpack": "^5.92.1", + "webpack-bundle-analyzer": "^4.10.2" }, "scripts": { "analyze": "node webpack.cjs" diff --git a/dev-packages/bundle-analyzer-scenarios/webpack.cjs b/dev-packages/bundle-analyzer-scenarios/webpack.cjs index aac95e59348a..f5874a607473 100644 --- a/dev-packages/bundle-analyzer-scenarios/webpack.cjs +++ b/dev-packages/bundle-analyzer-scenarios/webpack.cjs @@ -1,7 +1,7 @@ -const path = require('path'); -const { promises } = require('fs'); +const path = require('node:path'); +const { promises } = require('node:fs'); +const { parseArgs } = require('node:util'); -const inquirer = require('inquirer'); const webpack = require('webpack'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const HtmlWebpackPlugin = require('html-webpack-plugin'); @@ -9,20 +9,25 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); async function init() { const scenarios = await getScenariosFromDirectories(); - const answers = await inquirer.prompt([ - { - type: 'rawlist', - name: 'scenario', - message: 'Which scenario you want to run?', - choices: scenarios, - pageSize: scenarios.length, - loop: false, - }, - ]); + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { scenario: { type: 'string', short: 's' }, list: { type: 'boolean', short: 'l' } }, + }); + + if (values.list) { + console.log('Available scenarios:', scenarios); + process.exit(0); + } + + if (!scenarios.some(scenario => scenario === values.scenario)) { + console.error('Invalid scenario:', values.scenario); + console.error('Available scenarios:', scenarios); + process.exit(1); + } - console.log(`Bundling scenario: ${answers.scenario}`); + console.log(`Bundling scenario: ${values.scenario}`); - await runWebpack(answers.scenario); + await runWebpack(values.scenario); } async function runWebpack(scenario) { diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 97c7cb927857..a37c23a7b4bf 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -27,6 +27,6 @@ }, "volta": { "extends": "../../package.json", - "pnpm": "8.15.8" + "pnpm": "9.4.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts index 6350cb49f1c5..154f62ada912 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts @@ -45,6 +45,11 @@ export class AppController1 { return this.appService.testException(id); } + @Get('test-expected-exception/:id') + async testExpectedException(@Param('id') id: string) { + return this.appService.testExpectedException(id); + } + @Get('test-outgoing-fetch-external-allowed') async testOutgoingFetchExternalAllowed() { return this.appService.testOutgoingFetchExternalAllowed(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts index 01a96549546b..1103c65941a1 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/nestjs'; import { makeHttpRequest } from './utils'; @@ -52,6 +52,10 @@ export class AppService1 { throw new Error(`This is an exception with id ${id}`); } + testExpectedException(id: string) { + throw new HttpException(`This is an expected exception with id ${id}`, HttpStatus.FORBIDDEN); + } + async testOutgoingFetchExternalAllowed() { const fetchResponse = await fetch('http://localhost:3040/external-allowed'); diff --git a/dev-packages/e2e-tests/test-applications/nestjs/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs/tests/errors.test.ts index aa46f77815d4..349b25b0eee9 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Sends exception to Sentry', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs', event => { @@ -28,3 +28,28 @@ test('Sends exception to Sentry', async ({ baseURL }) => { span_id: expect.any(String), }); }); + +test('Does not send expected exception to Sentry', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError('nestjs', event => { + if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected exception with id 123') { + errorEventOccurred = true; + } + + return event?.transaction === 'GET /test-expected-exception/:id'; + }); + + const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-expected-exception/:id'; + }); + + const response = await fetch(`${baseURL}/test-expected-exception/123`); + expect(response.status).toBe(403); + + await transactionEventPromise; + + await new Promise(resolve => setTimeout(resolve, 10000)); + + expect(errorEventOccurred).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.gitignore b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.npmrc b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.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/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json new file mode 100644 index 000000000000..8a1634725184 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -0,0 +1,32 @@ +{ + "name": "node-otel-sdk-trace", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/sdk-node": "0.52.1", + "@opentelemetry/exporter-trace-otlp-http": "0.52.1", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/types": "latest || *", + "@types/express": "4.17.17", + "@types/node": "18.15.1", + "express": "4.19.2", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + { + webServer: [ + { + command: `node ./start-event-proxy.mjs`, + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node ./start-otel-proxy.mjs`, + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: 3030, + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts new file mode 100644 index 000000000000..26779990f6d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts @@ -0,0 +1,53 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (req, res) { + Sentry.withActiveSpan(null, async () => { + Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + }); + + await Sentry.flush(); + + res.send({}); + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts new file mode 100644 index 000000000000..fb270e1252d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts @@ -0,0 +1,35 @@ +const opentelemetry = require('@opentelemetry/sdk-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); +const Sentry = require('@sentry/node'); +const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: + process.env.E2E_TEST_DSN || + 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + + skipOpenTelemetrySetup: true, +}); + +const sdk = new opentelemetry.NodeSDK({ + sampler: sentryClient ? new SentrySampler(sentryClient) : undefined, + textMapPropagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), + spanProcessors: [ + new SentrySpanProcessor(), + new opentelemetry.node.BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], +}); + +sdk.start(); + +Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs new file mode 100644 index 000000000000..8c74fa842a1b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-otel-sdk-trace', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs new file mode 100644 index 000000000000..1cf9ef3e2c27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-otel-sdk-trace-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts new file mode 100644 index 000000000000..9cb97a051476 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel-sdk-trace', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts new file mode 100644 index 000000000000..39a7d27e9cb1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts @@ -0,0 +1,213 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + // Ensure we also send data to the OTLP endpoint + const otelPromise = waitForPlainRequest('node-otel-sdk-trace-otel', data => { + const json = JSON.parse(data) as any; + + return json.resourceSpans.length > 0; + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + const otelData = await otelPromise; + + // For now we do not test the actual shape of this, but only existence + expect(otelData).toBeDefined(); + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'query', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'query', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'expressInit', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'expressInit', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + 'otel.kind': 'INTERNAL', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'query', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'query', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'expressInit', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'expressInit', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-exception/:id', + 'express.name': '/test-exception/:id', + 'express.type': 'request_handler', + 'otel.kind': 'INTERNAL', + }, + description: '/test-exception/:id', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json new file mode 100644 index 000000000000..8cb64e989ed9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} 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 f356285a79d9..6b837910fc02 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -30,7 +30,7 @@ "@vitejs/plugin-vue-jsx": "^3.1.0", "@vue/tsconfig": "^0.5.1", "http-server": "^14.1.1", - "npm-run-all2": "^6.1.1", + "npm-run-all2": "^6.2.0", "typescript": "~5.3.0", "vite": "^5.0.11", "vue-tsc": "^1.8.27" diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index 5f77ba7cccea..973d2173aefa 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -140,6 +140,12 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/solidstart': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/svelte': access: $all publish: $all diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 5be51096cf75..e95e63700c09 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -22,7 +22,7 @@ import { makeSetSDKSourcePlugin, makeSucrasePlugin, } from './plugins/index.mjs'; -import { makePackageNodeEsm } from './plugins/make-esm-plugin.mjs'; +import { makePackageNodeEsm, makeReactEsmJsxRuntimePlugin } from './plugins/make-esm-plugin.mjs'; import { mergePlugins } from './utils.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -140,7 +140,11 @@ export function makeNPMConfigVariants(baseConfig, options = {}) { if (emitEsm) { variantSpecificConfigs.push({ - output: { format: 'esm', dir: path.join(baseConfig.output.dir, 'esm'), plugins: [makePackageNodeEsm()] }, + output: { + format: 'esm', + dir: path.join(baseConfig.output.dir, 'esm'), + plugins: [makePackageNodeEsm(), makeReactEsmJsxRuntimePlugin()], + }, }); } diff --git a/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs b/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs index 04dd68beaa1c..91aba689f888 100644 --- a/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs +++ b/dev-packages/rollup-utils/plugins/make-esm-plugin.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import replacePlugin from '@rollup/plugin-replace'; /** * Outputs a package.json file with {type: module} in the root of the output directory so that Node @@ -29,3 +30,17 @@ export function makePackageNodeEsm() { }, }; } + +/** + * Makes sure that whenever we add an `react/jsx-runtime` import, we add a `.js` to make the import esm compatible. + */ +export function makeReactEsmJsxRuntimePlugin() { + return replacePlugin({ + preventAssignment: false, + sourceMap: true, + values: { + "'react/jsx-runtime'": "'react/jsx-runtime.js'", + '"react/jsx-runtime"': '"react/jsx-runtime.js"', + }, + }); +} diff --git a/dev-packages/rollup-utils/plugins/npmPlugins.mjs b/dev-packages/rollup-utils/plugins/npmPlugins.mjs index fd736d702e07..6597e2244ab8 100644 --- a/dev-packages/rollup-utils/plugins/npmPlugins.mjs +++ b/dev-packages/rollup-utils/plugins/npmPlugins.mjs @@ -10,7 +10,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { codecovRollupPlugin } from '@codecov/rollup-plugin'; import json from '@rollup/plugin-json'; import replace from '@rollup/plugin-replace'; import cleanup from 'rollup-plugin-cleanup'; diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 82693bf4af89..30bedadc38bb 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as http from 'http'; -import * as https from 'https'; import type { AddressInfo } from 'net'; import * as os from 'os'; import * as path from 'path'; @@ -31,11 +30,27 @@ interface SentryRequestCallbackData { sentryResponseStatusCode?: number; } +type OnRequest = ( + eventCallbackListeners: Set<(data: string) => void>, + proxyRequest: http.IncomingMessage, + proxyRequestBody: string, +) => Promise<[number, string, Record | undefined]>; + /** - * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` - * option to this server (like this `tunnel: http://localhost:${port option}/`). + * Start a generic proxy server. + * The `onRequest` callback receives the incoming request and the request body, + * and should return a promise that resolves to a tuple with: + * statusCode, responseBody, responseHeaders */ -export async function startEventProxyServer(options: EventProxyServerOptions): Promise { +export async function startProxyServer( + options: { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; + }, + onRequest?: OnRequest, +): Promise { const eventCallbackListeners: Set<(data: string) => void> = new Set(); const proxyServer = http.createServer((proxyRequest, proxyResponse) => { @@ -59,102 +74,29 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() : Buffer.concat(proxyRequestChunks).toString(); - const envelopeHeader: EnvelopeItem[0] = JSON.parse(proxyRequestBody.split('\n')[0] as string); - - const shouldForwardEventToSentry = options.forwardToSentry != null ? options.forwardToSentry : true; - - if (!envelopeHeader.dsn && shouldForwardEventToSentry) { - // eslint-disable-next-line no-console - console.log( - '[event-proxy-server] Warn: No dsn on envelope header. Maybe a client-report was received. Proxy request body:', - proxyRequestBody, - ); - - proxyResponse.writeHead(200); - proxyResponse.write('{}', 'utf-8'); - proxyResponse.end(); - return; - } - - if (!shouldForwardEventToSentry) { - const data: SentryRequestCallbackData = { - envelope: parseEnvelope(proxyRequestBody), - rawProxyRequestBody: proxyRequestBody, - rawSentryResponseBody: '', - sentryResponseStatusCode: 200, - }; - eventCallbackListeners.forEach(listener => { - listener(Buffer.from(JSON.stringify(data)).toString('base64')); - }); - - proxyResponse.writeHead(200); - proxyResponse.write('{}', 'utf-8'); - proxyResponse.end(); - return; - } - - const { origin, pathname, host } = new URL(envelopeHeader.dsn as string); - - const projectId = pathname.substring(1); - const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; - - proxyRequest.headers.host = host; - - const sentryResponseChunks: Uint8Array[] = []; - - const sentryRequest = https.request( - sentryIngestUrl, - { headers: proxyRequest.headers, method: proxyRequest.method }, - sentryResponse => { - sentryResponse.addListener('data', (chunk: Buffer) => { - proxyResponse.write(chunk, 'binary'); - sentryResponseChunks.push(chunk); - }); - - sentryResponse.addListener('end', () => { - eventCallbackListeners.forEach(listener => { - const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); - - try { - const data: SentryRequestCallbackData = { - envelope: parseEnvelope(proxyRequestBody), - rawProxyRequestBody: proxyRequestBody, - rawSentryResponseBody, - sentryResponseStatusCode: sentryResponse.statusCode, - }; - - listener(Buffer.from(JSON.stringify(data)).toString('base64')); - } catch (error) { - if (`${error}`.includes('Unexpected token') && proxyRequestBody.includes('{"type":"replay_event"}')) { - // eslint-disable-next-line no-console - console.log('[event-proxy-server] Info: Received replay event, skipping...'); - } else { - // eslint-disable-next-line no-console - console.error( - '[event-proxy-server] Error: Failed to parse Sentry request envelope', - error, - proxyRequestBody, - ); - } - } - }); - proxyResponse.end(); + const callback: OnRequest = + onRequest || + (async (eventCallbackListeners, proxyRequest, proxyRequestBody) => { + eventCallbackListeners.forEach(listener => { + listener(proxyRequestBody); }); - sentryResponse.addListener('error', err => { - // eslint-disable-next-line no-console - console.log('[event-proxy-server] Warn: Proxying to Sentry returned an error!', err); - proxyResponse.writeHead(500); - proxyResponse.write('{}', 'utf-8'); - proxyResponse.end(); - }); - - proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); - }, - ); + return [200, '{}', {}]; + }); - sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); - sentryRequest.end(); + callback(eventCallbackListeners, proxyRequest, proxyRequestBody) + .then(([statusCode, responseBody, responseHeaders]) => { + proxyResponse.writeHead(statusCode, responseHeaders); + proxyResponse.write(responseBody, 'utf-8'); + proxyResponse.end(); + }) + .catch(error => { + // eslint-disable-next-line no-console + console.log('[event-proxy-server] Warn: Proxy server returned an error', error); + proxyResponse.writeHead(500); + proxyResponse.write('{}', 'utf-8'); + proxyResponse.end(); + }); }); }); @@ -193,7 +135,113 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P await eventCallbackServerStartupPromise; await proxyServerStartupPromise; - return; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + await startProxyServer(options, async (eventCallbackListeners, proxyRequest, proxyRequestBody) => { + const envelopeHeader: EnvelopeItem[0] = JSON.parse(proxyRequestBody.split('\n')[0] as string); + + const shouldForwardEventToSentry = options.forwardToSentry != null ? options.forwardToSentry : true; + + if (!envelopeHeader.dsn && shouldForwardEventToSentry) { + // eslint-disable-next-line no-console + console.log( + '[event-proxy-server] Warn: No dsn on envelope header. Maybe a client-report was received. Proxy request body:', + proxyRequestBody, + ); + + return [200, '{}', {}]; + } + + if (!shouldForwardEventToSentry) { + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody: '', + sentryResponseStatusCode: 200, + }; + eventCallbackListeners.forEach(listener => { + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + + return [200, '{}', {}]; + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn as string); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const reqHeaders: Record = {}; + for (const [key, value] of Object.entries(proxyRequest.headers)) { + reqHeaders[key] = value as string; + } + + // Fetch does not like this + delete reqHeaders['transfer-encoding']; + + return fetch(sentryIngestUrl, { + body: proxyRequestBody, + headers: reqHeaders, + method: proxyRequest.method, + }).then(async res => { + const rawSentryResponseBody = await res.text(); + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: res.status, + }; + + eventCallbackListeners.forEach(listener => { + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + + const resHeaders: Record = {}; + for (const [key, value] of res.headers.entries()) { + resHeaders[key] = value; + } + + return [res.status, rawSentryResponseBody, resHeaders]; + }); + }); +} + +/** Wait for any plain request being made to the proxy. */ +export async function waitForPlainRequest( + proxyServerName: string, + callback: (eventData: string) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + + eventContents = eventContents.concat(chunkString); + + if (callback(eventContents)) { + response.destroy(); + return resolve(eventContents); + } + }); + }); + + request.end(); + }); } /** Wait for a request to be sent. */ diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 4425d2688800..49685a6b18c2 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -1,9 +1,11 @@ export { + startProxyServer, startEventProxyServer, waitForEnvelopeItem, waitForError, waitForRequest, waitForTransaction, + waitForPlainRequest, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; diff --git a/package.json b/package.json index eab00cafbd18..a77d4f3a4202 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "volta": { "node": "18.20.3", - "yarn": "1.22.19" + "yarn": "1.22.22" }, "workspaces": [ "packages/angular", @@ -72,6 +72,7 @@ "packages/replay-canvas", "packages/replay-worker", "packages/solid", + "packages/solidstart", "packages/svelte", "packages/sveltekit", "packages/types", @@ -92,7 +93,6 @@ ], "devDependencies": { "@biomejs/biome": "^1.4.0", - "@codecov/rollup-plugin": "0.0.1-beta.5", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-esm-shim": "^0.1.5", "@rollup/plugin-json": "^6.1.0", @@ -112,7 +112,6 @@ "codecov": "^3.6.5", "deepmerge": "^4.2.2", "downlevel-dts": "~0.11.0", - "es-check": "7.1.0", "eslint": "7.32.0", "jest": "^27.5.1", "jest-environment-node": "^27.5.1", @@ -120,9 +119,8 @@ "lerna": "7.1.1", "madge": "7.0.0", "nodemon": "^2.0.16", - "npm-run-all": "^4.1.5", + "npm-run-all2": "^6.2.0", "prettier": "^3.1.1", - "replace-in-file": "^4.0.0", "rimraf": "^3.0.2", "rollup": "^4.13.0", "rollup-plugin-cleanup": "^3.2.1", @@ -136,8 +134,15 @@ "vitest": "^1.6.0", "yalc": "^1.0.0-pre.53" }, + "//_resolutions_comment": [ + "Because new versions of strip-ansi, string-width, and wrap-ansi are ESM only packages,", + "we need to resolve them to the CommonJS versions.", + "This is a temporary solution until we can upgrade to a version of lerna that supports ESM packages" + ], "resolutions": { - "gauge/strip-ansi": "6.0.1" + "gauge/strip-ansi": "6.0.1", + "wide-align/string-width": "4.2.3", + "cliui/wrap-ansi": "7.0.0" }, "version": "0.0.0", "name": "sentry-javascript", diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 6999641a641f..4e473e42ea47 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -342,15 +342,34 @@ export function _addMeasureSpans( duration: number, timeOrigin: number, ): number { - const measureStartTimestamp = timeOrigin + startTime; - const measureEndTimestamp = measureStartTimestamp + duration; + const navEntry = getNavigationEntry(); + const requestTime = msToSec(navEntry ? navEntry.requestStart : 0); + // Because performance.measure accepts arbitrary timestamps it can produce + // spans that happen before the browser even makes a request for the page. + // + // An example of this is the automatically generated Next.js-before-hydration + // spans created by the Next.js framework. + // + // To prevent this we will pin the start timestamp to the request start time + // This does make duration inaccruate, so if this does happen, we will add + // an attribute to the span + const measureStartTimestamp = timeOrigin + Math.max(startTime, requestTime); + const startTimeStamp = timeOrigin + startTime; + const measureEndTimestamp = startTimeStamp + duration; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + }; + + if (measureStartTimestamp !== startTimeStamp) { + attributes['sentry.browser.measure_happened_before_request'] = true; + attributes['sentry.browser.measure_start_time'] = measureStartTimestamp; + } startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { name: entry.name as string, op: entry.entryType as string, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', - }, + attributes, }); return measureStartTimestamp; @@ -395,36 +414,29 @@ function _addPerformanceNavigationTiming( /** Create request and response related spans */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function _addRequest(span: Span, entry: Record, timeOrigin: number): void { + const requestStartTimestamp = timeOrigin + msToSec(entry.requestStart as number); + const responseEndTimestamp = timeOrigin + msToSec(entry.responseEnd as number); + const responseStartTimestamp = timeOrigin + msToSec(entry.responseStart as number); if (entry.responseEnd) { // It is possible that we are collecting these metrics when the page hasn't finished loading yet, for example when the HTML slowly streams in. // In this case, ie. when the document request hasn't finished yet, `entry.responseEnd` will be 0. // In order not to produce faulty spans, where the end timestamp is before the start timestamp, we will only collect // these spans when the responseEnd value is available. The backend (Relay) would drop the entire span if it contained faulty spans. - startAndEndSpan( - span, - timeOrigin + msToSec(entry.requestStart as number), - timeOrigin + msToSec(entry.responseEnd as number), - { - op: 'browser', - name: 'request', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', - }, + startAndEndSpan(span, requestStartTimestamp, responseEndTimestamp, { + op: 'browser', + name: 'request', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', }, - ); + }); - startAndEndSpan( - span, - timeOrigin + msToSec(entry.responseStart as number), - timeOrigin + msToSec(entry.responseEnd as number), - { - op: 'browser', - name: 'response', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', - }, + startAndEndSpan(span, responseStartTimestamp, responseEndTimestamp, { + op: 'browser', + name: 'response', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', }, - ); + }); } } diff --git a/packages/deno/package.json b/packages/deno/package.json index 7450d7a57e24..eb17293ef66c 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -41,9 +41,9 @@ "build:types": "run-s deno-types build:types:tsc build:types:bundle", "build:types:tsc": "tsc -p tsconfig.types.json", "build:types:bundle": "rollup -c rollup.types.config.mjs", - "build:tarball": "npm pack", + "build:tarball": "node ./scripts/prepack.js && npm pack", "circularDepCheck": "madge --circular src/index.ts", - "clean": "rimraf build build-types build-test coverage", + "clean": "rimraf build build-types build-test coverage sentry-deno-*.tgz", "prefix": "yarn deno-types", "fix": "eslint . --format stylish --fix", "prelint": "yarn deno-types", @@ -55,7 +55,7 @@ "test:types": "deno check ./build/index.mjs", "test:unit": "deno test --allow-read --allow-run", "test:unit:update": "deno test --allow-read --allow-write --allow-run -- --update", - "yalc:publish": "yalc publish --push --sig" + "yalc:publish": "node ./scripts/prepack.js && yalc publish --push --sig" }, "volta": { "extends": "../../package.json" diff --git a/packages/deno/scripts/prepack.js b/packages/deno/scripts/prepack.js new file mode 100644 index 000000000000..19422f912715 --- /dev/null +++ b/packages/deno/scripts/prepack.js @@ -0,0 +1,111 @@ +/* eslint-disable no-console */ + +/** + * This script prepares the central `build` directory for NPM package creation. + * It first copies all non-code files into the `build` directory, including `package.json`, which + * is edited to adjust entry point paths. These corrections are performed so that the paths align with + * the directory structure inside `build`. + * + * TODO(v9): Remove this script and change the Deno SDK to import from build/X. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const BUILD_DIR = 'build'; + +const ENTRY_POINTS = ['main', 'module', 'types', 'browser']; +const EXPORT_MAP_ENTRY_POINT = 'exports'; +const TYPES_VERSIONS_ENTRY_POINT = 'typesVersions'; + +const PACKAGE_JSON = 'package.json'; + +/** + * @typedef {Record<(typeof ENTRY_POINTS)[number], string>} PackageJsonEntryPoints - an object containing module details + */ + +/** + * @typedef {Record} ConditionalExportEntryPoints - an object containing module details + */ + +/** + * @typedef {Record>} TypeVersions - an object containing module details + */ + +/** + * @typedef {Partial & Record>} PackageJsonExports - types for `package.json` exports + */ + +/** + * @typedef {Record & PackageJsonEntryPoints & {[EXPORT_MAP_ENTRY_POINT]: PackageJsonExports} & {[TYPES_VERSIONS_ENTRY_POINT]: TypeVersions}} PackageJson - types for `package.json` + */ + +/** + * @type {PackageJson} + */ +const pkgJson = require(path.resolve(PACKAGE_JSON)); + +// check if build dir exists +if (!fs.existsSync(path.resolve(BUILD_DIR))) { + console.error(`\nERROR: Directory '${BUILD_DIR}' does not exist in ${pkgJson.name}.`); + console.error("This script should only be executed after you've run `yarn build`."); + process.exit(1); +} + +// package.json modifications +/** + * @type {PackageJson} + */ +const newPkgJson = { ...pkgJson }; + +// modify entry points to point to correct paths (i.e. strip out the build directory) +ENTRY_POINTS.filter(entryPoint => newPkgJson[entryPoint]).forEach(entryPoint => { + newPkgJson[entryPoint] = newPkgJson[entryPoint].replace(`${BUILD_DIR}/`, ''); +}); + +/** + * Recursively traverses the exports object and rewrites all string values to remove the build directory. + * + * @param {PackageJsonExports} exportsObject - the exports object to traverse + * @param {string} key - the key of the current exports object + */ +function rewriteConditionalExportEntryPoint(exportsObject, key) { + const exportsField = exportsObject[key]; + if (!exportsField) { + return; + } + + if (typeof exportsField === 'string') { + exportsObject[key] = exportsField.replace(`${BUILD_DIR}/`, ''); + return; + } + Object.keys(exportsField).forEach(subfieldKey => { + rewriteConditionalExportEntryPoint(exportsField, subfieldKey); + }); +} + +if (newPkgJson[EXPORT_MAP_ENTRY_POINT]) { + Object.keys(newPkgJson[EXPORT_MAP_ENTRY_POINT]).forEach(key => { + rewriteConditionalExportEntryPoint(newPkgJson[EXPORT_MAP_ENTRY_POINT], key); + }); +} + +if (newPkgJson[TYPES_VERSIONS_ENTRY_POINT]) { + Object.entries(newPkgJson[TYPES_VERSIONS_ENTRY_POINT]).forEach(([key, val]) => { + newPkgJson[TYPES_VERSIONS_ENTRY_POINT][key] = Object.entries(val).reduce((acc, [key, val]) => { + const newKey = key.replace(`${BUILD_DIR}/`, ''); + acc[newKey] = val.map(v => v.replace(`${BUILD_DIR}/`, '')); + return acc; + }, {}); + }); +} + +const newPackageJsonPath = path.resolve(BUILD_DIR, PACKAGE_JSON); + +// write modified package.json to file (pretty-printed with 2 spaces) +try { + fs.writeFileSync(newPackageJsonPath, JSON.stringify(newPkgJson, null, 2)); +} catch (error) { + console.error(`\nERROR: Error while writing modified ${PACKAGE_JSON} to disk in ${pkgJson.name}:\n`, error); + process.exit(1); +} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index b84c55e5611f..883ba3c26d41 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -404,6 +404,22 @@ export type NextConfigFunction = ( * Webpack config */ +// Note: The interface for `ignoreWarnings` is larger but we only need this. See https://webpack.js.org/configuration/other-options/#ignorewarnings +export type IgnoreWarningsOption = ( + | { module?: RegExp; message?: RegExp } + | (( + webpackError: { + module?: { + readableIdentifier: (requestShortener: unknown) => string; + }; + message: string; + }, + compilation: { + requestShortener: unknown; + }, + ) => boolean) +)[]; + // The two possible formats for providing custom webpack config in `next.config.js` export type WebpackConfigFunction = (config: WebpackConfigObject, options: BuildContext) => WebpackConfigObject; export type WebpackConfigObject = { @@ -413,7 +429,7 @@ export type WebpackConfigObject = { output: { filename: string; path: string }; target: string; context: string; - ignoreWarnings?: { module?: RegExp }[]; // Note: The interface for `ignoreWarnings` is larger but we only need this. See https://webpack.js.org/configuration/other-options/#ignorewarnings + ignoreWarnings?: IgnoreWarningsOption; resolve?: { modules?: string[]; alias?: { [key: string]: string | boolean }; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index eece7ff30305..4002db18f295 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -14,6 +14,7 @@ import type { VercelCronsConfig } from '../common/types'; import type { BuildContext, EntryPropertyObject, + IgnoreWarningsOption, NextConfigObject, SentryBuildOptions, WebpackConfigFunction, @@ -72,9 +73,7 @@ export function constructWebpackConfigFunction( // Add a loader which will inject code that sets global values addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext); - if (isServer) { - addOtelWarningIgnoreRule(newConfig); - } + addOtelWarningIgnoreRule(newConfig); let pagesDirPath: string | undefined; const maybePagesDirPath = path.join(projectDir, 'pages'); @@ -668,9 +667,28 @@ function getRequestAsyncStorageModuleLocation( function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules): void { const ignoreRules = [ + // Inspired by @matmannion: https://github.com/getsentry/sentry-javascript/issues/12077#issuecomment-2180307072 + (warning, compilation) => { + // This is wapped in try-catch because we are vendoring types for this hook and we can't be 100% sure that we are accessing API that is there + try { + if (!warning.module) { + return false; + } + + const isDependencyThatMayRaiseCriticalDependencyMessage = + /@opentelemetry\/instrumentation/.test(warning.module.readableIdentifier(compilation.requestShortener)) || + /@prisma\/instrumentation/.test(warning.module.readableIdentifier(compilation.requestShortener)); + const isCriticalDependencyMessage = /Critical dependency/.test(warning.message); + + return isDependencyThatMayRaiseCriticalDependencyMessage && isCriticalDependencyMessage; + } catch { + return false; + } + }, + // We provide these objects in addition to the hook above to provide redundancy in case the hook fails. { module: /@opentelemetry\/instrumentation/, message: /Critical dependency/ }, { module: /@prisma\/instrumentation/, message: /Critical dependency/ }, - ]; + ] satisfies IgnoreWarningsOption; if (newConfig.ignoreWarnings === undefined) { newConfig.ignoreWarnings = ignoreRules; diff --git a/packages/node/package.json b/packages/node/package.json index c37a34b515c0..1722f8876fbd 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -87,7 +87,7 @@ "@opentelemetry/resources": "^1.25.1", "@opentelemetry/sdk-trace-base": "^1.25.1", "@opentelemetry/semantic-conventions": "^1.25.1", - "@prisma/instrumentation": "5.16.0", + "@prisma/instrumentation": "5.16.1", "@sentry/core": "8.13.0", "@sentry/opentelemetry": "8.13.0", "@sentry/types": "8.13.0", diff --git a/packages/node/src/integrations/tracing/hapi/index.ts b/packages/node/src/integrations/tracing/hapi/index.ts index 2d951142275e..1303464c5374 100644 --- a/packages/node/src/integrations/tracing/hapi/index.ts +++ b/packages/node/src/integrations/tracing/hapi/index.ts @@ -15,7 +15,7 @@ import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../../../debug-build'; import { generateInstrumentOnce } from '../../../otel/instrument'; import { ensureIsWrapped } from '../../../utils/ensureIsWrapped'; -import type { RequestEvent, Server } from './types'; +import type { Request, RequestEvent, Server } from './types'; const INTEGRATION_NAME = 'Hapi'; @@ -61,7 +61,7 @@ export const hapiErrorPlugin = { register: async function (serverArg: Record) { const server = serverArg as unknown as Server; - server.events.on('request', (request, event) => { + server.events.on('request', (request: Request, event: RequestEvent) => { if (getIsolationScope() !== getDefaultIsolationScope()) { const route = request.route; if (route && route.path) { diff --git a/packages/node/src/integrations/tracing/hapi/types.ts b/packages/node/src/integrations/tracing/hapi/types.ts index 4da83f672076..db3404499148 100644 --- a/packages/node/src/integrations/tracing/hapi/types.ts +++ b/packages/node/src/integrations/tracing/hapi/types.ts @@ -103,38 +103,6 @@ export interface Listener { export type Tags = { [tag: string]: boolean }; -type Dependencies = - | string - | string[] - | { - [key: string]: string; - }; - -interface PluginNameVersion { - name: string; - version?: string | undefined; -} - -interface PluginPackage { - pkg: any; -} - -interface PluginBase { - register: (server: Server, options: T) => void | Promise; - multiple?: boolean | undefined; - dependencies?: Dependencies | undefined; - requirements?: - | { - node?: string | undefined; - hapi?: string | undefined; - } - | undefined; - - once?: boolean | undefined; -} - -type Plugin = PluginBase & (PluginNameVersion | PluginPackage); - interface UserCredentials {} interface AppCredentials {} @@ -205,7 +173,7 @@ interface RequestRoute { }; } -interface Request extends Podium { +export interface Request extends Podium { app: ApplicationState; readonly auth: RequestAuth; events: RequestEvents; @@ -232,16 +200,6 @@ interface ResponseToolkit { readonly continue: symbol; } -interface ServerEventCriteria { - name: T; - channels?: string | string[] | undefined; - clone?: boolean | undefined; - count?: number | undefined; - filter?: string | string[] | { tags: string | string[]; all?: boolean | undefined } | undefined; - spread?: boolean | undefined; - tags?: boolean | undefined; -} - export interface RequestEvent { timestamp: string; tags: string[]; @@ -250,26 +208,15 @@ export interface RequestEvent { error: object; } -type RequestEventHandler = (request: Request, event: RequestEvent, tags: { [key: string]: true }) => void; interface ServerEvents { - on(criteria: 'request' | ServerEventCriteria<'request'>, listener: RequestEventHandler): void; + on(criteria: any, listener: any): void; } -type RouteRequestExtType = - | 'onPreAuth' - | 'onCredentials' - | 'onPostAuth' - | 'onPreHandler' - | 'onPostHandler' - | 'onPreResponse'; - -type ServerRequestExtType = RouteRequestExtType | 'onRequest'; - export type Server = Record & { events: ServerEvents; - ext(event: ServerRequestExtType, method: Lifecycle.Method, options?: Record): void; + register: any; + ext(event: any, method: Lifecycle.Method, options?: Record): void; initialize(): Promise; - register(plugins: Plugin | Array>, options?: Record): Promise; start(): Promise; }; diff --git a/packages/node/src/integrations/tracing/nest.ts b/packages/node/src/integrations/tracing/nest.ts index bbb658318946..ab6a66fdb895 100644 --- a/packages/node/src/integrations/tracing/nest.ts +++ b/packages/node/src/integrations/tracing/nest.ts @@ -100,6 +100,13 @@ export function setupNestErrorHandler(app: MinimalNestJsApp, baseFilter: NestJsE const originalCatch = Reflect.get(target, prop, receiver); return (exception: unknown, host: unknown) => { + const status_code = (exception as { status?: number }).status; + + // don't report expected errors + if (status_code !== undefined && status_code >= 400 && status_code < 500) { + return originalCatch.apply(target, [exception, host]); + } + captureException(exception); return originalCatch.apply(target, [exception, host]); }; diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 746136c03022..4c30ba2bd2cf 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -130,7 +130,7 @@ function _init( } } - if (!isCjs()) { + if (!isCjs() && options.registerEsmLoaderHooks !== false) { maybeInitializeEsmLoader(); } diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 2c00302e2e64..882114a013f9 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -85,6 +85,16 @@ export interface BaseNodeOptions { */ maxSpanWaitDuration?: number; + /** + * Whether to register ESM loader hooks to automatically instrument libraries. + * This is necessary to auto instrument libraries that are loaded via ESM imports, but might it can cause issues + * with certain libraries. If you run into problems running your app with this enabled, + * please raise an issue in https://github.com/getsentry/sentry-javascript. + * + * Defaults to `true`. + */ + registerEsmLoaderHooks?: boolean; + /** Callback that is executed when a fatal global error occurs. */ onFatalError?(this: void, error: Error): void; } diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index dcfc869cb108..e33201b21518 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -49,27 +49,33 @@ If the setup through the wizard doesn't work for you, you can also set up the SD yarn add @sentry/nuxt ``` -### 2. Client-side Setup +### 2. Nuxt Module Setup The Sentry Nuxt SDK is based on [Nuxt Modules](https://nuxt.com/docs/api/kit/modules). -1. Add `@sentry/nuxt` to the modules section of `nuxt.config.ts`: +1. Add `@sentry/nuxt/module` to the modules section of `nuxt.config.ts`: ```javascript // nuxt.config.ts export default defineNuxtConfig({ - modules: ['@sentry/nuxt'], - runtimeConfig: { - public: { - sentry: { - dsn: env.DSN, - // Additional config - }, - }, - }, + modules: ['@sentry/nuxt/module'], }); ``` +2. 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, + }); +} +``` + ### 3. Server-side Setup todo: add server-side setup diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 4862d08aba2d..1a9bbb4c009a 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -12,16 +12,28 @@ "files": [ "/build" ], - "main": "build/module.cjs", - "module": "build/module.mjs", - "types": "build/types.d.ts", + "main": "build/cjs/index.server.js", + "module": "build/esm/index.server.js", + "browser": "build/esm/index.client.js", + "types": "build/types/index.types.d.ts", "exports": { + "./package.json": "./package.json", ".": { - "types": "./build/types.d.ts", - "import": "./build/module.mjs", - "require": "./build/module.cjs" + "types": "./build/types/index.types.d.ts", + "browser": { + "import": "./build/esm/index.client.js", + "require": "./build/cjs/index.client.js" + }, + "node": { + "import": "./build/esm/index.server.js", + "require": "./build/cjs/index.server.js" + } }, - "./package.json": "./package.json" + "./module": { + "types": "./build/module/types.d.ts", + "import": "./build/module/module.mjs", + "require": "./build/module/module.cjs" + } }, "publishConfig": { "access": "public" @@ -31,6 +43,7 @@ }, "dependencies": { "@nuxt/kit": "^3.12.2", + "@sentry/browser": "8.13.0", "@sentry/core": "8.13.0", "@sentry/node": "8.13.0", "@sentry/opentelemetry": "8.13.0", @@ -44,12 +57,14 @@ "nuxt": "^3.12.2" }, "scripts": { - "build": "run-p build:transpile", + "build": "run-p build:transpile build:types build:nuxt-module", "build:dev": "yarn build", - "build:transpile": "nuxt-module-build build --outDir build", + "build:nuxt-module": "nuxt-module-build build --outDir build/module", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", - "build:transpile:watch": "nuxt-module-build build --outDir build --watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "npm pack", "circularDepCheck": "madge --circular src/index.client.ts && madge --circular src/index.server.ts && madge --circular src/index.types.ts", @@ -72,7 +87,8 @@ "^build:types" ], "outputs": [ - "{projectRoot}/build" + "{projectRoot}/build", + "{projectRoot}/build/module" ] } } diff --git a/packages/nuxt/rollup.npm.config.mjs b/packages/nuxt/rollup.npm.config.mjs new file mode 100644 index 000000000000..e800fdbba474 --- /dev/null +++ b/packages/nuxt/rollup.npm.config.mjs @@ -0,0 +1,7 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.client.ts', 'src/client/index.ts'], + }), +); diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index 3a82dfb9f52f..e07c3267c902 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,19 +1,19 @@ +import { init as initBrowser } from '@sentry/browser'; import { applySdkMetadata } from '@sentry/core'; import type { Client } from '@sentry/types'; -import { init as initVue } from '@sentry/vue'; -import type { SentryVueOptions } from '../common/types'; +import type { SentryNuxtOptions } from '../common/types'; /** * Initializes the client-side of the Nuxt SDK * * @param options Configuration options for the SDK. */ -export function init(options: SentryVueOptions): Client | undefined { +export function init(options: SentryNuxtOptions): Client | undefined { const sentryOptions = { ...options, }; applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'vue']); - return initVue(sentryOptions); + return initBrowser(sentryOptions); } diff --git a/packages/nuxt/src/common/debug-build.ts b/packages/nuxt/src/common/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/nuxt/src/common/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/nuxt/src/common/snippets.ts b/packages/nuxt/src/common/snippets.ts new file mode 100644 index 000000000000..5b8a3f1f3ea1 --- /dev/null +++ b/packages/nuxt/src/common/snippets.ts @@ -0,0 +1,47 @@ +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 = '