diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6a938d15facc..33d8c9ed27c0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,9 @@ updates: allow: - dependency-name: "@sentry/cli" - dependency-name: "@sentry/vite-plugin" + - dependency-name: "@opentelemetry/*" + - dependency-name: "@prisma/instrumentation" + - dependency-name: "opentelemetry-instrumentation-fetch-node" versioning-strategy: increase commit-message: prefix: feat diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b131d8cb2717..81ca274c81b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -411,6 +411,7 @@ jobs: ${{ github.workspace }}/packages/browser/build/bundles/** ${{ github.workspace }}/packages/replay/build/bundles/** ${{ github.workspace }}/packages/replay-canvas/build/bundles/** + ${{ github.workspace }}/packages/feedback/build/bundles/** ${{ github.workspace }}/packages/**/*.tgz ${{ github.workspace }}/packages/aws-serverless/build/aws/dist-serverless/*.zip @@ -621,21 +622,20 @@ jobs: - bundle - bundle_min - bundle_replay - - bundle_replay_min - bundle_tracing - - bundle_tracing_min - bundle_tracing_replay - - bundle_tracing_replay_min + - bundle_tracing_replay_feedback + - bundle_tracing_replay_feedback_min project: - chromium include: # Only check all projects for esm & full bundle # We also shard the tests as they take the longest - - bundle: bundle_tracing_replay_min + - bundle: bundle_tracing_replay_feedback_min project: '' shard: 1 shards: 2 - - bundle: bundle_tracing_replay_min + - bundle: bundle_tracing_replay_feedback_min project: '' shard: 2 shards: 2 @@ -652,7 +652,7 @@ jobs: shards: 3 exclude: # Do not run the default chromium-only tests - - bundle: bundle_tracing_replay_min + - bundle: bundle_tracing_replay_feedback_min project: 'chromium' - bundle: esm project: 'chromium' diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 9d34f7878785..6d1c24fe5d45 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -12,6 +12,13 @@ on: env: HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/packages/*/*.tgz + ${{ github.workspace }}/dev-packages/event-proxy-server/build + ${{ github.workspace }}/node_modules + ${{ github.workspace }}/packages/*/node_modules + ${{ github.workspace }}/dev-packages/*/node_modules + permissions: contents: read issues: write @@ -33,10 +40,7 @@ jobs: - name: Check canary cache uses: actions/cache@v4 with: - path: | - ${{ github.workspace }}/packages/*/*.tgz - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules + path: ${{ env.CACHED_BUILD_PATHS }} key: canary-${{ env.HEAD_COMMIT }} - name: Install dependencies run: yarn install @@ -94,24 +98,22 @@ jobs: - uses: pnpm/action-setup@v2 with: version: 8.3.1 + - name: Set up Node uses: actions/setup-node@v4 with: - node-version-file: 'package.json' + node-version-file: 'dev-packages/e2e-tests/package.json' - name: Restore canary cache uses: actions/cache/restore@v4 with: - path: | - ${{ github.workspace }}/packages/*/*.tgz - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules + path: ${{ env.CACHED_BUILD_PATHS }} key: canary-${{ env.HEAD_COMMIT }} - name: Get node version id: versions run: | - echo "echo node=$(jq -r '.volta.node' package.json)" >> $GITHUB_OUTPUT + echo "echo node=$(jq -r '.volta.node' dev-packages/e2e-tests/package.json)" >> $GITHUB_OUTPUT - name: Validate Verdaccio run: yarn test:validate diff --git a/.size-limit.js b/.size-limit.js index 68e7f534efa6..6e005fd7c3e7 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -1,32 +1,32 @@ module.exports = [ - // Main browser webpack builds + // Browser SDK (ESM) { - name: '@sentry/browser (incl. Tracing, Replay, Feedback)', + name: '@sentry/browser', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, replayIntegration, browserTracingIntegration, feedbackIntegration }', + import: createImport('init'), gzip: true, - limit: '90 KB', + limit: '24 KB', }, { - name: '@sentry/browser (incl. Tracing, Replay)', + name: '@sentry/browser (incl. Tracing)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, replayIntegration, browserTracingIntegration }', + import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '90 KB', + limit: '34 KB', }, { - name: '@sentry/browser (incl. Tracing, Replay with Canvas)', + name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, replayIntegration, browserTracingIntegration, replayCanvasIntegration }', + import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '90 KB', + limit: '70 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, replayIntegration, browserTracingIntegration }', + import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '75 KB', + limit: '65 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); config.plugins.push( @@ -41,134 +41,181 @@ module.exports = [ }, }, { - name: '@sentry/browser (incl. Tracing)', + name: '@sentry/browser (incl. Tracing, Replay with Canvas)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, browserTracingIntegration }', + import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '90 KB', + limit: '75 KB', }, { - name: '@sentry/browser (incl. browserTracingIntegration)', + name: '@sentry/browser (incl. Tracing, Replay, Feedback)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, browserTracingIntegration }', + import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '90 KB', + limit: '83 KB', }, { - name: '@sentry/browser (incl. feedbackIntegration)', + name: '@sentry/browser (incl. Feedback)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, feedbackIntegration }', + import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '90 KB', + limit: '37 KB', }, { - name: '@sentry/browser (incl. feedbackModalIntegration)', + name: '@sentry/browser (incl. Feedback, Feedback Modal)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, feedbackIntegration, feedbackModalIntegration }', + import: createImport('init', 'feedbackIntegration', 'feedbackModalIntegration'), gzip: true, - limit: '90 KB', + limit: '37 KB', }, { - name: '@sentry/browser (incl. feedbackScreenshotIntegration)', + name: '@sentry/browser (incl. Feedback, Feedback Modal, Feedback Screenshot)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, feedbackIntegration, feedbackModalIntegration, feedbackScreenshotIntegration }', + import: createImport('init', 'feedbackIntegration', 'feedbackModalIntegration', 'feedbackScreenshotIntegration'), gzip: true, - limit: '90 KB', + limit: '40 KB', }, { name: '@sentry/browser (incl. sendFeedback)', path: 'packages/browser/build/npm/esm/index.js', - import: '{ init, sendFeedback }', + import: createImport('init', 'sendFeedback'), gzip: true, - limit: '90 KB', + limit: '30 KB', }, + // React SDK (ESM) { - name: '@sentry/browser', - path: 'packages/browser/build/npm/esm/index.js', - import: '{ init }', + name: '@sentry/react', + path: 'packages/react/build/esm/index.js', + import: createImport('init', 'ErrorBoundary'), gzip: true, - limit: '90 KB', + limit: '27 KB', }, - - // Browser CDN bundles { - name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', - path: 'packages/browser/build/bundles/bundle.tracing.replay.feedback.min.js', + name: '@sentry/react (incl. Tracing)', + path: 'packages/react/build/esm/index.js', + import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), gzip: true, - limit: '90 KB', + limit: '37 KB', }, + // Vue SDK (ESM) { - name: 'CDN Bundle (incl. Tracing, Replay)', - path: 'packages/browser/build/bundles/bundle.tracing.replay.min.js', + name: '@sentry/vue', + path: 'packages/vue/build/esm/index.js', + import: createImport('init'), gzip: true, - limit: '90 KB', + limit: '28 KB', }, { - name: 'CDN Bundle (incl. Tracing)', - path: 'packages/browser/build/bundles/bundle.tracing.min.js', + name: '@sentry/vue (incl. Tracing)', + path: 'packages/vue/build/esm/index.js', + import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '40 KB', + limit: '38 KB', }, + // Svelte SDK (ESM) + { + name: '@sentry/svelte', + path: 'packages/svelte/build/esm/index.js', + import: createImport('init'), + gzip: true, + limit: '24 KB', + }, + // Browser CDN bundles { name: 'CDN Bundle', - path: 'packages/browser/build/bundles/bundle.min.js', + path: createCDNPath('bundle.min.js'), gzip: true, - limit: '30 KB', + limit: '26 KB', + }, + { + name: 'CDN Bundle (incl. Tracing)', + path: createCDNPath('bundle.tracing.min.js'), + gzip: true, + limit: '36 KB', + }, + { + name: 'CDN Bundle (incl. Tracing, Replay)', + path: createCDNPath('bundle.tracing.replay.min.js'), + gzip: true, + limit: '70 KB', + }, + { + name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', + path: createCDNPath('bundle.tracing.replay.feedback.min.js'), + gzip: true, + limit: '86 KB', }, - // browser CDN bundles (non-gzipped) { - name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', - path: 'packages/browser/build/bundles/bundle.tracing.replay.min.js', + name: 'CDN Bundle - uncompressed', + path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '260 KB', + limit: '80 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', - path: 'packages/browser/build/bundles/bundle.tracing.min.js', + path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '120 KB', + limit: '105 KB', }, { - name: 'CDN Bundle - uncompressed', - path: 'packages/browser/build/bundles/bundle.min.js', + name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', + path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '80 KB', - }, - - // React - { - name: '@sentry/react (incl. Tracing, Replay)', - path: 'packages/react/build/esm/index.js', - import: '{ init, browserTracingIntegration, replayIntegration }', - gzip: true, - limit: '90 KB', + limit: '220 KB', }, + // Next.js SDK (ESM) { - name: '@sentry/react', - path: 'packages/react/build/esm/index.js', - import: '{ init }', - gzip: true, - limit: '90 KB', - }, - - // Next.js - // TODO: Re-enable these, when we figure out why they break... - /* { - name: '@sentry/nextjs Client (incl. Tracing, Replay)', + name: '@sentry/nextjs (client)', path: 'packages/nextjs/build/esm/client/index.js', - import: '{ init, browserTracingIntegration, replayIntegration }', - gzip: true, - limit: '110 KB', + import: createImport('init'), + ignore: ['next/router', 'next/constants'], + gzip: true, + limit: '37 KB', + }, + // SvelteKit SDK (ESM) + { + name: '@sentry/sveltekit (client)', + path: 'packages/sveltekit/build/esm/client/index.js', + import: createImport('init'), + ignore: ['$app/stores'], + gzip: true, + limit: '37 KB', + }, + // Node SDK (ESM) + { + name: '@sentry/node', + path: 'packages/node/build/esm/index.js', + import: createImport('init'), + ignore: [ + 'node:http', + 'node:https', + 'node:diagnostics_channel', + 'async_hooks', + 'child_process', + 'fs', + 'os', + 'path', + 'inspector', + 'worker_threads', + 'http', + 'stream', + 'zlib', + 'net', + 'tls', + ], + gzip: true, + limit: '150 KB', }, - { - name: '@sentry/nextjs Client', - path: 'packages/nextjs/build/esm/client/index.js', - import: '{ init }', - gzip: true, - limit: '57 KB', - }, */ ]; + +function createImport(...args) { + return `{ ${args.join(', ')} }`; +} + +function createCDNPath(name) { + return `packages/browser/build/bundles/${name}`; +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bafd44c9c78..aa4c9807c79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,146 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.0.0-beta.0 + +This is the first beta release of Sentry JavaScript SDK v8. With this release, there are no more planned breaking +changes for the v8 cycle. + +Read the [in-depth migration guide](./MIGRATION.md) to find out how to address any breaking changes in your code. All +deprecations from the v7 cycle, with the exception of `getCurrentHub()`, have been removed and can no longer be used in +v8. + +### Version Support + +The Sentry JavaScript SDK v8 now supports Node.js 14.8.0 or higher. This applies to `@sentry/node` and all of our +node-based server-side sdks (`@sentry/nextjs`, `@sentry/remix`, etc.). + +The browser SDKs now require +[ES2018+](https://caniuse.com/?feats=mdn-javascript_builtins_regexp_dotall,js-regexp-lookbehind,mdn-javascript_builtins_regexp_named_capture_groups,mdn-javascript_builtins_regexp_property_escapes,mdn-javascript_builtins_symbol_asynciterator,mdn-javascript_functions_method_definitions_async_generator_methods,mdn-javascript_grammar_template_literals_template_literal_revision,mdn-javascript_operators_destructuring_rest_in_objects,mdn-javascript_operators_destructuring_rest_in_arrays,promise-finally) +compatible browsers. New minimum browser versions: + +- Chrome 63 +- Edge 79 +- Safari/iOS Safari 12 +- Firefox 58 +- Opera 50 +- Samsung Internet 8.2 + +For more details, please see the [version support section in migration guide](./MIGRATION.md#1-version-support-changes). + +### Package removal + +The following packages will no longer be published + +- [@sentry/hub](./MIGRATION.md#sentryhub) +- [@sentry/tracing](./MIGRATION.md#sentrytracing) +- [@sentry/integrations](./MIGRATION.md#sentryintegrations) +- [@sentry/serverless](./MIGRATION.md#sentryserverless) +- [@sentry/replay](./MIGRATION.md#sentryreplay) + +### Initializing Server-side SDKs (Node, Bun, Next.js, SvelteKit, Astro, Remix): + +Initializing the SDKs on the server-side has been simplified. See more details in our migration docs about +[initializing the SDK in v8](./MIGRATION.md/#initializing-the-node-sdk). + +### Performance Monitoring Changes + +The API around performance monitoring and tracing has been vastly improved, and we've added support for more +integrations out of the box. + +- [Performance Monitoring API](./MIGRATION.md#performance-monitoring-api) +- [Performance Monitoring Integrations](./MIGRATION.md#performance-monitoring-integrations) + +### Important Changes since v8.0.0-alpha.9 + +- **feat(browser): Create spans as children of root span by default (#10986)** + +Because execution context isolation in browser environments does not work reliably, we deciced to keep a flat span +hierarchy by default in v8. + +- **feat(core): Deprecate `addTracingExtensions` (#11579)** + +Instead of calling `Sentry.addTracingExtensions()` if you want to use performance in a browser SDK without using +`browserTracingIntegration()`, you should now call `Sentry.registerSpanErrorInstrumentation()`. + +- **feat(core): Implement `suppressTracing` (#11468)** + +You can use the new `suppressTracing` API to ensure a given callback will not generate any spans: + +```js +return Sentry.suppressTracing(() => { + // Ensure this fetch call does not generate a span + return fetch('/my-url'); +}); +``` + +- **feat: Rename ESM loader hooks to `import` and `loader` (#11498)** + +We renamed the loader hooks for better clarity: + +```sh +# For Node.js <= 18.18.2 +node --loader=@sentry/node/loader app.js + +# For Node.js >= 18.19.0 +node --import=@sentry/node/import app.js +``` + +- **feat(node): Do not exit process by default when other `onUncaughtException` handlers are registered in + `onUncaughtExceptionIntegration` (#11532)** + +In v8, we will no longer exit the node process by default if other uncaught exception handlers have been registered by +the user. + +- **Better handling of transaction name for errors** + +We improved the way we keep the transaction name for error events, even when spans are not sampled or performance is +disabled. + +- feat(fastify): Update scope `transactionName` when handling request (#11447) +- feat(hapi): Update scope `transactionName` when handling request (#11448) +- feat(koa): Update scope `transactionName` when creating router span (#11476) +- feat(sveltekit): Update scope transactionName when handling server-side request (#11511) +- feat(nestjs): Update scope transaction name with parameterized route (#11510) + +### Removal/Refactoring of deprecated functionality + +- feat(core): Remove `getCurrentHub` from `AsyncContextStrategy` (#11581) +- feat(core): Remove `getGlobalHub` export (#11565) +- feat(core): Remove `Hub` class export (#11560) +- feat(core): Remove most Hub class exports (#11536) +- feat(nextjs): Remove webpack 4 support (#11605) +- feat(vercel-edge): Stop using hub (#11539) + +### Other Changes + +- feat: Hoist `getCurrentHub` shim to core as `getCurrentHubShim` (#11537) +- feat(core): Add default behaviour for `rewriteFramesIntegration` in browser (#11535) +- feat(core): Ensure replay envelopes are sent in order when offline (#11413) +- feat(core): Extract errors from props in unkown inputs (#11526) +- feat(core): Update metric normalization (#11518) +- feat(feedback): Customize feedback placeholder text color (#11417) +- feat(feedback): Maintain v7 compat in the @sentry-internal/feedback package (#11461) +- feat(next): Handle existing root spans for isolation scope (#11479) +- feat(node): Ensure tracing without performance (TWP) works (#11564) +- feat(opentelemetry): Export `getRequestSpanData` (#11508) +- feat(opentelemetry): Remove otel.attributes in context (#11604) +- feat(ratelimit): Add metrics rate limit (#11538) +- feat(remix): Skip span creation for `OPTIONS` and `HEAD` requests. (#11149) +- feat(replay): Merge packages together & ensure bundles are built (#11552) +- feat(tracing): Adds span envelope and datacategory (#11534) +- fix(browser): Ensure pageload trace remains active after pageload span finished (#11600) +- fix(browser): Ensure tracing without performance (TWP) works (#11561) +- fix(nextjs): Fix `tunnelRoute` matching logic for hybrid cloud (#11576) +- fix(nextjs): Remove Http integration from Next.js (#11304) +- fix(node): Ensure isolation scope is correctly cloned for non-recording spans (#11503) +- fix(node): Make fastify types more broad (#11544) +- fix(node): Send ANR events without scope if event loop blocked indefinitely (#11578) +- fix(tracing): Fixes latest route name and source not updating correctly (#11533) +- ref(browser): Move browserTracing into browser pkg (#11484) +- ref(feedback): Configure font size (#11437) +- ref(feedback): Refactor Feedback types into @sentry/types and reduce the exported surface area (#11355) + ## 8.0.0-alpha.9 This is the eighth alpha release of Sentry JavaScript SDK v8, which includes a variety of breaking changes. diff --git a/MIGRATION.md b/MIGRATION.md index 51bf19cddb01..d64f3fe03374 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -65,7 +65,7 @@ We've removed the following packages: `@sentry/tracing` has been removed and will no longer be published. See [below](./MIGRATION.md/#3-removal-of-deprecated-apis) for more details. -For Browser SDKs you can import `BrowserTracing` from the SDK directly: +For Browser SDKs you can import `browserTracingIntegration` from the SDK directly: ```js // v7 @@ -86,12 +86,13 @@ import * as Sentry from '@sentry/browser'; Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1.0, - integrations: [new Sentry.BrowserTracing()], + integrations: [Sentry.browserTracingIntegration()], }); ``` -If you were importing `@sentry/tracing` for the side effect, you can now use `Sentry.addTracingExtensions()` to add the -tracing extensions to the SDK. `addTracingExtensions` replaces the `addExtensionMethods` method from `@sentry/tracing`. +If you don't want to use `browserTracingIntegration` but still manually start spans, you can now use +`Sentry.registerSpanErrorInstrumentation()` to setup handlers for span instrumentation. +`registerSpanErrorInstrumentation` replaces the `addExtensionMethods` method from `@sentry/tracing`. ```js // v7 @@ -108,7 +109,7 @@ Sentry.init({ // v8 import * as Sentry from '@sentry/browser'; -Sentry.addTracingExtensions(); +Sentry.registerSpanErrorInstrumentation(); Sentry.init({ dsn: '__DSN__', @@ -1118,6 +1119,7 @@ Sentry.init({ - [Updated behaviour of `extraErrorDataIntegration`](./MIGRATION.md#extraerrordataintegration-changes) - [Updated behaviour of `transactionContext` passed to `tracesSampler`](./MIGRATION.md#transactioncontext-no-longer-passed-to-tracessampler) - [Updated behaviour of `getClient()`](./MIGRATION.md#getclient-always-returns-a-client) +- [Updated behaviour of the SDK in combination with `onUncaughtException` handlers in Node.js](./MIGRATION.md#behaviour-in-combination-with-onuncaughtexception-handlers-in-node.js) - [Removal of Client-Side health check transaction filters](./MIGRATION.md#removal-of-client-side-health-check-transaction-filters) - [Change of Replay default options (`unblock` and `unmask`)](./MIGRATION.md#change-of-replay-default-options-unblock-and-unmask) - [Angular Tracing Decorator renaming](./MIGRATION.md#angular-tracing-decorator-renaming) @@ -1168,6 +1170,16 @@ some attributes may only be set later during the span lifecycle (and thus not be Sentry was actually initialized, using `getClient()` will thus not work anymore. Instead, you should use the new `Sentry.isInitialized()` utility to check this. +#### Behaviour in combination with `onUncaughtException` handlers in Node.js + +Previously the SDK exited the process by default, even though additional `onUncaughtException` may have been registered, +that would have prevented the process from exiting. You could opt out of this behaviour by setting the +`exitEvenIfOtherHandlersAreRegistered: false` in the `onUncaughtExceptionIntegration` options. Up until now the value +for this option defaulted to `true`. + +Going forward, the default value for `exitEvenIfOtherHandlersAreRegistered` will be `false`, meaning that the SDK will +not exit your process when you have registered other `onUncaughtException` handlers. + #### Removal of Client-Side health check transaction filters The SDK no longer filters out health check transactions by default. Instead, they are sent to Sentry but still dropped diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 4224af55ffc7..9a7e99ccb3a9 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -23,6 +23,8 @@ "test:bundle:replay:min": "PW_BUNDLE=bundle_replay_min yarn test", "test:bundle:tracing": "PW_BUNDLE=bundle_tracing yarn test", "test:bundle:tracing:min": "PW_BUNDLE=bundle_tracing_min yarn test", + "test:bundle:full": "PW_BUNDLE=bundle_tracing_replay_feedback yarn test", + "test:bundle:full:min": "PW_BUNDLE=bundle_tracing_replay_feedback_min yarn test", "test:cjs": "PW_BUNDLE=cjs yarn test", "test:esm": "PW_BUNDLE=esm yarn test", "test:loader": "npx playwright test -c playwright.loader.config.ts --project='chromium'", diff --git a/dev-packages/browser-integration-tests/playwright.config.ts b/dev-packages/browser-integration-tests/playwright.config.ts index 78a71108837f..77ed6014d230 100644 --- a/dev-packages/browser-integration-tests/playwright.config.ts +++ b/dev-packages/browser-integration-tests/playwright.config.ts @@ -11,7 +11,7 @@ const config: PlaywrightTestConfig = { testMatch: /test.ts/, use: { - trace: process.env.CI ? 'retry-with-trace' : 'off', + trace: process.env.CI ? 'retain-on-failure' : 'off', }, projects: [ diff --git a/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts b/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts index 5d4ebe233c4d..b58efe858f25 100644 --- a/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts +++ b/dev-packages/browser-integration-tests/suites/feedback/captureFeedback/test.ts @@ -1,10 +1,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; -import { envelopeRequestParser, getEnvelopeType } from '../../../utils/helpers'; +import { envelopeRequestParser, getEnvelopeType, shouldSkipFeedbackTest } from '../../../utils/helpers'; -sentryTest('should capture feedback (@sentry-internal/feedback import)', async ({ getLocalTestPath, page }) => { - if (process.env.PW_BUNDLE) { +sentryTest('should capture feedback', async ({ getLocalTestPath, page }) => { + if (shouldSkipFeedbackTest()) { sentryTest.skip(); } @@ -39,7 +39,7 @@ sentryTest('should capture feedback (@sentry-internal/feedback import)', async ( await page.locator('[name="name"]').fill('Jane Doe'); await page.locator('[name="email"]').fill('janedoe@example.org'); await page.locator('[name="message"]').fill('my example feedback'); - await page.getByLabel('Send Bug Report').click(); + await page.locator('[data-sentry-feedback] .btn--primary').click(); const feedbackEvent = envelopeRequestParser((await feedbackRequestPromise).request()); expect(feedbackEvent).toEqual({ diff --git a/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/init.js b/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/init.js index 46441bdf2538..020e045c780d 100644 --- a/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/init.js +++ b/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/init.js @@ -5,7 +5,7 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', replaysOnErrorSampleRate: 1.0, - replaysSessionSampleRate: 0, + replaysSessionSampleRate: 1.0, integrations: [ Sentry.replayIntegration({ flushMinDelay: 200, diff --git a/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts b/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts index 6868caf99545..6768bf838e75 100644 --- a/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts +++ b/dev-packages/browser-integration-tests/suites/feedback/captureFeedbackAndReplay/hasSampling/test.ts @@ -1,107 +1,111 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeRequestParser, getEnvelopeType } from '../../../../utils/helpers'; -import { getCustomRecordingEvents, getReplayEvent, waitForReplayRequest } from '../../../../utils/replayHelpers'; - -sentryTest( - 'should capture feedback (@sentry-internal/feedback import)', - async ({ forceFlushReplay, getLocalTestPath, page }) => { - if (process.env.PW_BUNDLE) { - sentryTest.skip(); +import { envelopeRequestParser, getEnvelopeType, shouldSkipFeedbackTest } from '../../../../utils/helpers'; +import { + collectReplayRequests, + getReplayBreadcrumbs, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../utils/replayHelpers'; + +sentryTest('should capture feedback', async ({ forceFlushReplay, getLocalTestPath, page }) => { + if (shouldSkipFeedbackTest() || shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + const feedbackRequestPromise = page.waitForResponse(res => { + const req = res.request(); + + const postData = req.postData(); + if (!postData) { + return false; } - const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); - const feedbackRequestPromise = page.waitForResponse(res => { - const req = res.request(); - - const postData = req.postData(); - if (!postData) { - return false; - } - - try { - return getEnvelopeType(req) === 'feedback'; - } catch (err) { - return false; - } - }); + try { + return getEnvelopeType(req) === 'feedback'; + } catch (err) { + return false; + } + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const url = await getLocalTestPath({ testDir: __dirname }); - - const [, , replayReq0] = await Promise.all([page.goto(url), page.getByText('Report a Bug').click(), reqPromise0]); - - // Inputs are slow, these need to be serial - await page.locator('[name="name"]').fill('Jane Doe'); - await page.locator('[name="email"]').fill('janedoe@example.org'); - await page.locator('[name="message"]').fill('my example feedback'); - - // Force flush here, as inputs are slow and can cause click event to be in unpredictable segments - await Promise.all([forceFlushReplay(), reqPromise1]); - - const [, feedbackResp, replayReq2] = await Promise.all([ - page.getByLabel('Send Bug Report').click(), - feedbackRequestPromise, - reqPromise2, - ]); - - const feedbackEvent = envelopeRequestParser(feedbackResp.request()); - const replayEvent = getReplayEvent(replayReq0); - // Feedback breadcrumb is on second segment because we flush when "Report a Bug" is clicked - // And then the breadcrumb is sent when feedback form is submitted - const { breadcrumbs } = getCustomRecordingEvents(replayReq2); - - expect(breadcrumbs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - category: 'sentry.feedback', - data: { feedbackId: expect.any(String) }, - timestamp: expect.any(Number), - type: 'default', - }), - ]), - ); - - expect(feedbackEvent).toEqual({ - type: 'feedback', - breadcrumbs: expect.any(Array), - contexts: { - feedback: { - contact_email: 'janedoe@example.org', - message: 'my example feedback', - name: 'Jane Doe', - replay_id: replayEvent.event_id, - source: 'widget', - url: expect.stringContaining('/dist/index.html'), - }, - }, - level: 'info', - timestamp: expect.any(Number), - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - sdk: { - integrations: expect.arrayContaining(['Feedback']), - version: expect.any(String), - name: 'sentry.javascript.browser', - packages: expect.anything(), - }, - request: { + const url = await getLocalTestPath({ testDir: __dirname }); + + await Promise.all([page.goto(url), page.getByText('Report a Bug').click(), reqPromise0]); + + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayBreadcrumbs(recordingEvents).some(breadcrumb => breadcrumb.category === 'sentry.feedback'); + }); + + // Inputs are slow, these need to be serial + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('my example feedback'); + + // Force flush here, as inputs are slow and can cause click event to be in unpredictable segments + await Promise.all([forceFlushReplay()]); + + const [, feedbackResp] = await Promise.all([ + page.locator('[data-sentry-feedback] .btn--primary').click(), + feedbackRequestPromise, + ]); + + const { replayEvents, replayRecordingSnapshots } = await replayRequestPromise; + const breadcrumbs = getReplayBreadcrumbs(replayRecordingSnapshots); + + const replayEvent = replayEvents[0]; + const feedbackEvent = envelopeRequestParser(feedbackResp.request()); + + expect(breadcrumbs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + category: 'sentry.feedback', + data: { feedbackId: expect.any(String) }, + timestamp: expect.any(Number), + type: 'default', + }), + ]), + ); + + expect(feedbackEvent).toEqual({ + type: 'feedback', + breadcrumbs: expect.any(Array), + contexts: { + feedback: { + contact_email: 'janedoe@example.org', + message: 'my example feedback', + name: 'Jane Doe', + replay_id: replayEvent.event_id, + source: 'widget', url: expect.stringContaining('/dist/index.html'), - headers: { - 'User-Agent': expect.stringContaining(''), - }, }, - platform: 'javascript', - }); - }, -); + }, + level: 'info', + timestamp: expect.any(Number), + event_id: expect.stringMatching(/\w{32}/), + environment: 'production', + sdk: { + integrations: expect.arrayContaining(['Feedback']), + version: expect.any(String), + name: 'sentry.javascript.browser', + packages: expect.anything(), + }, + request: { + url: expect.stringContaining('/dist/index.html'), + headers: { + 'User-Agent': expect.stringContaining(''), + }, + }, + platform: 'javascript', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/init.js b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/init.js new file mode 100644 index 000000000000..85b9d1a0fb2e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; +import { httpClientIntegration } from '@sentry/browser'; + +window.Sentry = { + ...Sentry, + // This would be done by the CDN bundle otherwise + httpClientIntegration: httpClientIntegration, +}; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/subject.js b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/subject.js new file mode 100644 index 000000000000..6ce3c00ab277 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/subject.js @@ -0,0 +1,7 @@ +window._testLazyLoadIntegration = async function run() { + const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration'); + + window.Sentry.getClient()?.addIntegration(integration()); + + window._integrationLoaded = true; +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/test.ts b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/test.ts new file mode 100644 index 000000000000..7faba50a3864 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/existingIntegration/test.ts @@ -0,0 +1,24 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; + +sentryTest('it bails if the integration is already loaded', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")'); + expect(hasIntegration).toBe(false); + + const scriptTagsBefore = await page.evaluate('document.querySelectorAll("script").length'); + + await page.evaluate('window._testLazyLoadIntegration()'); + await page.waitForFunction('window._integrationLoaded'); + + const scriptTagsAfter = await page.evaluate('document.querySelectorAll("script").length'); + + const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")'); + expect(hasIntegration2).toBe(true); + + expect(scriptTagsAfter).toBe(scriptTagsBefore); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/init.js b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/init.js new file mode 100644 index 000000000000..eb75d30bf760 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = { + ...Sentry, + // Ensure this is _not_ set + httpClientIntegration: undefined, +}; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/subject.js b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/subject.js new file mode 100644 index 000000000000..6ce3c00ab277 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/subject.js @@ -0,0 +1,7 @@ +window._testLazyLoadIntegration = async function run() { + const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration'); + + window.Sentry.getClient()?.addIntegration(integration()); + + window._integrationLoaded = true; +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/test.ts b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/test.ts new file mode 100644 index 000000000000..d407445c0e84 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/lazyLoad/validIntegration/test.ts @@ -0,0 +1,24 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; + +sentryTest('it allows to lazy load an integration', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")'); + expect(hasIntegration).toBe(false); + + const scriptTagsBefore = await page.evaluate('document.querySelectorAll("script").length'); + + await page.evaluate('window._testLazyLoadIntegration()'); + await page.waitForFunction('window._integrationLoaded'); + + const scriptTagsAfter = await page.evaluate('document.querySelectorAll("script").length'); + + const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")'); + expect(hasIntegration2).toBe(true); + + expect(scriptTagsAfter).toBe(scriptTagsBefore + 1); +}); diff --git a/dev-packages/browser-integration-tests/suites/manual-client/browser-context/init.js b/dev-packages/browser-integration-tests/suites/manual-client/browser-context/init.js index 4066402e4e15..64e5af159cb0 100644 --- a/dev-packages/browser-integration-tests/suites/manual-client/browser-context/init.js +++ b/dev-packages/browser-integration-tests/suites/manual-client/browser-context/init.js @@ -1,6 +1,6 @@ import { BrowserClient, - Hub, + Scope, breadcrumbsIntegration, dedupeIntegration, defaultStackParser, @@ -31,6 +31,9 @@ const client = new BrowserClient({ integrations, }); -const hub = new Hub(client); +const scope = new Scope(); +scope.setClient(client); -hub.captureException(new Error('test client')); +client.init(); + +scope.captureException(new Error('test client')); diff --git a/dev-packages/browser-integration-tests/suites/manual-client/skip-init-browser-extension/test.ts b/dev-packages/browser-integration-tests/suites/manual-client/skip-init-browser-extension/test.ts index 41d2da3e9e9d..aeac53b9957a 100644 --- a/dev-packages/browser-integration-tests/suites/manual-client/skip-init-browser-extension/test.ts +++ b/dev-packages/browser-integration-tests/suites/manual-client/skip-init-browser-extension/test.ts @@ -28,7 +28,7 @@ sentryTest( expect(isInitialized).toEqual(false); expect(errorLogs.length).toEqual(1); expect(errorLogs[0]).toEqual( - '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/troubleshooting/#setting-up-sentry-in-shared-environments-eg-browser-extensions', + '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/', ); }, ); diff --git a/dev-packages/browser-integration-tests/suites/manual-client/skip-init-chrome-extension/test.ts b/dev-packages/browser-integration-tests/suites/manual-client/skip-init-chrome-extension/test.ts index 401788b588a9..3c5cc9c7648b 100644 --- a/dev-packages/browser-integration-tests/suites/manual-client/skip-init-chrome-extension/test.ts +++ b/dev-packages/browser-integration-tests/suites/manual-client/skip-init-chrome-extension/test.ts @@ -26,6 +26,6 @@ sentryTest('should not initialize when inside a Chrome browser extension', async expect(isInitialized).toEqual(false); expect(errorLogs.length).toEqual(1); expect(errorLogs[0]).toEqual( - '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/troubleshooting/#setting-up-sentry-in-shared-environments-eg-browser-extensions', + '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/', ); }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/denyUrls/init.js b/dev-packages/browser-integration-tests/suites/public-api/denyUrls/init.js new file mode 100644 index 000000000000..c16b31fd1c85 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/denyUrls/init.js @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +window._errorCount = 0; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + denyUrls: ['foo.js'], + beforeSend: event => { + window._errorCount++; + return event; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/denyUrls/subject.js b/dev-packages/browser-integration-tests/suites/public-api/denyUrls/subject.js new file mode 100644 index 000000000000..d0189ca3db75 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/denyUrls/subject.js @@ -0,0 +1,32 @@ +/** + * We always filter on the caller, not the cause of the error + * + * > foo.js file called a function in bar.js + * > bar.js file called a function in baz.js + * > baz.js threw an error + * + * foo.js is denied in the `init` call (init.js), thus we filter it + * */ +var urlWithDeniedUrl = new Error('filter'); +urlWithDeniedUrl.stack = + 'Error: bar\n' + + ' at http://localhost:5000/foo.js:7:19\n' + + ' at bar(http://localhost:5000/bar.js:2:3)\n' + + ' at baz(http://localhost:5000/baz.js:2:9)\n'; + +/** + * > foo-pass.js file called a function in bar-pass.js + * > bar-pass.js file called a function in baz-pass.js + * > baz-pass.js threw an error + * + * foo-pass.js is *not* denied in the `init` call (init.js), thus we don't filter it + * */ +var urlWithoutDeniedUrl = new Error('pass'); +urlWithoutDeniedUrl.stack = + 'Error: bar\n' + + ' at http://localhost:5000/foo-pass.js:7:19\n' + + ' at bar(http://localhost:5000/bar-pass.js:2:3)\n' + + ' at baz(http://localhost:5000/baz-pass.js:2:9)\n'; + +Sentry.captureException(urlWithDeniedUrl); +Sentry.captureException(urlWithoutDeniedUrl); diff --git a/dev-packages/browser-integration-tests/suites/public-api/denyUrls/test.ts b/dev-packages/browser-integration-tests/suites/public-api/denyUrls/test.ts new file mode 100644 index 000000000000..c374e8ae766c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/denyUrls/test.ts @@ -0,0 +1,17 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers'; + +sentryTest('should allow to ignore specific urls', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values?.[0].type).toEqual('Error'); + expect(eventData.exception?.values?.[0].value).toEqual('pass'); + + const count = await page.evaluate('window._errorCount'); + expect(count).toEqual(1); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/init.js b/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/init.js new file mode 100644 index 000000000000..e66214c5c0bf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/init.js @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +window._errorCount = 0; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + ignoreErrors: ['ignoreErrorTest'], + beforeSend: event => { + window._errorCount++; + return event; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/subject.js b/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/subject.js new file mode 100644 index 000000000000..9f7883c97284 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/subject.js @@ -0,0 +1,3 @@ +Sentry.captureException(new Error('foo')); +Sentry.captureException(new Error('ignoreErrorTest')); +Sentry.captureException(new Error('bar')); diff --git a/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/test.ts b/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/test.ts new file mode 100644 index 000000000000..35752ae39232 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/ignoreErrors/test.ts @@ -0,0 +1,19 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests } from '../../../utils/helpers'; + +sentryTest('should allow to ignore specific errors', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const events = await getMultipleSentryEnvelopeRequests(page, 2, { url }); + + expect(events[0].exception?.values?.[0].type).toEqual('Error'); + expect(events[0].exception?.values?.[0].value).toEqual('foo'); + expect(events[1].exception?.values?.[0].type).toEqual('Error'); + expect(events[1].exception?.values?.[0].value).toEqual('bar'); + + const count = await page.evaluate('window._errorCount'); + expect(count).toEqual(2); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/init.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/init.js index 3fb0df7a75d4..3364667c960c 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/init.js @@ -2,8 +2,6 @@ import * as Sentry from '@sentry/browser'; window.Sentry = Sentry; -Sentry.addTracingExtensions(); - Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/init.js b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/init.js new file mode 100644 index 000000000000..0e1297ae8710 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + transport: Sentry.makeBrowserOfflineTransport(), + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/template.html b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/template.html new file mode 100644 index 000000000000..2b3e2f0b27b4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/test.ts b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/test.ts new file mode 100644 index 000000000000..a74a2c891fad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayOffline/test.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; + +sentryTest('should capture replays offline', async ({ getLocalTestPath, page }) => { + // makeBrowserOfflineTransport is not included in any CDN bundles + if (shouldSkipReplayTest() || (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle'))) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + // This would be the obvious way to test offline support but it doesn't appear to work! + // await context.setOffline(true); + + // Abort the first envelope request so the event gets queued + await page.route(/ingest\.sentry\.io/, route => route.abort('internetdisconnected'), { times: 1 }); + + await page.goto(url); + + await new Promise(resolve => setTimeout(resolve, 2_000)); + + // Now send a second event which should be queued after the the first one and force flushing the queue + await page.locator('button').click(); + + const replayEvent0 = getReplayEvent(await reqPromise0); + const replayEvent1 = getReplayEvent(await reqPromise1); + + // Check that we received the envelopes in the correct order + expect(replayEvent0.timestamp).toBeGreaterThan(0); + expect(replayEvent1.timestamp).toBeGreaterThan(0); + expect(replayEvent0.timestamp).toBeLessThan(replayEvent1.timestamp || 0); +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts b/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts index b7de815b7825..8a9ba80cdbc4 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/initial-scope/test.ts @@ -25,6 +25,14 @@ sentryTest('should start a new session with navigation.', async ({ getLocalTestP const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', (route: Route) => route.fulfill({ path: `${__dirname}/dist/index.html` })); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const initSession = await getFirstSentryEnvelopeRequest(page, url); await page.click('#navigate'); diff --git a/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts b/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts index 1359f90ac0e0..62c969d2e76d 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/start-session/test.ts @@ -24,6 +24,14 @@ sentryTest('should start a new session with navigation.', async ({ getLocalTestP const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', (route: Route) => route.fulfill({ path: `${__dirname}/dist/index.html` })); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const initSession = await getFirstSentryEnvelopeRequest(page, url); await page.locator('#navigate').click(); diff --git a/dev-packages/browser-integration-tests/suites/sessions/v7-hub-start-session/test.ts b/dev-packages/browser-integration-tests/suites/sessions/v7-hub-start-session/test.ts index 1359f90ac0e0..62c969d2e76d 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/v7-hub-start-session/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/v7-hub-start-session/test.ts @@ -24,6 +24,14 @@ sentryTest('should start a new session with navigation.', async ({ getLocalTestP const url = await getLocalTestPath({ testDir: __dirname }); await page.route('**/foo', (route: Route) => route.fulfill({ path: `${__dirname}/dist/index.html` })); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const initSession = await getFirstSentryEnvelopeRequest(page, url); await page.locator('#navigate').click(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts index f7dc01ca7f54..5a46a65a4392 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts @@ -49,25 +49,3 @@ sentryTest('should create a navigation transaction on page navigation', async ({ expect(pageloadSpanId).not.toEqual(navigationSpanId); }); - -sentryTest('should create a new trace for for multiple navigations', async ({ getLocalTestPath, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestPath({ testDir: __dirname }); - - await getFirstSentryEnvelopeRequest(page, url); - const navigationEvent1 = await getFirstSentryEnvelopeRequest(page, `${url}#foo`); - const navigationEvent2 = await getFirstSentryEnvelopeRequest(page, `${url}#bar`); - - expect(navigationEvent1.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent2.contexts?.trace?.op).toBe('navigation'); - - const navigation1TraceId = navigationEvent1.contexts?.trace?.trace_id; - const navigation2TraceId = navigationEvent2.contexts?.trace?.trace_id; - - expect(navigation1TraceId).toBeDefined(); - expect(navigation2TraceId).toBeDefined(); - expect(navigation1TraceId).not.toEqual(navigation2TraceId); -}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts index b60cdce9703b..2b2d5fa8bae5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts @@ -14,10 +14,11 @@ sentryTest('should add browser-related spans to pageload transaction', async ({ const eventData = await getFirstSentryEnvelopeRequest(page, url); const browserSpans = eventData.spans?.filter(({ op }) => op === 'browser'); - // Spans `connect`, `cache` and `DNS` are not always inside `pageload` transaction. + // Spans `domContentLoadedEvent`, `connect`, `cache` and `DNS` are not + // always inside `pageload` transaction. expect(browserSpans?.length).toBeGreaterThanOrEqual(4); - ['domContentLoadedEvent', 'loadEvent', 'request', 'response'].forEach(eventDesc => + ['loadEvent', 'request', 'response'].forEach(eventDesc => expect(browserSpans).toContainEqual( expect.objectContaining({ description: eventDesc, diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/init.js new file mode 100644 index 000000000000..5cfae0864821 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracePropagationTargets: ['http://example.com'], +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/subject.js new file mode 100644 index 000000000000..eef6d917a2d8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/subject.js @@ -0,0 +1,5 @@ +fetch('http://example.com/0').then( + fetch('http://example.com/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://example.com/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/test.ts new file mode 100644 index 000000000000..95c7b3052732 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-no-tracing/test.ts @@ -0,0 +1,30 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'should not attach `sentry-trace` header to fetch requests without tracing', + async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + for (const request of requests) { + const requestHeaders = request.headers(); + expect(requestHeaders['sentry-trace']).toBeUndefined(); + expect(requestHeaders['baggage']).toBeUndefined(); + } + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/init.js new file mode 100644 index 000000000000..203a32557f21 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://example.com'], + tracesSampleRate: 0, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/subject.js new file mode 100644 index 000000000000..eef6d917a2d8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/subject.js @@ -0,0 +1,5 @@ +fetch('http://example.com/0').then( + fetch('http://example.com/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://example.com/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/test.ts new file mode 100644 index 000000000000..44911665af7c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should attach `sentry-trace` header to unsampled fetch requests', async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/), + baggage: expect.any(String), + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/), + baggage: expect.any(String), + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/), + baggage: expect.any(String), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/init.js new file mode 100644 index 000000000000..89854ab72450 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://example.com'], + // no tracesSampleRate defined means TWP mode +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/subject.js new file mode 100644 index 000000000000..eef6d917a2d8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/subject.js @@ -0,0 +1,5 @@ +fetch('http://example.com/0').then( + fetch('http://example.com/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://example.com/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/test.ts new file mode 100644 index 000000000000..8c5c626731df --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance/test.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'should attach `sentry-trace` header to tracing without performance (TWP) fetch requests', + async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + baggage: expect.any(String), + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + baggage: expect.any(String), + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + baggage: expect.any(String), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/init.js index a8d8c03d1f25..e94191654e74 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/init.js @@ -2,10 +2,22 @@ import * as Sentry from '@sentry/browser'; window.Sentry = Sentry; +window._sentryTransactionsCount = 0; + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - // disable pageload transaction - integrations: [Sentry.browserTracingIntegration({ instrumentPageLoad: false })], + // disable auto span creation + integrations: [ + Sentry.browserTracingIntegration({ + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], tracePropagationTargets: ['http://example.com'], tracesSampleRate: 1, + autoSessionTracking: false, + beforeSendTransaction() { + window._sentryTransactionsCount++; + return null; + }, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/test.ts index 4dc5a0ac4e0a..acaf0e98d693 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-no-active-span/test.ts @@ -1,35 +1,41 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeUrlRegex, shouldSkipTracingTest } from '../../../../utils/helpers'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; sentryTest( - 'there should be no span created for fetch requests with no active span', - async ({ getLocalTestPath, page }) => { + 'should not create span for fetch requests with no active span but should attach sentry-trace header', + async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } - const url = await getLocalTestPath({ testDir: __dirname }); + const sentryTraceHeaders: string[] = []; - let requestCount = 0; - page.on('request', request => { - expect(envelopeUrlRegex.test(request.url())).toBe(false); - requestCount++; + await page.route('http://example.com/**', route => { + const sentryTraceHeader = route.request().headers()['sentry-trace']; + if (sentryTraceHeader) { + sentryTraceHeaders.push(sentryTraceHeader); + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); }); - await page.goto(url); - - // Here are the requests that should exist: - // 1. HTML page - // 2. Init JS bundle - // 3. Subject JS bundle - // 4 [OPTIONAl] CDN JS bundle - // and then 3 fetch requests - if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_')) { - expect(requestCount).toBe(7); - } else { - expect(requestCount).toBe(6); - } + const url = await getLocalTestUrl({ testDir: __dirname }); + + await Promise.all([page.goto(url), ...[0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))]); + + expect(await page.evaluate('window._sentryTransactionsCount')).toBe(0); + + expect(sentryTraceHeaders).toHaveLength(3); + expect(sentryTraceHeaders).toEqual([ + expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + ]); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/subject.js index f62499b1e9c5..eef6d917a2d8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/subject.js @@ -1 +1,5 @@ -fetch('http://example.com/0').then(fetch('http://example.com/1').then(fetch('http://example.com/2'))); +fetch('http://example.com/0').then( + fetch('http://example.com/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://example.com/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts index 4308491f2d7f..00cf0baafc6a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts @@ -4,7 +4,7 @@ import type { Event } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; -sentryTest('should create spans for multiple fetch requests', async ({ getLocalTestPath, page }) => { +sentryTest('should create spans for fetch requests', async ({ getLocalTestPath, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } @@ -40,7 +40,7 @@ sentryTest('should create spans for multiple fetch requests', async ({ getLocalT ); }); -sentryTest('should attach `sentry-trace` header to multiple fetch requests', async ({ getLocalTestPath, page }) => { +sentryTest('should attach `sentry-trace` header to fetch requests', async ({ getLocalTestPath, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } @@ -56,10 +56,25 @@ sentryTest('should attach `sentry-trace` header to multiple fetch requests', asy expect(requests).toHaveLength(3); - for (const request of requests) { - const requestHeaders = request.headers(); - expect(requestHeaders).toMatchObject({ - 'sentry-trace': expect.any(String), - }); - } + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), + baggage: expect.any(String), + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), + baggage: expect.any(String), + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), + baggage: expect.any(String), + }); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/init.js new file mode 100644 index 000000000000..5cfae0864821 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracePropagationTargets: ['http://example.com'], +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/subject.js new file mode 100644 index 000000000000..cb5f05dad5c1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/subject.js @@ -0,0 +1,12 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://example.com/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', 'http://example.com/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', 'http://example.com/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/test.ts new file mode 100644 index 000000000000..95c7b3052732 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-no-tracing/test.ts @@ -0,0 +1,30 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'should not attach `sentry-trace` header to fetch requests without tracing', + async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + for (const request of requests) { + const requestHeaders = request.headers(); + expect(requestHeaders['sentry-trace']).toBeUndefined(); + expect(requestHeaders['baggage']).toBeUndefined(); + } + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/init.js new file mode 100644 index 000000000000..203a32557f21 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://example.com'], + tracesSampleRate: 0, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/subject.js new file mode 100644 index 000000000000..cb5f05dad5c1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/subject.js @@ -0,0 +1,12 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://example.com/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', 'http://example.com/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', 'http://example.com/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/test.ts new file mode 100644 index 000000000000..954628873383 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should attach `sentry-trace` header to unsampled xhr requests', async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/), + baggage: expect.any(String), + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/), + baggage: expect.any(String), + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/), + baggage: expect.any(String), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/init.js new file mode 100644 index 000000000000..e7db5efdc388 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://example.com'], +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/subject.js new file mode 100644 index 000000000000..cb5f05dad5c1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/subject.js @@ -0,0 +1,12 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://example.com/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', 'http://example.com/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', 'http://example.com/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/test.ts new file mode 100644 index 000000000000..2e1a8ee36277 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance/test.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'should attach `sentry-trace` header to tracing without performance (TWP) xhr requests', + async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + baggage: expect.any(String), + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + baggage: expect.any(String), + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + baggage: expect.any(String), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/init.js index a8d8c03d1f25..e94191654e74 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/init.js @@ -2,10 +2,22 @@ import * as Sentry from '@sentry/browser'; window.Sentry = Sentry; +window._sentryTransactionsCount = 0; + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - // disable pageload transaction - integrations: [Sentry.browserTracingIntegration({ instrumentPageLoad: false })], + // disable auto span creation + integrations: [ + Sentry.browserTracingIntegration({ + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], tracePropagationTargets: ['http://example.com'], tracesSampleRate: 1, + autoSessionTracking: false, + beforeSendTransaction() { + window._sentryTransactionsCount++; + return null; + }, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/test.ts index 19c1f5891a39..d3c5f91362c9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-no-active-span/test.ts @@ -1,35 +1,41 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeUrlRegex, shouldSkipTracingTest } from '../../../../utils/helpers'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; sentryTest( - 'there should be no span created for xhr requests with no active span', - async ({ getLocalTestPath, page }) => { + 'should not create span for xhr requests with no active span but should attach sentry-trace header', + async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } - const url = await getLocalTestPath({ testDir: __dirname }); + const sentryTraceHeaders: string[] = []; - let requestCount = 0; - page.on('request', request => { - expect(envelopeUrlRegex.test(request.url())).toBe(false); - requestCount++; + await page.route('http://example.com/**', route => { + const sentryTraceHeader = route.request().headers()['sentry-trace']; + if (sentryTraceHeader) { + sentryTraceHeaders.push(sentryTraceHeader); + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); }); - await page.goto(url); - - // Here are the requests that should exist: - // 1. HTML page - // 2. Init JS bundle - // 3. Subject JS bundle - // 4 [OPTIONAl] CDN JS bundle - // and then 3 fetch requests - if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_')) { - expect(requestCount).toBe(7); - } else { - expect(requestCount).toBe(6); - } + const url = await getLocalTestUrl({ testDir: __dirname }); + + await Promise.all([page.goto(url), ...[0, 1, 2].map(idx => page.waitForRequest(`http://example.com/${idx}`))]); + + expect(await page.evaluate('window._sentryTransactionsCount')).toBe(0); + + expect(sentryTraceHeaders).toHaveLength(3); + expect(sentryTraceHeaders).toEqual([ + expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/), + ]); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr/subject.js index 5790c230aa66..cb5f05dad5c1 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr/subject.js @@ -4,6 +4,7 @@ xhr_1.send(); const xhr_2 = new XMLHttpRequest(); xhr_2.open('GET', 'http://example.com/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); xhr_2.send(); const xhr_3 = new XMLHttpRequest(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr/test.ts index 3d329c9c5c6d..13646a34826e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr/test.ts @@ -4,7 +4,7 @@ import type { Event } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; -sentryTest('should create spans for multiple XHR requests', async ({ getLocalTestPath, page }) => { +sentryTest('should create spans for XHR requests', async ({ getLocalTestPath, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } @@ -28,7 +28,7 @@ sentryTest('should create spans for multiple XHR requests', async ({ getLocalTes ); }); -sentryTest('should attach `sentry-trace` header to multiple XHR requests', async ({ getLocalTestPath, page }) => { +sentryTest('should attach `sentry-trace` header to XHR requests', async ({ getLocalTestPath, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } @@ -44,10 +44,25 @@ sentryTest('should attach `sentry-trace` header to multiple XHR requests', async expect(requests).toHaveLength(3); - for (const request of requests) { - const requestHeaders = request.headers(); - expect(requestHeaders).toMatchObject({ - 'sentry-trace': expect.any(String), - }); - } + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), + baggage: expect.any(String), + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), + baggage: expect.any(String), + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), + baggage: expect.any(String), + }); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/init.js new file mode 100644 index 000000000000..83076460599f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation/test.ts new file mode 100644 index 000000000000..cb8dc17812c7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation/test.ts @@ -0,0 +1,26 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should create a new trace on each navigation', async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + await getFirstSentryEnvelopeRequest(page, url); + const navigationEvent1 = await getFirstSentryEnvelopeRequest(page, `${url}#foo`); + const navigationEvent2 = await getFirstSentryEnvelopeRequest(page, `${url}#bar`); + + expect(navigationEvent1.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent2.contexts?.trace?.op).toBe('navigation'); + + const navigation1TraceId = navigationEvent1.contexts?.trace?.trace_id; + const navigation2TraceId = navigationEvent2.contexts?.trace?.trace_id; + + expect(navigation1TraceId).toMatch(/^[0-9a-f]{32}$/); + expect(navigation2TraceId).toMatch(/^[0-9a-f]{32}$/); + expect(navigation1TraceId).not.toEqual(navigation2TraceId); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload/test.ts new file mode 100644 index 000000000000..16659f013dd0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload/test.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'should create a new trace for a navigation after the initial pageload', + async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const pageloadEvent = await getFirstSentryEnvelopeRequest(page, url); + const navigationEvent1 = await getFirstSentryEnvelopeRequest(page, `${url}#foo`); + + expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + expect(navigationEvent1.contexts?.trace?.op).toBe('navigation'); + + const pageloadTraceId = pageloadEvent.contexts?.trace?.trace_id; + const navigation1TraceId = navigationEvent1.contexts?.trace?.trace_id; + + expect(pageloadTraceId).toMatch(/^[0-9a-f]{32}$/); + expect(navigation1TraceId).toMatch(/^[0-9a-f]{32}$/); + expect(pageloadTraceId).not.toEqual(navigation1TraceId); + }, +); + +sentryTest('error after pageload has pageload traceId', async ({ getLocalTestPath, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const pageloadEvent = await getFirstSentryEnvelopeRequest(page, url); + expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + + const pageloadTraceId = pageloadEvent.contexts?.trace?.trace_id; + expect(pageloadTraceId).toMatch(/^[0-9a-f]{32}$/); + + const [, errorEvent] = await Promise.all([ + page.locator('#errorBtn').click(), + getFirstSentryEnvelopeRequest(page), + ]); + + const errorTraceId = errorEvent.contexts?.trace?.trace_id; + expect(errorTraceId).toBe(pageloadTraceId); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/subject.js new file mode 100644 index 000000000000..5131ea7631e9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/subject.js @@ -0,0 +1,4 @@ +const errorBtn = document.getElementById('errorBtn'); +errorBtn.addEventListener('click', () => { + throw new Error('Sentry Test Error'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/template.html new file mode 100644 index 000000000000..a29ad2056a45 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/transport/offline/init.js b/dev-packages/browser-integration-tests/suites/transport/offline/init.js index a69f8a32b32a..e102d6a8a5a5 100644 --- a/dev-packages/browser-integration-tests/suites/transport/offline/init.js +++ b/dev-packages/browser-integration-tests/suites/transport/offline/init.js @@ -4,5 +4,5 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport), + transport: Sentry.makeBrowserOfflineTransport(), }); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index d2907ae47af1..9b967748208e 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -26,21 +26,14 @@ const useLoader = bundleKey.startsWith('loader'); // In this case, if we encounter this import, we want to add this CDN bundle file instead const IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS: Record = { httpClientIntegration: 'httpclient', - HttpClient: 'httpclient', captureConsoleIntegration: 'captureconsole', CaptureConsole: 'captureconsole', debugIntegration: 'debug', - Debug: 'debug', rewriteFramesIntegration: 'rewriteframes', - RewriteFrames: 'rewriteframes', contextLinesIntegration: 'contextlines', - ContextLines: 'contextlines', extraErrorDataIntegration: 'extraerrordata', - ExtraErrorData: 'extraerrordata', reportingObserverIntegration: 'reportingobserver', - ReportingObserver: 'reportingobserver', sessionTimingIntegration: 'sessiontiming', - SessionTiming: 'sessiontiming', }; const BUNDLE_PATHS: Record> = { @@ -55,6 +48,8 @@ const BUNDLE_PATHS: Record> = { bundle_tracing_min: 'build/bundles/bundle.tracing.min.js', bundle_tracing_replay: 'build/bundles/bundle.tracing.replay.js', bundle_tracing_replay_min: 'build/bundles/bundle.tracing.replay.min.js', + bundle_tracing_replay_feedback: 'build/bundles/bundle.tracing.replay.feedback.js', + bundle_tracing_replay_feedback_min: 'build/bundles/bundle.tracing.replay.feedback.min.js', loader_base: 'build/bundles/bundle.min.js', loader_eager: 'build/bundles/bundle.min.js', loader_debug: 'build/bundles/bundle.debug.min.js', @@ -227,7 +222,8 @@ class SentryScenarioGenerationPlugin { const integrationBundleKey = bundleKey .replace('loader_', 'bundle_') .replace('_replay', '') - .replace('_tracing', ''); + .replace('_tracing', '') + .replace('_feedback', ''); this.requiredIntegrations.forEach(integration => { const fileName = `${integration}.bundle.js`; diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 0e54eea0fb31..2fc55acd51c7 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -189,6 +189,18 @@ export function shouldSkipTracingTest(): boolean { return bundle != null && !bundle.includes('tracing') && !bundle.includes('esm') && !bundle.includes('cjs'); } +/** + * We can only test replay tests in certain bundles/packages: + * - NPM (ESM, CJS) + * - CDN bundles that contain the Replay integration + * + * @returns `true` if we should skip the feedback test + */ +export function shouldSkipFeedbackTest(): boolean { + const bundle = process.env.PW_BUNDLE as string | undefined; + return bundle != null && !bundle.includes('feedback') && !bundle.includes('esm') && !bundle.includes('cjs'); +} + /** * Waits until a number of requests matching urlRgx at the given URL arrive. * If the timout option is configured, this function will abort waiting, even if it hasn't reveived the configured diff --git a/dev-packages/e2e-tests/test-applications/esm-loader-node-express-app/package.json b/dev-packages/e2e-tests/test-applications/esm-loader-node-express-app/package.json index 70055cdf8159..056929e63a76 100644 --- a/dev-packages/e2e-tests/test-applications/esm-loader-node-express-app/package.json +++ b/dev-packages/e2e-tests/test-applications/esm-loader-node-express-app/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "node --import=@sentry/node/register src/app.mjs", + "start": "node --import=@sentry/node/import-hook src/app.mjs", "clean": "npx rimraf node_modules,pnpm-lock.yaml", "test:build": "pnpm install", "test:assert": "playwright test" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx index 5ae73102057d..92bee1dbac4b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx @@ -1,3 +1,6 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + export const dynamic = 'force-dynamic'; export default function Page() { @@ -9,6 +12,9 @@ export async function generateMetadata({ }: { searchParams: { [key: string]: string | string[] | undefined }; }) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + if (searchParams['shouldThrowInGenerateMetadata']) { throw new Error('generateMetadata Error'); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts index 52c28e1d974a..da08ccb481bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -37,8 +37,14 @@ test('Should send a transaction and an error event for a faulty generateMetadata await page.goto(`/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1`); - expect(await transactionPromise).toBeDefined(); - expect(await errorEventPromise).toBeDefined(); + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); + expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Should send a transaction event for a generateViewport() function invokation', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts index c0a24f747d56..3e25a99133da 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts @@ -24,7 +24,8 @@ test('Should send a transaction with a fetch span', async ({ page }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + // todo: without the HTTP integration in the Next.js SDK, this is set to 'manual' -> we could rename this to be more specific + 'sentry.origin': 'manual', }), description: 'GET http://example.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/error/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/error/page.tsx index 35d15616fd1c..1a86e2ac59cf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/error/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/error/page.tsx @@ -1,7 +1,12 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + export const dynamic = 'force-dynamic'; export const runtime = 'edge'; export default async function Page() { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope throw new Error('Edge Server Component Error'); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx index c7a6a8887e90..9d6ec241fca6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/edge-server-components/page.tsx @@ -1,7 +1,13 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + export const dynamic = 'force-dynamic'; export const runtime = 'edge'; export default async function Page() { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + return

Hello world!

; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts index a43862231568..8879a85c488a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/edge/route.ts @@ -1,3 +1,5 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; import { NextResponse } from 'next/server'; export const runtime = 'edge'; @@ -7,5 +9,8 @@ export async function PATCH() { } export async function DELETE(): Promise { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + throw new Error('route-handler-edge-error'); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/error/route.ts index e2de561c4783..e873849d22df 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/error/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/error/route.ts @@ -1,3 +1,9 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + export async function PUT(): Promise { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + throw new Error('route-handler-error'); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/faulty/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/faulty/page.tsx new file mode 100644 index 000000000000..f31b3f1899da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-component/faulty/page.tsx @@ -0,0 +1,15 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +export default async function FaultyServerComponent() { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (Math.random() + 1 > 0) { + throw new Error('I am a faulty server component'); + } + + return null; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts index a491ccde0a91..6096fcfb1493 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts @@ -1,7 +1,12 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export async function middleware(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + if (request.headers.has('x-should-throw')) { throw new Error('Middleware Error'); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts index b2b2dfdf4fc3..6236aa63d936 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts @@ -1,8 +1,14 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + export const config = { runtime: 'edge', }; export default async function handler() { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + return new Response( JSON.stringify({ name: 'Jim Halpert', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts index 043112494c23..ed1a0acdf412 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts @@ -1,5 +1,10 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + export const config = { runtime: 'edge' }; export default () => { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope throw new Error('Edge Route Error'); }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-fc.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-fc.tsx index 6342caec47ca..552aeae3b331 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-fc.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-fc.tsx @@ -1,4 +1,10 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; + export default function Page() { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + throw new Error('Pages SSR Error FC'); return
Hello world!
; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts index cbe9dcafae71..33bf951337a8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts @@ -40,6 +40,10 @@ test('Should create a transaction with error status for faulty edge routes', asy expect(edgerouteTransaction.contexts?.trace?.status).toBe('internal_error'); expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); expect(edgerouteTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + + // Assert that isolation scope works properly + expect(edgerouteTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(edgerouteTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Should record exceptions for faulty edge routes', async ({ request }) => { @@ -51,5 +55,9 @@ test('Should record exceptions for faulty edge routes', async ({ request }) => { // Noop }); - expect(await errorEventPromise).toBeDefined(); + const errorEvent = await errorEventPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts index 4e69abbdd3e2..f5f3e70c9770 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts @@ -8,7 +8,13 @@ test('Should record exceptions for faulty edge server components', async ({ page await page.goto('/edge-server-components/error'); - expect(await errorEventPromise).toBeDefined(); + const errorEvent = await errorEventPromise; + + expect(errorEvent).toBeDefined(); + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Should record transaction for edge server components', async ({ page }) => { @@ -22,4 +28,8 @@ test('Should record transaction for edge server components', async ({ page }) => expect(serverComponentTransaction).toBeDefined(); expect(serverComponentTransaction.request?.headers).toBeDefined(); + + // Assert that isolation scope works properly + expect(serverComponentTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(serverComponentTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts index 240e04ebe37f..79b07bd37a15 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts @@ -14,6 +14,10 @@ test('Should create a transaction for middleware', async ({ request }) => { expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + + // Assert that isolation scope works properly + expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Should create a transaction with error status for faulty middleware', async ({ request }) => { @@ -43,7 +47,11 @@ test('Records exceptions happening in middleware', async ({ request }) => { // Noop }); - expect(await errorEventPromise).toBeDefined(); + const errorEvent = await errorEventPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts index 73f8bd5e31b9..3e6396c4a618 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts @@ -24,7 +24,7 @@ test('Will capture error for SSR rendering error with a connected trace (Functio return errorEvent?.exception?.values?.[0]?.value === 'Pages SSR Error FC'; }); - const serverComponentTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const ssrTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === '/pages-router/ssr-error-fc' && (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id @@ -33,6 +33,14 @@ test('Will capture error for SSR rendering error with a connected trace (Functio await page.goto('/pages-router/ssr-error-fc'); - expect(await errorEventPromise).toBeDefined(); - expect(await serverComponentTransaction).toBeDefined(); + const errorEvent = await errorEventPromise; + const ssrTransaction = await ssrTransactionPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + + // TODO(lforst): Reuse SSR request span isolation scope to fix the following two assertions + // expect(ssrTransaction.tags?.['my-isolated-tag']).toBe(true); + // expect(ssrTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts index 70f9bb32d3bc..21e0bf4c745f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts @@ -48,8 +48,15 @@ test('Should record exceptions and transactions for faulty route handlers', asyn const routehandlerTransaction = await routehandlerTransactionPromise; const routehandlerError = await errorEventPromise; + // Assert that isolation scope works properly + expect(routehandlerTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(routehandlerTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(routehandlerError.tags?.['my-isolated-tag']).toBe(true); + expect(routehandlerError.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(routehandlerTransaction.contexts?.trace?.status).toBe('unknown_error'); expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.origin).toBe('auto.function.nextjs'); expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-error'); // TODO: Uncomment once we update the scope transaction name on the server side @@ -87,6 +94,12 @@ test.describe('Edge runtime', () => { const routehandlerTransaction = await routehandlerTransactionPromise; const routehandlerError = await errorEventPromise; + // Assert that isolation scope works properly + expect(routehandlerTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(routehandlerTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(routehandlerError.tags?.['my-isolated-tag']).toBe(true); + expect(routehandlerError.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); expect(routehandlerTransaction.contexts?.runtime?.name).toBe('vercel-edge'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts new file mode 100644 index 000000000000..00aeae924fcc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server'; +import axios, { AxiosError } from 'axios'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 90_000; + +test('Sends a transaction for a server component', async ({ page }) => { + // TODO: Fix that this is flakey on dev server - might be an SDK bug + test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); + + const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'function.nextjs' && + transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' + ); + }); + + await page.goto('/server-component/parameter/1337/42'); + + const transactionEvent = await serverComponentTransactionPromise; + const transactionEventId = transactionEvent.event_id; + + expect(transactionEvent.request?.headers).toBeDefined(); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Should not set an error status on a server component transaction when it redirects', async ({ page }) => { + // TODO: Fix that this is flakey on dev server - might be an SDK bug + test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); + + const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'Page Server Component (/server-component/redirect)'; + }); + + await page.goto('/server-component/redirect'); + + expect((await serverComponentTransactionPromise).contexts?.trace?.status).not.toBe('internal_error'); +}); + +test('Should set a "not_found" status on a server component transaction when notFound() is called', async ({ + page, +}) => { + // TODO: Fix that this is flakey on dev server - might be an SDK bug + test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); + + const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'Page Server Component (/server-component/not-found)'; + }); + + await page.goto('/server-component/not-found'); + + expect((await serverComponentTransactionPromise).contexts?.trace?.status).toBe('not_found'); +}); + +test('Should capture an error and transaction with correct status for a faulty server component', async ({ page }) => { + const transactionEventPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'Page Server Component (/server-component/faulty)'; + }); + + const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'I am a faulty server component'; + }); + + await page.goto('/server-component/faulty'); + + const transactionEvent = await transactionEventPromise; + const errorEvent = await errorEventPromise; + + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); + + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); + expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 57ddb57f75cf..5f7d4dc8496d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -48,75 +48,6 @@ test('Sends a pageload transaction', async ({ page }) => { .toBe(200); }); -if (process.env.TEST_ENV === 'production') { - // TODO: Fix that this is flakey on dev server - might be an SDK bug - test('Sends a transaction for a server component', async ({ page }) => { - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'function.nextjs' && - transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' - ); - }); - - await page.goto('/server-component/parameter/1337/42'); - - const transactionEvent = await serverComponentTransactionPromise; - const transactionEventId = transactionEvent.event_id; - - expect(transactionEvent.request?.headers).toBeDefined(); - - await expect - .poll( - async () => { - try { - const response = await axios.get( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - - return response.status; - } catch (e) { - if (e instanceof AxiosError && e.response) { - if (e.response.status !== 404) { - throw e; - } else { - return e.response.status; - } - } else { - throw e; - } - } - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); - }); - - test('Should not set an error status on a server component transaction when it redirects', async ({ page }) => { - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/server-component/redirect)'; - }); - - await page.goto('/server-component/redirect'); - - expect((await serverComponentTransactionPromise).contexts?.trace?.status).not.toBe('internal_error'); - }); - - test('Should set a "not_found" status on a server component transaction when notFound() is called', async ({ - page, - }) => { - const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/server-component/not-found)'; - }); - - await page.goto('/server-component/not-found'); - - expect((await serverComponentTransactionPromise).contexts?.trace?.status).toBe('not_found'); - }); -} - test('Should send a transaction for instrumented server actions', async ({ page }) => { const nextjsVersion = packageJson.dependencies.next; const nextjsMajor = Number(nextjsVersion.split('.')[0]); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-app/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-app/package.json index 9e8779cf9bdd..56c8818933af 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-app/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-app/package.json @@ -3,10 +3,11 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "node src/app.js", + "start": "ts-node src/app.ts", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", + "typecheck": "tsc", + "test:build": "pnpm install && pnpm run typecheck", "test:assert": "pnpm test" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-app/src/app.js b/dev-packages/e2e-tests/test-applications/node-fastify-app/src/app.ts similarity index 69% rename from dev-packages/e2e-tests/test-applications/node-fastify-app/src/app.js rename to dev-packages/e2e-tests/test-applications/node-fastify-app/src/app.ts index ded1a2de7fcb..187d259b1f5b 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-app/src/app.js +++ b/dev-packages/e2e-tests/test-applications/node-fastify-app/src/app.ts @@ -1,8 +1,21 @@ -require('./tracing'); +import type * as S from '@sentry/node'; +const Sentry = require('@sentry/node') as typeof S; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + integrations: [], + tracesSampleRate: 1, + tunnel: 'http://localhost:3031/', // proxy server + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +import type * as H from 'http'; +import type * as F from 'fastify'; -const Sentry = require('@sentry/node'); -const { fastify } = require('fastify'); -const http = require('http'); +// Make sure fastify is imported after Sentry is initialized +const { fastify } = require('fastify') as typeof F; +const http = require('http') as typeof H; const app = fastify(); const port = 3030; @@ -10,28 +23,28 @@ const port2 = 3040; Sentry.setupFastifyErrorHandler(app); -app.get('/test-success', function (req, res) { +app.get('/test-success', function (_req, res) { res.send({ version: 'v1' }); }); -app.get('/test-param/:param', function (req, res) { +app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { res.send({ paramWas: req.params.param }); }); -app.get('/test-inbound-headers/:id', function (req, res) { +app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { const headers = req.headers; res.send({ headers, id: req.params.id }); }); -app.get('/test-outgoing-http/:id', async function (req, res) { +app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { const id = req.params.id; const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); res.send(data); }); -app.get('/test-outgoing-fetch/:id', async function (req, res) { +app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { const id = req.params.id; const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); const data = await response.json(); @@ -55,8 +68,8 @@ app.get('/test-error', async function (req, res) { res.send({ exceptionId }); }); -app.get('/test-exception', async function (req, res) { - throw new Error('This is an exception'); +app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { + throw new Error(`This is an exception with id ${req.params.id}`); }); app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { @@ -101,9 +114,9 @@ app2.get('/external-disallowed', function (req, res) { app2.listen({ port: port2 }); -function makeHttpRequest(url) { +function makeHttpRequest(url: string) { return new Promise(resolve => { - const data = []; + const data: any[] = []; http .request(url, httpRes => { diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-app/src/tracing.js b/dev-packages/e2e-tests/test-applications/node-fastify-app/src/tracing.js deleted file mode 100644 index 136b401cbd73..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-fastify-app/src/tracing.js +++ /dev/null @@ -1,10 +0,0 @@ -const Sentry = require('@sentry/node'); - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - integrations: [], - tracesSampleRate: 1, - tunnel: 'http://localhost:3031/', // proxy server - tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], -}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/errors.test.ts index e9ee378fa00f..8a4dd6c9bcd7 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/errors.test.ts @@ -41,11 +41,11 @@ test('Sends exception to Sentry', async ({ baseURL }) => { test('Sends correct error event', async ({ baseURL }) => { const errorEventPromise = waitForError('node-fastify-app', event => { - return !event.type && event.exception?.values?.[0]?.value === 'This is an exception'; + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; }); try { - await axios.get(`${baseURL}/test-exception`); + await axios.get(`${baseURL}/test-exception/123`); } catch { // this results in an error, but we don't care - we want to check the error event } @@ -53,16 +53,16 @@ test('Sends correct error event', async ({ baseURL }) => { const errorEvent = await errorEventPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception'); + 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', + url: 'http://localhost:3030/test-exception/123', }); - expect(errorEvent.transaction).toEqual('GET /test-exception'); + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.any(String), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/transactions.test.ts index 6d3ca7e8d6fd..0e2fe4f2215b 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-app/tests/transactions.test.ts @@ -56,6 +56,23 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent).toEqual( expect.objectContaining({ spans: [ + { + data: { + 'plugin.name': 'fastify -> sentry-fastify-error-handler', + 'fastify.type': 'middleware', + 'hook.name': 'onRequest', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'manual', + }, + description: 'middleware - fastify -> sentry-fastify-error-handler', + 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), + origin: 'manual', + }, { data: { 'plugin.name': 'fastify -> sentry-fastify-error-handler', diff --git a/dev-packages/e2e-tests/test-applications/node-hapi-app/src/app.js b/dev-packages/e2e-tests/test-applications/node-hapi-app/src/app.js index 95564255b60f..6d6f959410dd 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi-app/src/app.js +++ b/dev-packages/e2e-tests/test-applications/node-hapi-app/src/app.js @@ -44,6 +44,15 @@ const init = async () => { }, }); + server.route({ + method: 'GET', + path: '/test-error/{id}', + handler: function (request) { + console.log('This is an error with id', request.params.id); + throw new Error(`This is an error with id ${request.params.id}`); + }, + }); + server.route({ method: 'GET', path: '/test-failure', diff --git a/dev-packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts index d50c68ac4e72..d0c8c5d19fb7 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts @@ -121,6 +121,20 @@ test('Sends successful transactions to Sentry', async ({ baseURL }) => { .toBe(200); }); +test('sends error with parameterized transaction name', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-hapi-app', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'This is an error with id 123'; + }); + + try { + await axios.get(`${baseURL}/test-error/123`); + } catch {} + + const errorEvent = await errorEventPromise; + + expect(errorEvent?.transaction).toBe('GET /test-error/{id}'); +}); + test('Sends parameterized transactions to Sentry', async ({ baseURL }) => { const pageloadTransactionEventPromise = waitForTransaction('node-hapi-app', transactionEvent => { return ( diff --git a/dev-packages/e2e-tests/test-applications/node-koa-app/index.js b/dev-packages/e2e-tests/test-applications/node-koa-app/index.js index 3ee16ab7200e..9d58bd6ca3b6 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa-app/index.js +++ b/dev-packages/e2e-tests/test-applications/node-koa-app/index.js @@ -75,6 +75,10 @@ router1.get('/test-exception', async ctx => { throw new Error('This is an exception'); }); +router1.get('/test-exception/:id', async ctx => { + throw new Error(`This is an exception with id ${ctx.params.id}`); +}); + router1.get('/test-outgoing-fetch-external-allowed', async ctx => { const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); const data = await fetchResponse.json(); diff --git a/dev-packages/e2e-tests/test-applications/node-koa-app/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-koa-app/tests/errors.test.ts index 1d6cf604f176..5759c2bad543 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa-app/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa-app/tests/errors.test.ts @@ -41,11 +41,11 @@ test('Sends exception to Sentry', async ({ baseURL }) => { test('Sends correct error event', async ({ baseURL }) => { const errorEventPromise = waitForError('node-koa-app', event => { - return !event.type && event.exception?.values?.[0]?.value === 'This is an exception'; + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; }); try { - await axios.get(`${baseURL}/test-exception`); + await axios.get(`${baseURL}/test-exception/123`); } catch { // this results in an error, but we don't care - we want to check the error event } @@ -53,16 +53,16 @@ test('Sends correct error event', async ({ baseURL }) => { const errorEvent = await errorEventPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception'); + 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', + url: 'http://localhost:3030/test-exception/123', }); - expect(errorEvent.transaction).toEqual('GET /test-exception'); + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.any(String), diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts index 5dda4845d392..6350cb49f1c5 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts @@ -40,9 +40,9 @@ export class AppController1 { return this.appService.testError(); } - @Get('test-exception') - async testException() { - return this.appService.testException(); + @Get('test-exception/:id') + async testException(@Param('id') id: string) { + return this.appService.testException(id); } @Get('test-outgoing-fetch-external-allowed') diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts index 387668889c24..79b01f26f51c 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts @@ -48,8 +48,8 @@ export class AppService1 { return { exceptionId }; } - testException() { - throw new Error('This is an exception'); + testException(id: string) { + throw new Error(`This is an exception with id ${id}`); } async testOutgoingFetchExternalAllowed() { diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts index 8d478c063472..ba7b0cf1849b 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts @@ -43,11 +43,11 @@ test('Sends captured error to Sentry', async ({ baseURL }) => { test('Sends exception to Sentry', async ({ baseURL }) => { const errorEventPromise = waitForError('node-nestjs-app', event => { - return !event.type && event.exception?.values?.[0]?.value === 'This is an exception'; + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; }); try { - axios.get(`${baseURL}/test-exception`); + axios.get(`${baseURL}/test-exception/123`); } catch { // this results in an error, but we don't care - we want to check the error event } @@ -55,16 +55,16 @@ test('Sends exception to Sentry', async ({ baseURL }) => { const errorEvent = await errorEventPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception'); + 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', + url: 'http://localhost:3030/test-exception/123', }); - expect(errorEvent.transaction).toEqual('GET /test-exception'); + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.any(String), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.server.test.ts index ffdfad2932c8..51488b103107 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.server.test.ts @@ -60,7 +60,6 @@ test.describe('server-side errors', () => { }), ); - // TODO: Uncomment once we update the scope transaction name on the server side - // expect(errorEvent.transaction).toEqual('GET /server-route-error'); + expect(errorEvent.transaction).toEqual('GET /server-route-error'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.server.test.ts index 6274239a936b..d2215cf8e763 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.server.test.ts @@ -63,7 +63,6 @@ test.describe('server-side errors', () => { }), ); - // TODO: Uncomment once we update the scope transaction name on the server side - // expect(errorEvent.transaction).toEqual('GET /server-route-error'); + expect(errorEvent.transaction).toEqual('GET /server-route-error'); }); }); diff --git a/dev-packages/event-proxy-server/package.json b/dev-packages/event-proxy-server/package.json index a0aad2fed4cf..2c70c0877079 100644 --- a/dev-packages/event-proxy-server/package.json +++ b/dev-packages/event-proxy-server/package.json @@ -39,7 +39,7 @@ "build:types": "tsc -p tsconfig.types.json", "clean": "rimraf -g ./node_modules ./build" }, - "dependencies": { + "devDependencies": { "@sentry/types": "8.0.0-alpha.9", "@sentry/utils": "8.0.0-alpha.9" }, diff --git a/dev-packages/node-integration-tests/suites/anr/basic-session.js b/dev-packages/node-integration-tests/suites/anr/basic-session.js index 5661e08b850b..c6415b6358da 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic-session.js +++ b/dev-packages/node-integration-tests/suites/anr/basic-session.js @@ -8,9 +8,8 @@ setTimeout(() => { }, 10000); Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - debug: true, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], autoSessionTracking: true, }); diff --git a/dev-packages/node-integration-tests/suites/anr/basic.js b/dev-packages/node-integration-tests/suites/anr/basic.js index d98b18216703..b1dddf958d46 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic.js +++ b/dev-packages/node-integration-tests/suites/anr/basic.js @@ -8,9 +8,8 @@ setTimeout(() => { }, 10000); Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - debug: true, autoSessionTracking: false, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], }); diff --git a/dev-packages/node-integration-tests/suites/anr/basic.mjs b/dev-packages/node-integration-tests/suites/anr/basic.mjs index 77bb9ae3626d..c3e74222f587 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic.mjs +++ b/dev-packages/node-integration-tests/suites/anr/basic.mjs @@ -8,9 +8,8 @@ setTimeout(() => { }, 10000); Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - debug: true, autoSessionTracking: false, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], }); diff --git a/dev-packages/node-integration-tests/suites/anr/indefinite.mjs b/dev-packages/node-integration-tests/suites/anr/indefinite.mjs new file mode 100644 index 000000000000..d37f041b8c23 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/anr/indefinite.mjs @@ -0,0 +1,31 @@ +import * as assert from 'assert'; +import * as crypto from 'crypto'; + +import * as Sentry from '@sentry/node'; + +setTimeout(() => { + process.exit(); +}, 10000); + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: '1.0', + autoSessionTracking: false, + integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], +}); + +Sentry.setUser({ email: 'person@home.com' }); +Sentry.addBreadcrumb({ message: 'important message!' }); + +function longWork() { + // This loop will run almost indefinitely + for (let i = 0; i < 2000000000; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + assert.ok(hash); + } +} + +setTimeout(() => { + longWork(); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/anr/isolated.mjs b/dev-packages/node-integration-tests/suites/anr/isolated.mjs index d9b179c63e71..2f36575fbbd2 100644 --- a/dev-packages/node-integration-tests/suites/anr/isolated.mjs +++ b/dev-packages/node-integration-tests/suites/anr/isolated.mjs @@ -8,9 +8,8 @@ setTimeout(() => { }, 10000); Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: process.env.SENTRY_DSN, release: '1.0', - debug: true, autoSessionTracking: false, integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })], }); diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index b0299f4a038d..fcd9f121ed34 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -1,7 +1,7 @@ import { conditionalTest } from '../../utils'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; -const EXPECTED_ANR_EVENT = { +const ANR_EVENT = { // Ensure we have context contexts: { trace: { @@ -21,15 +21,6 @@ const EXPECTED_ANR_EVENT = { timezone: expect.any(String), }, }, - user: { - email: 'person@home.com', - }, - breadcrumbs: [ - { - timestamp: expect.any(Number), - message: 'important message!', - }, - ], // and an exception that is our ANR exception: { values: [ @@ -60,21 +51,42 @@ const EXPECTED_ANR_EVENT = { }, }; +const ANR_EVENT_WITH_SCOPE = { + ...ANR_EVENT, + user: { + email: 'person@home.com', + }, + breadcrumbs: [ + { + timestamp: expect.any(Number), + message: 'important message!', + }, + ], +}; + conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => { afterAll(() => { cleanupChildProcesses(); }); test('CJS', done => { - createRunner(__dirname, 'basic.js').expect({ event: EXPECTED_ANR_EVENT }).start(done); + createRunner(__dirname, 'basic.js').withMockSentryServer().expect({ event: ANR_EVENT_WITH_SCOPE }).start(done); }); test('ESM', done => { - createRunner(__dirname, 'basic.mjs').expect({ event: EXPECTED_ANR_EVENT }).start(done); + createRunner(__dirname, 'basic.mjs').withMockSentryServer().expect({ event: ANR_EVENT_WITH_SCOPE }).start(done); + }); + + test('blocked indefinitely', done => { + createRunner(__dirname, 'indefinite.mjs').withMockSentryServer().expect({ event: ANR_EVENT }).start(done); }); test('With --inspect', done => { - createRunner(__dirname, 'basic.mjs').withFlags('--inspect').expect({ event: EXPECTED_ANR_EVENT }).start(done); + createRunner(__dirname, 'basic.mjs') + .withMockSentryServer() + .withFlags('--inspect') + .expect({ event: ANR_EVENT_WITH_SCOPE }) + .start(done); }); test('should exit', done => { @@ -97,22 +109,23 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => test('With session', done => { createRunner(__dirname, 'basic-session.js') + .withMockSentryServer() .expect({ session: { status: 'abnormal', abnormal_mechanism: 'anr_foreground', }, }) - .expect({ event: EXPECTED_ANR_EVENT }) + .expect({ event: ANR_EVENT_WITH_SCOPE }) .start(done); }); test('from forked process', done => { - createRunner(__dirname, 'forker.js').expect({ event: EXPECTED_ANR_EVENT }).start(done); + createRunner(__dirname, 'forker.js').expect({ event: ANR_EVENT_WITH_SCOPE }).start(done); }); test('worker can be stopped and restarted', done => { - createRunner(__dirname, 'stop-and-start.js').expect({ event: EXPECTED_ANR_EVENT }).start(done); + createRunner(__dirname, 'stop-and-start.js').expect({ event: ANR_EVENT_WITH_SCOPE }).start(done); }); const EXPECTED_ISOLATED_EVENT = { @@ -142,6 +155,9 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => }; test('fetches correct isolated scope', done => { - createRunner(__dirname, 'isolated.mjs').expect({ event: EXPECTED_ISOLATED_EVENT }).start(done); + createRunner(__dirname, 'isolated.mjs') + .withMockSentryServer() + .expect({ event: EXPECTED_ISOLATED_EVENT }) + .start(done); }); }); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts new file mode 100644 index 000000000000..3f52580dda1d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts @@ -0,0 +1,22 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + tracesSampleRate: 1, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test/express/:id', req => { + throw new Error(`test_error with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts new file mode 100644 index 000000000000..f76edf06bedb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts @@ -0,0 +1,39 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture and send Express controller error with txn name if tracesSampleRate is 0', done => { + const runner = createRunner(__dirname, 'server.ts') + .ignore('session', 'sessions', 'transaction') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'test_error with id 123', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + transaction: 'GET /test/express/:id', + }, + }) + .start(done); + + expect(() => runner.makeRequest('get', '/test/express/123')).rejects.toThrow(); +}); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error/server.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-unset/server.ts similarity index 83% rename from dev-packages/node-integration-tests/suites/express/handle-error/server.ts rename to dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-unset/server.ts index 1f452fbecc97..38833d7a9bc7 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error/server.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-unset/server.ts @@ -12,8 +12,8 @@ import express from 'express'; const app = express(); -app.get('/test/express', () => { - throw new Error('test_error'); +app.get('/test/express/:id', req => { + throw new Error(`test_error with id ${req.params.id}`); }); Sentry.setupExpressErrorHandler(app); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-unset/test.ts similarity index 74% rename from dev-packages/node-integration-tests/suites/express/handle-error/test.ts rename to dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-unset/test.ts index fca4d270da40..c48a3b1f9444 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error/test.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-unset/test.ts @@ -4,9 +4,9 @@ afterAll(() => { cleanupChildProcesses(); }); -test('should capture and send Express controller error.', done => { +test('should capture and send Express controller error if tracesSampleRate is not set.', done => { const runner = createRunner(__dirname, 'server.ts') - .ignore('session', 'sessions') + .ignore('session', 'sessions', 'transaction') .expect({ event: { exception: { @@ -17,7 +17,7 @@ test('should capture and send Express controller error.', done => { handled: false, }, type: 'Error', - value: 'test_error', + value: 'test_error with id 123', stacktrace: { frames: expect.arrayContaining([ expect.objectContaining({ @@ -34,5 +34,5 @@ test('should capture and send Express controller error.', done => { }) .start(done); - expect(() => runner.makeRequest('get', '/test/express')).rejects.toThrow(); + expect(() => runner.makeRequest('get', '/test/express/123')).rejects.toThrow(); }); diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/trace-header-assign/server.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/trace-header-assign/server.ts new file mode 100644 index 000000000000..f1393c3cfc5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/trace-header-assign/server.ts @@ -0,0 +1,35 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + integrations: [ + // TODO: This used to use the Express integration + ], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import http from 'http'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http.get('http://somewhere.not.sentry/').getHeaders(); + + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/trace-header-assign/test.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/trace-header-assign/test.ts index 2870f0a8b51c..071f02f83647 100644 --- a/dev-packages/node-integration-tests/suites/express/sentry-trace/trace-header-assign/test.ts +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/trace-header-assign/test.ts @@ -7,7 +7,7 @@ afterAll(() => { }); test('Should assign `sentry-trace` header which sets parent trace id of an outgoing request.', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); + const runner = createRunner(__dirname, 'server.ts').start(); const response = await runner.makeRequest('get', '/test/express', { 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', diff --git a/dev-packages/node-integration-tests/suites/express/span-isolationScope/server.ts b/dev-packages/node-integration-tests/suites/express/span-isolationScope/server.ts new file mode 100644 index 000000000000..99a9c53e932e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/span-isolationScope/server.ts @@ -0,0 +1,29 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +Sentry.setTag('global', 'tag'); + +app.get('/test/isolationScope', (_req, res) => { + // eslint-disable-next-line no-console + console.log('This is a test log.'); + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + Sentry.setTag('isolation-scope', 'tag'); + + res.send({}); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/span-isolationScope/test.ts b/dev-packages/node-integration-tests/suites/express/span-isolationScope/test.ts new file mode 100644 index 000000000000..b6954e426a1d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/span-isolationScope/test.ts @@ -0,0 +1,39 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('correctly applies isolation scope to span', done => { + createRunner(__dirname, 'server.ts') + .ignore('session', 'sessions') + .expect({ + transaction: { + transaction: 'GET /test/isolationScope', + breadcrumbs: [ + { + category: 'console', + level: 'log', + message: expect.stringMatching(/\{"port":(\d+)\}/), + timestamp: expect.any(Number), + }, + { + category: 'console', + level: 'log', + message: 'This is a test log.', + timestamp: expect.any(Number), + }, + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + ], + tags: { + global: 'tag', + 'isolation-scope': 'tag', + }, + }, + }) + .start(done) + .makeRequest('get', '/test/isolationScope'); +}); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/withError/server.js b/dev-packages/node-integration-tests/suites/express/tracing/withError/server.js new file mode 100644 index 000000000000..3b45591ec4df --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/tracing/withError/server.js @@ -0,0 +1,29 @@ +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, +}); + +// 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/:id1/:id2', (_req, res) => { + Sentry.captureMessage(new Error('error_1')); + res.send('Success'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/withError/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/withError/test.ts new file mode 100644 index 000000000000..f164bdd0caab --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/tracing/withError/test.ts @@ -0,0 +1,28 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('express tracing experimental', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('should apply the scope transactionName to error events', done => { + createRunner(__dirname, 'server.js') + .ignore('session', 'sessions', 'transaction') + .expect({ + event: { + exception: { + values: [ + { + value: 'error_1', + }, + ], + }, + transaction: 'GET /test/:id1/:id2', + }, + }) + .start(done) + .makeRequest('get', '/test/123/abc?q=1'); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts index c90dd989e37f..60523dc37684 100644 --- a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts @@ -15,15 +15,14 @@ describe('OnUncaughtException integration', () => { }); }); - test('should close process on uncaught error when additional listeners are registered', done => { - expect.assertions(3); + test('should not close process on uncaught error when additional listeners are registered', done => { + expect.assertions(2); const testScriptPath = path.resolve(__dirname, 'additional-listener-test-script.js'); childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => { - expect(err).not.toBeNull(); - expect(err?.code).toBe(1); - expect(stdout).not.toBe("I'm alive!"); + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); done(); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.js b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.js index 69443559e9a8..184dcb3b8ea1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.js @@ -35,6 +35,14 @@ const init = async () => { }, }); + server.route({ + method: 'GET', + path: '/error/{id}', + handler: (_request, _h) => { + return new Error('Sentry Test Error'); + }, + }); + server.route({ method: 'GET', path: '/boom-error', diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts index e5903c536a95..10967a6d6be2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts @@ -56,6 +56,20 @@ describe('hapi auto-instrumentation', () => { .makeRequest('get', '/error'); }); + test('CJS - should assign parameterized transactionName to error.', done => { + createRunner(__dirname, 'scenario.js') + .expect({ + event: { + ...EXPECTED_ERROR_EVENT, + transaction: 'GET /error/{id}', + }, + }) + .ignore('transaction') + .expectError() + .start(done) + .makeRequest('get', '/error/123'); + }); + test('CJS - should handle returned Boom errors in routes.', done => { createRunner(__dirname, 'scenario.js') .expect({ diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/scenario.ts new file mode 100644 index 000000000000..bcf4c3adb432 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/scenario.ts @@ -0,0 +1,58 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck These are only tests +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { loggingTransport, sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 0, + transport: loggingTransport, + integrations: integrations => integrations.filter(i => i.name !== 'Express'), + debug: true, +}); + +import { Controller, Get, Injectable, Module, Param } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; + +const port = 3480; + +// Stop the process from exiting before the transaction is sent +// eslint-disable-next-line @typescript-eslint/no-empty-function +setInterval(() => {}, 1000); + +@Injectable() +class AppService { + getHello(): string { + return 'Hello World!'; + } +} + +@Controller() +class AppController { + constructor(private readonly appService: AppService) {} + + @Get('test-exception/:id') + async testException(@Param('id') id: string): void { + Sentry.captureException(new Error(`error with id ${id}`)); + } +} + +@Module({ + imports: [], + controllers: [AppController], + providers: [AppService], +}) +class AppModule {} + +async function init(): Promise { + const app = await NestFactory.create(AppModule); + Sentry.setupNestErrorHandler(app); + await app.listen(port); + sendPortToRunner(port); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +init(); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/test.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/test.ts new file mode 100644 index 000000000000..f38550469446 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/test.ts @@ -0,0 +1,39 @@ +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +jest.setTimeout(20000); + +const { TS_VERSION } = process.env; +const isOldTS = TS_VERSION && TS_VERSION.startsWith('3.'); + +// This is required to run the test with ts-node and decorators +process.env.TS_NODE_PROJECT = `${__dirname}/tsconfig.json`; + +conditionalTest({ min: 16 })('nestjs auto instrumentation', () => { + afterAll(async () => { + cleanupChildProcesses(); + }); + + test("should assign scope's transactionName if spans are not sampled and express integration is disabled", done => { + if (isOldTS) { + // Skipping test on old TypeScript + return done(); + } + + createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + value: 'error with id 456', + }, + ], + }, + transaction: 'GET /test-exception/:id', + }, + }) + .start(done) + .makeRequest('get', '/test-exception/456'); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/tsconfig.json b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/tsconfig.json new file mode 100644 index 000000000000..84b8f8d6c44e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors-no-express/tsconfig.json @@ -0,0 +1,9 @@ +{ + "include": ["scenario.ts"], + "compilerOptions": { + "module": "commonjs", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "ES2021", + } +} diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/scenario.ts new file mode 100644 index 000000000000..8a00c25fab7a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/scenario.ts @@ -0,0 +1,56 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck These are only tests +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { loggingTransport, sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 0, + transport: loggingTransport, +}); + +import { Controller, Get, Injectable, Module, Param } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; + +const port = 3460; + +// Stop the process from exiting before the transaction is sent +// eslint-disable-next-line @typescript-eslint/no-empty-function +setInterval(() => {}, 1000); + +@Injectable() +class AppService { + getHello(): string { + return 'Hello World!'; + } +} + +@Controller() +class AppController { + constructor(private readonly appService: AppService) {} + + @Get('test-exception/:id') + async testException(@Param('id') id: string): void { + Sentry.captureException(new Error(`error with id ${id}`)); + } +} + +@Module({ + imports: [], + controllers: [AppController], + providers: [AppService], +}) +class AppModule {} + +async function init(): Promise { + const app = await NestFactory.create(AppModule); + Sentry.setupNestErrorHandler(app); + await app.listen(port); + sendPortToRunner(port); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +init(); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/test.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/test.ts new file mode 100644 index 000000000000..264cbe1482cc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/test.ts @@ -0,0 +1,39 @@ +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +jest.setTimeout(20000); + +const { TS_VERSION } = process.env; +const isOldTS = TS_VERSION && TS_VERSION.startsWith('3.'); + +// This is required to run the test with ts-node and decorators +process.env.TS_NODE_PROJECT = `${__dirname}/tsconfig.json`; + +conditionalTest({ min: 16 })('nestjs auto instrumentation', () => { + afterAll(async () => { + cleanupChildProcesses(); + }); + + test("should assign scope's transactionName if spans are not sampled", done => { + if (isOldTS) { + // Skipping test on old TypeScript + return done(); + } + + createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + value: 'error with id 123', + }, + ], + }, + transaction: 'GET /test-exception/:id', + }, + }) + .start(done) + .makeRequest('get', '/test-exception/123'); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/tsconfig.json b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/tsconfig.json new file mode 100644 index 000000000000..84b8f8d6c44e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-errors/tsconfig.json @@ -0,0 +1,9 @@ +{ + "include": ["scenario.ts"], + "compilerOptions": { + "module": "commonjs", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "ES2021", + } +} diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/scenario.ts new file mode 100644 index 000000000000..cffc8de263d2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/scenario.ts @@ -0,0 +1,58 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck These are only tests +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { loggingTransport, sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1, + transport: loggingTransport, + integrations: integrations => integrations.filter(i => i.name !== 'Express'), + debug: true, +}); + +import { Controller, Get, Injectable, Module, Param } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; + +const port = 3470; + +// Stop the process from exiting before the transaction is sent +// eslint-disable-next-line @typescript-eslint/no-empty-function +setInterval(() => {}, 1000); + +@Injectable() +class AppService { + getHello(): string { + return 'Hello World!'; + } +} + +@Controller() +class AppController { + constructor(private readonly appService: AppService) {} + + @Get('test-exception/:id') + async testException(@Param('id') id: string): void { + Sentry.captureException(new Error(`error with id ${id}`)); + } +} + +@Module({ + imports: [], + controllers: [AppController], + providers: [AppService], +}) +class AppModule {} + +async function init(): Promise { + const app = await NestFactory.create(AppModule); + Sentry.setupNestErrorHandler(app); + await app.listen(port); + sendPortToRunner(port); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +init(); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/test.ts b/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/test.ts new file mode 100644 index 000000000000..70eb9e9aaa26 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/test.ts @@ -0,0 +1,40 @@ +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +jest.setTimeout(20000); + +const { TS_VERSION } = process.env; +const isOldTS = TS_VERSION && TS_VERSION.startsWith('3.'); + +// This is required to run the test with ts-node and decorators +process.env.TS_NODE_PROJECT = `${__dirname}/tsconfig.json`; + +conditionalTest({ min: 16 })('nestjs auto instrumentation', () => { + afterAll(async () => { + cleanupChildProcesses(); + }); + + test("should assign scope's transactionName if express integration is disabled", done => { + if (isOldTS) { + // Skipping test on old TypeScript + return done(); + } + + createRunner(__dirname, 'scenario.ts') + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + value: 'error with id 456', + }, + ], + }, + transaction: 'GET /test-exception/:id', + }, + }) + .start(done) + .makeRequest('get', '/test-exception/456'); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/tsconfig.json b/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/tsconfig.json new file mode 100644 index 000000000000..84b8f8d6c44e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nestjs-no-express/tsconfig.json @@ -0,0 +1,9 @@ +{ + "include": ["scenario.ts"], + "compilerOptions": { + "module": "commonjs", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "ES2021", + } +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/scenario.ts new file mode 100644 index 000000000000..55b0a04c6784 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/scenario.ts @@ -0,0 +1,24 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +async function run(): Promise { + // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented + await new Promise(resolve => setTimeout(resolve, 100)); + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/test.ts new file mode 100644 index 000000000000..2de3dcfea66b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/test.ts @@ -0,0 +1,53 @@ +import { conditionalTest } from '../../../../utils'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +conditionalTest({ min: 18 })('outgoing fetch', () => { + test('outgoing fetch requests are correctly instrumented without tracesSampleRate', done => { + expect.assertions(15); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .ensureNoErrorOutput() + .ignore('session', 'sessions') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(done); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.ts new file mode 100644 index 000000000000..191797a10c15 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.ts @@ -0,0 +1,25 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +async function run(): Promise { + // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented + await new Promise(resolve => setTimeout(resolve, 100)); + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts new file mode 100644 index 000000000000..40e14fe648f8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -0,0 +1,48 @@ +import { conditionalTest } from '../../../../utils'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +conditionalTest({ min: 18 })('outgoing fetch', () => { + test('outgoing sampled fetch requests without active span are correctly instrumented', done => { + expect.assertions(11); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .ignore('session', 'sessions') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(done); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/scenario.ts new file mode 100644 index 000000000000..4d47e16fd42f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/scenario.ts @@ -0,0 +1,25 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +async function run(): Promise { + await Sentry.startSpan({ name: 'test_span' }, async () => { + // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented + await new Promise(resolve => setTimeout(resolve, 100)); + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/test.ts new file mode 100644 index 000000000000..40d05d2131eb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/test.ts @@ -0,0 +1,40 @@ +import { conditionalTest } from '../../../../utils'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +conditionalTest({ min: 18 })('outgoing fetch', () => { + test('outgoing sampled fetch requests are correctly instrumented', done => { + expect.assertions(11); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + // we're not too concerned with the actual transaction here since this is tested elsewhere + }, + }) + .start(done); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.ts new file mode 100644 index 000000000000..91c38bf2b23a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.ts @@ -0,0 +1,28 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); + +async function run(): Promise { + // Wrap in span that is not sampled + await Sentry.startSpan({ name: 'outer' }, async () => { + // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented + await new Promise(resolve => setTimeout(resolve, 100)); + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + }); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts new file mode 100644 index 000000000000..0b0ceeaa499c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts @@ -0,0 +1,48 @@ +import { conditionalTest } from '../../../../utils'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +conditionalTest({ min: 18 })('outgoing fetch', () => { + test('outgoing fetch requests are correctly instrumented when not sampled', done => { + expect.assertions(11); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .ignore('session', 'sessions') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(done); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/scenario.ts new file mode 100644 index 000000000000..8213ddf7034e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/scenario.ts @@ -0,0 +1,39 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +import * as http from 'http'; + +async function run(): Promise { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/test.ts new file mode 100644 index 000000000000..570d9f4865a6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/test.ts @@ -0,0 +1,50 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('outgoing http requests are correctly instrumented without tracesSampleRate', done => { + expect.assertions(15); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .ensureNoErrorOutput() + .ignore('session', 'sessions') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.ts new file mode 100644 index 000000000000..e98a9e9aea80 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.ts @@ -0,0 +1,40 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +import * as http from 'http'; + +async function run(): Promise { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts new file mode 100644 index 000000000000..ee0151cd6297 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -0,0 +1,49 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('outgoing sampled http requests without active span are correctly instrumented', done => { + expect.assertions(15); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .ignore('session', 'sessions') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts new file mode 100644 index 000000000000..c346b617b9e6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts @@ -0,0 +1,20 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +import * as http from 'http'; + +Sentry.startSpan({ name: 'test_span' }, () => { + http.get(`${process.env.SERVER_URL}/api/v0`); + http.get(`${process.env.SERVER_URL}/api/v1`); + http.get(`${process.env.SERVER_URL}/api/v2`); + http.get(`${process.env.SERVER_URL}/api/v3`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts new file mode 100644 index 000000000000..f3ad8bc5728e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -0,0 +1,41 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('outgoing sampled http requests are correctly instrumented', done => { + expect.assertions(15); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + // we're not too concerned with the actual transaction here since this is tested elsewhere + }, + }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.ts new file mode 100644 index 000000000000..6b66b11b8ffb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.ts @@ -0,0 +1,43 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); + +import * as http from 'http'; + +async function run(): Promise { + // Wrap in span that is not sampled + await Sentry.startSpan({ name: 'outer' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + }); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts new file mode 100644 index 000000000000..c860958622fa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -0,0 +1,49 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('outgoing http requests are correctly instrumented when not sampled', done => { + expect.assertions(15); + + createTestServer(done) + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + expect(headers['__requestUrl']).toBeUndefined(); + }) + .start() + .then(SERVER_URL => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .ignore('session', 'sessions') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts index e87e9f3df1bc..c43b5607ef52 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts @@ -2,16 +2,18 @@ import { createRunner } from '../../../utils/runner'; import { createTestServer } from '../../../utils/server'; test('HttpIntegration should instrument correct requests when tracePropagationTargets option is provided', done => { - expect.assertions(9); + expect.assertions(11); createTestServer(done) .get('/api/v0', headers => { - expect(typeof headers['baggage']).toBe('string'); - expect(typeof headers['sentry-trace']).toBe('string'); + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); }) .get('/api/v1', headers => { - expect(typeof headers['baggage']).toBe('string'); - expect(typeof headers['sentry-trace']).toBe('string'); + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); diff --git a/dev-packages/rollup-utils/code/otelEsmRegisterLoaderTemplate.js b/dev-packages/rollup-utils/code/otelEsmImportHookTemplate.js similarity index 100% rename from dev-packages/rollup-utils/code/otelEsmRegisterLoaderTemplate.js rename to dev-packages/rollup-utils/code/otelEsmImportHookTemplate.js diff --git a/dev-packages/rollup-utils/code/otelEsmHooksLoaderTemplate.js b/dev-packages/rollup-utils/code/otelEsmLoaderHookTemplate.js similarity index 100% rename from dev-packages/rollup-utils/code/otelEsmHooksLoaderTemplate.js rename to dev-packages/rollup-utils/code/otelEsmLoaderHookTemplate.js diff --git a/dev-packages/rollup-utils/code/sentryNodeEsmHooksLoaderTemplate.js b/dev-packages/rollup-utils/code/sentryNodeEsmHooksLoaderTemplate.js deleted file mode 100644 index 06fb71a76860..000000000000 --- a/dev-packages/rollup-utils/code/sentryNodeEsmHooksLoaderTemplate.js +++ /dev/null @@ -1 +0,0 @@ -import '@sentry/node/hook'; diff --git a/dev-packages/rollup-utils/code/sentryNodeEsmRegisterLoaderTemplate.js b/dev-packages/rollup-utils/code/sentryNodeEsmImportHookTemplate.js similarity index 87% rename from dev-packages/rollup-utils/code/sentryNodeEsmRegisterLoaderTemplate.js rename to dev-packages/rollup-utils/code/sentryNodeEsmImportHookTemplate.js index 5a9fa441f106..a071457406a7 100644 --- a/dev-packages/rollup-utils/code/sentryNodeEsmRegisterLoaderTemplate.js +++ b/dev-packages/rollup-utils/code/sentryNodeEsmImportHookTemplate.js @@ -1,2 +1,2 @@ -import { getFormat, getSource, load, resolve } from '@sentry/node/register'; +import { getFormat, getSource, load, resolve } from '@sentry/node/loader-hook'; export { getFormat, getSource, load, resolve }; diff --git a/dev-packages/rollup-utils/code/sentryNodeEsmLoaderHookTemplate.js b/dev-packages/rollup-utils/code/sentryNodeEsmLoaderHookTemplate.js new file mode 100644 index 000000000000..d5167fd15bf9 --- /dev/null +++ b/dev-packages/rollup-utils/code/sentryNodeEsmLoaderHookTemplate.js @@ -0,0 +1 @@ +import '@sentry/node/import-hook'; diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index ed6cc4492756..f984cbf518b9 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -151,23 +151,23 @@ export function makeOtelLoaders(outputFolder, hookVariant) { throw new Error('hookVariant is neither "otel" nor "sentry-node". Pick one.'); } - const expectedRegisterLoaderLocation = `${outputFolder}/register.mjs`; + const expectedRegisterLoaderLocation = `${outputFolder}/import-hook.mjs`; const foundRegisterLoaderExport = Object.keys(packageDotJSON.exports ?? {}).some(key => { return packageDotJSON?.exports?.[key]?.import?.default === expectedRegisterLoaderLocation; }); if (!foundRegisterLoaderExport) { throw new Error( - `You used the makeOtelLoaders() rollup utility without specifying the register loader inside \`exports[something].import.default\`. Please add "${expectedRegisterLoaderLocation}" as a value there (maybe check for typos - it needs to be "${expectedRegisterLoaderLocation}" exactly).`, + `You used the makeOtelLoaders() rollup utility without specifying the import hook inside \`exports[something].import.default\`. Please add "${expectedRegisterLoaderLocation}" as a value there (maybe check for typos - it needs to be "${expectedRegisterLoaderLocation}" exactly).`, ); } - const expectedHooksLoaderLocation = `${outputFolder}/hook.mjs`; + const expectedHooksLoaderLocation = `${outputFolder}/loader-hook.mjs`; const foundHookLoaderExport = Object.keys(packageDotJSON.exports ?? {}).some(key => { return packageDotJSON?.exports?.[key]?.import?.default === expectedHooksLoaderLocation; }); if (!foundHookLoaderExport) { throw new Error( - `You used the makeOtelLoaders() rollup utility without specifying the hook loader inside \`exports[something].import.default\`. Please add "${expectedHooksLoaderLocation}" as a value there (maybe check for typos - it needs to be "${expectedHooksLoaderLocation}" exactly).`, + `You used the makeOtelLoaders() rollup utility without specifying the loader hook inside \`exports[something].import.default\`. Please add "${expectedHooksLoaderLocation}" as a value there (maybe check for typos - it needs to be "${expectedHooksLoaderLocation}" exactly).`, ); } @@ -190,12 +190,12 @@ export function makeOtelLoaders(outputFolder, hookVariant) { input: path.join( __dirname, 'code', - hookVariant === 'otel' ? 'otelEsmRegisterLoaderTemplate.js' : 'sentryNodeEsmRegisterLoaderTemplate.js', + hookVariant === 'otel' ? 'otelEsmImportHookTemplate.js' : 'sentryNodeEsmImportHookTemplate.js', ), - external: ['@opentelemetry/instrumentation/hook.mjs', '@sentry/node/register'], + external: /.*/, output: { format: 'esm', - file: path.join(outputFolder, 'register.mjs'), + file: path.join(outputFolder, 'import-hook.mjs'), }, }, // --loader hook @@ -203,12 +203,12 @@ export function makeOtelLoaders(outputFolder, hookVariant) { input: path.join( __dirname, 'code', - hookVariant === 'otel' ? 'otelEsmHooksLoaderTemplate.js' : 'sentryNodeEsmHooksLoaderTemplate.js', + hookVariant === 'otel' ? 'otelEsmLoaderHookTemplate.js' : 'sentryNodeEsmLoaderHookTemplate.js', ), - external: ['@opentelemetry/instrumentation/hook.mjs', '@sentry/node/hook'], + external: /.*/, output: { format: 'esm', - file: path.join(outputFolder, 'hook.mjs'), + file: path.join(outputFolder, 'loader-hook.mjs'), }, }, ]); diff --git a/package.json b/package.json index 00945d179e72..05eb47e40591 100644 --- a/package.json +++ b/package.json @@ -136,11 +136,7 @@ "vitest": "^0.29.2", "yalc": "^1.0.0-pre.53" }, - "resolutions": { - "wrap-ansi": "7.0.0", - "string-width": "4.1.0", - "**/terser/source-map": "0.7.4" - }, + "resolutions": {}, "version": "0.0.0", "name": "sentry-javascript", "prettier": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 457cdc36925f..9cb714697126 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -42,14 +42,14 @@ "require": "./build/cjs/integration/middleware/index.js", "types": "./build/types/integration/middleware/index.types.d.ts" }, - "./register": { + "./import": { "import": { - "default": "./build/register.mjs" + "default": "./build/import-hook.mjs" } }, - "./hook": { + "./loader": { "import": { - "default": "./build/hook.mjs" + "default": "./build/loader-hook.mjs" } } }, diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 7dd2e589b86c..e6beabd06e0e 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -23,7 +23,6 @@ export { getCurrentScope, getGlobalScope, getIsolationScope, - Hub, setCurrentClient, Scope, SDK_VERSION, diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index e74dff71ce72..762f8422b549 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -25,14 +25,14 @@ "default": "./build/npm/cjs/index.js" } }, - "./register": { + "./import": { "import": { - "default": "./build/register.mjs" + "default": "./build/import-hook.mjs" } }, - "./hook": { + "./loader": { "import": { - "default": "./build/hook.mjs" + "default": "./build/loader-hook.mjs" } } }, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 68e0a587fb0c..0f1e801f99ed 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -18,7 +18,6 @@ export { getCurrentScope, getGlobalScope, getIsolationScope, - Hub, setCurrentClient, Scope, SDK_VERSION, @@ -93,6 +92,7 @@ export { spotlightIntegration, initOpenTelemetry, spanToJSON, + spanToTraceHeader, trpcMiddleware, } from '@sentry/node'; diff --git a/packages/browser-utils/.eslintrc.js b/packages/browser-utils/.eslintrc.js index 50f4342a74c6..9d8a86b13b96 100644 --- a/packages/browser-utils/.eslintrc.js +++ b/packages/browser-utils/.eslintrc.js @@ -1,5 +1,8 @@ module.exports = { extends: ['../../.eslintrc.js'], + env: { + browser: true, + }, overrides: [ { files: ['src/**'], @@ -8,9 +11,10 @@ module.exports = { }, }, { - files: ['src/browser/web-vitals/**'], + files: ['src/metrics/**'], rules: { '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', }, }, ], diff --git a/packages/browser-utils/src/browser/index.ts b/packages/browser-utils/src/browser/index.ts deleted file mode 100644 index 948a09c0dda4..000000000000 --- a/packages/browser-utils/src/browser/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type { RequestInstrumentationOptions } from './request'; - -export { - BROWSER_TRACING_INTEGRATION_ID, - browserTracingIntegration, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, -} from './browserTracingIntegration'; - -export { instrumentOutgoingRequests, defaultRequestInstrumentationOptions } from './request'; - -export { - addPerformanceInstrumentationHandler, - addClsInstrumentationHandler, - addFidInstrumentationHandler, - addTtfbInstrumentationHandler, - addLcpInstrumentationHandler, -} from './instrument'; diff --git a/packages/browser-utils/src/common/debug-build.ts b/packages/browser-utils/src/debug-build.ts similarity index 100% rename from packages/browser-utils/src/common/debug-build.ts rename to packages/browser-utils/src/debug-build.ts diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 019639e8c0b3..f65c78a6d3bf 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -1,15 +1,23 @@ export { - browserTracingIntegration, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, - BROWSER_TRACING_INTEGRATION_ID, - instrumentOutgoingRequests, - defaultRequestInstrumentationOptions, addPerformanceInstrumentationHandler, addClsInstrumentationHandler, addFidInstrumentationHandler, addTtfbInstrumentationHandler, addLcpInstrumentationHandler, -} from './browser'; +} from './metrics/instrument'; -export type { RequestInstrumentationOptions } from './browser'; +export { + addPerformanceEntries, + startTrackingInteractions, + startTrackingLongTasks, + startTrackingWebVitals, +} from './metrics/browserMetrics'; + +export { addClickKeypressInstrumentationHandler } from './instrument/dom'; + +export { addHistoryInstrumentationHandler } from './instrument/history'; + +export { + addXhrInstrumentationHandler, + SENTRY_XHR_DATA_KEY, +} from './instrument/xhr'; diff --git a/packages/utils/src/instrument/dom.ts b/packages/browser-utils/src/instrument/dom.ts similarity index 94% rename from packages/utils/src/instrument/dom.ts rename to packages/browser-utils/src/instrument/dom.ts index aab64c8c149b..5e813f23eb67 100644 --- a/packages/utils/src/instrument/dom.ts +++ b/packages/browser-utils/src/instrument/dom.ts @@ -1,13 +1,7 @@ -// TODO(v8): Move everything in this file into the browser package. Nothing here is generic and we run risk of leaking browser types into non-browser packages. - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ import type { HandlerDataDom } from '@sentry/types'; -import { uuid4 } from '../misc'; -import { addNonEnumerableProperty, fill } from '../object'; -import { GLOBAL_OBJ } from '../worldwide'; -import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; +import { addHandler, addNonEnumerableProperty, fill, maybeInstrument, triggerHandlers, uuid4 } from '@sentry/utils'; +import { WINDOW } from '../metrics/types'; type SentryWrappedTarget = HTMLElement & { _sentryId?: string }; @@ -25,14 +19,13 @@ type RemoveEventListener = ( type InstrumentedElement = Element & { __sentry_instrumentation_handlers__?: { [key in 'click' | 'keypress']?: { - handler?: Function; + handler?: unknown; /** The number of custom listeners attached to this element */ refCount: number; }; }; }; -const WINDOW = GLOBAL_OBJ as unknown as Window; const DEBOUNCE_DURATION = 1000; let debounceTimerID: number | undefined; @@ -71,7 +64,7 @@ export function instrumentDOM(): void { // could potentially prevent the event from bubbling up to our global listeners. This way, our handler are still // guaranteed to fire at least once.) ['EventTarget', 'Node'].forEach((target: string) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any const proto = (WINDOW as any)[target] && (WINDOW as any)[target].prototype; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-prototype-builtins if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) { diff --git a/packages/utils/src/instrument/history.ts b/packages/browser-utils/src/instrument/history.ts similarity index 78% rename from packages/utils/src/instrument/history.ts rename to packages/browser-utils/src/instrument/history.ts index dc144c0e5818..f791bcef5389 100644 --- a/packages/utils/src/instrument/history.ts +++ b/packages/browser-utils/src/instrument/history.ts @@ -1,15 +1,6 @@ -// TODO(v8): Move everything in this file into the browser package. Nothing here is generic and we run risk of leaking browser types into non-browser packages. - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ import type { HandlerDataHistory } from '@sentry/types'; - -import { fill } from '../object'; -import { supportsHistory } from '../supports'; -import { GLOBAL_OBJ } from '../worldwide'; -import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; - -const WINDOW = GLOBAL_OBJ as unknown as Window; +import { addHandler, fill, maybeInstrument, supportsHistory, triggerHandlers } from '@sentry/utils'; +import { WINDOW } from '../metrics/types'; let lastHref: string | undefined; @@ -33,7 +24,7 @@ function instrumentHistory(): void { } const oldOnPopState = WINDOW.onpopstate; - WINDOW.onpopstate = function (this: WindowEventHandlers, ...args: any[]): any { + WINDOW.onpopstate = function (this: WindowEventHandlers, ...args: unknown[]) { const to = WINDOW.location.href; // keep track of the current URL state, as we always receive only the updated state const from = lastHref; @@ -53,7 +44,7 @@ function instrumentHistory(): void { }; function historyReplacementFunction(originalHistoryFunction: () => void): () => void { - return function (this: History, ...args: any[]): void { + return function (this: History, ...args: unknown[]): void { const url = args.length > 2 ? args[2] : undefined; if (url) { // coerce to string (this is what pushState does) diff --git a/packages/utils/src/instrument/xhr.ts b/packages/browser-utils/src/instrument/xhr.ts similarity index 86% rename from packages/utils/src/instrument/xhr.ts rename to packages/browser-utils/src/instrument/xhr.ts index b00300fd553a..c504c8dce5f6 100644 --- a/packages/utils/src/instrument/xhr.ts +++ b/packages/browser-utils/src/instrument/xhr.ts @@ -1,18 +1,12 @@ -// TODO(v8): Move everything in this file into the browser package. Nothing here is generic and we run risk of leaking browser types into non-browser packages. - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ import type { HandlerDataXhr, SentryWrappedXMLHttpRequest, WrappedFunction } from '@sentry/types'; -import { isString } from '../is'; -import { fill } from '../object'; -import { GLOBAL_OBJ } from '../worldwide'; -import { addHandler, maybeInstrument, triggerHandlers } from './_handlers'; - -const WINDOW = GLOBAL_OBJ as unknown as Window; +import { addHandler, fill, isString, maybeInstrument, triggerHandlers } from '@sentry/utils'; +import { WINDOW } from '../metrics/types'; export const SENTRY_XHR_DATA_KEY = '__sentry_xhr_v3__'; +type WindowWithXhr = Window & { XMLHttpRequest?: typeof XMLHttpRequest }; + /** * Add an instrumentation handler for when an XHR request happens. * The handler function is called once when the request starts and once when it ends, @@ -29,15 +23,14 @@ export function addXhrInstrumentationHandler(handler: (data: HandlerDataXhr) => /** Exported only for tests. */ export function instrumentXHR(): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (!(WINDOW as any).XMLHttpRequest) { + if (!(WINDOW as WindowWithXhr).XMLHttpRequest) { return; } const xhrproto = XMLHttpRequest.prototype; fill(xhrproto, 'open', function (originalOpen: () => void): () => void { - return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: any[]): void { + return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: unknown[]): void { const startTimestamp = Date.now(); // open() should always be called with two or more arguments @@ -87,8 +80,8 @@ export function instrumentXHR(): void { }; if ('onreadystatechange' in this && typeof this.onreadystatechange === 'function') { - fill(this, 'onreadystatechange', function (original: WrappedFunction): Function { - return function (this: SentryWrappedXMLHttpRequest, ...readyStateArgs: any[]): void { + fill(this, 'onreadystatechange', function (original: WrappedFunction) { + return function (this: SentryWrappedXMLHttpRequest, ...readyStateArgs: unknown[]): void { onreadystatechangeHandler(); return original.apply(this, readyStateArgs); }; @@ -100,7 +93,7 @@ export function instrumentXHR(): void { // Intercepting `setRequestHeader` to access the request headers of XHR instance. // This will only work for user/library defined headers, not for the default/browser-assigned headers. // Request cookies are also unavailable for XHR, as `Cookie` header can't be defined by `setRequestHeader`. - fill(this, 'setRequestHeader', function (original: WrappedFunction): Function { + fill(this, 'setRequestHeader', function (original: WrappedFunction) { return function (this: SentryWrappedXMLHttpRequest, ...setRequestHeaderArgs: unknown[]): void { const [header, value] = setRequestHeaderArgs; @@ -119,7 +112,7 @@ export function instrumentXHR(): void { }); fill(xhrproto, 'send', function (originalSend: () => void): () => void { - return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: any[]): void { + return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: unknown[]): void { const sentryXhrData = this[SENTRY_XHR_DATA_KEY]; if (!sentryXhrData) { diff --git a/packages/browser-utils/src/browser/metrics/index.ts b/packages/browser-utils/src/metrics/browserMetrics.ts similarity index 98% rename from packages/browser-utils/src/browser/metrics/index.ts rename to packages/browser-utils/src/metrics/browserMetrics.ts index 770ec8354596..7c6620ff7911 100644 --- a/packages/browser-utils/src/browser/metrics/index.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -5,18 +5,18 @@ import type { Measurements, Span, SpanAttributes, StartSpanOptions } from '@sent import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logger, parseUrl } from '@sentry/utils'; import { spanToJSON } from '@sentry/core'; -import { DEBUG_BUILD } from '../../common/debug-build'; +import { DEBUG_BUILD } from '../debug-build'; import { addClsInstrumentationHandler, addFidInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, addTtfbInstrumentationHandler, -} from '../instrument'; -import { WINDOW } from '../types'; -import { getNavigationEntry } from '../web-vitals/lib/getNavigationEntry'; -import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher'; +} from './instrument'; +import { WINDOW } from './types'; import { isMeasurementValue, startAndEndSpan } from './utils'; +import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry'; +import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; interface NavigatorNetworkInformation { readonly connection?: NetworkInformation; diff --git a/packages/browser-utils/src/browser/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts similarity index 99% rename from packages/browser-utils/src/browser/instrument.ts rename to packages/browser-utils/src/metrics/instrument.ts index e4af4805315a..535f584dddb1 100644 --- a/packages/browser-utils/src/browser/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -1,6 +1,6 @@ import { getFunctionName, logger } from '@sentry/utils'; -import { DEBUG_BUILD } from '../common/debug-build'; +import { DEBUG_BUILD } from '../debug-build'; import { onCLS } from './web-vitals/getCLS'; import { onFID } from './web-vitals/getFID'; import { onLCP } from './web-vitals/getLCP'; diff --git a/packages/browser-utils/src/browser/types.ts b/packages/browser-utils/src/metrics/types.ts similarity index 100% rename from packages/browser-utils/src/browser/types.ts rename to packages/browser-utils/src/metrics/types.ts diff --git a/packages/browser-utils/src/browser/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts similarity index 100% rename from packages/browser-utils/src/browser/metrics/utils.ts rename to packages/browser-utils/src/metrics/utils.ts diff --git a/packages/browser-utils/src/browser/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/README.md rename to packages/browser-utils/src/metrics/web-vitals/README.md diff --git a/packages/browser-utils/src/browser/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/getCLS.ts rename to packages/browser-utils/src/metrics/web-vitals/getCLS.ts diff --git a/packages/browser-utils/src/browser/web-vitals/getFID.ts b/packages/browser-utils/src/metrics/web-vitals/getFID.ts similarity index 96% rename from packages/browser-utils/src/browser/web-vitals/getFID.ts rename to packages/browser-utils/src/metrics/web-vitals/getFID.ts index f79b388c042d..92543b89e170 100644 --- a/packages/browser-utils/src/browser/web-vitals/getFID.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getFID.ts @@ -35,14 +35,14 @@ export const FIDThresholds: MetricRatingThresholds = [100, 300]; * _**Important:** since FID is only reported after the user interacts with the * page, it's possible that it will not be reported for some page loads._ */ -export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}): void => { +export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}) => { whenActivated(() => { const visibilityWatcher = getVisibilityWatcher(); const metric = initMetric('FID'); // eslint-disable-next-line prefer-const let report: ReturnType; - const handleEntry = (entry: PerformanceEventTiming) => { + const handleEntry = (entry: PerformanceEventTiming): void => { // Only report if the page wasn't hidden prior to the first input. if (entry.startTime < visibilityWatcher.firstHiddenTime) { metric.value = entry.processingStart - entry.startTime; diff --git a/packages/browser-utils/src/browser/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/getINP.ts rename to packages/browser-utils/src/metrics/web-vitals/getINP.ts diff --git a/packages/browser-utils/src/browser/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/getLCP.ts rename to packages/browser-utils/src/metrics/web-vitals/getLCP.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/bindReporter.ts b/packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/bindReporter.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/bindReporter.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/generateUniqueID.ts b/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/generateUniqueID.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/getActivationStart.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/getActivationStart.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/getNavigationEntry.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/getNavigationEntry.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/getVisibilityWatcher.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/getVisibilityWatcher.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/initMetric.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/initMetric.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/observe.ts b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/observe.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/observe.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/onHidden.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/polyfills/interactionCountPolyfill.ts b/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/polyfills/interactionCountPolyfill.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/runOnce.ts b/packages/browser-utils/src/metrics/web-vitals/lib/runOnce.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/runOnce.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/runOnce.ts diff --git a/packages/browser-utils/src/browser/web-vitals/lib/whenActivated.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenActivated.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/lib/whenActivated.ts rename to packages/browser-utils/src/metrics/web-vitals/lib/whenActivated.ts diff --git a/packages/browser-utils/src/browser/web-vitals/onFCP.ts b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/onFCP.ts rename to packages/browser-utils/src/metrics/web-vitals/onFCP.ts diff --git a/packages/browser-utils/src/browser/web-vitals/onTTFB.ts b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/onTTFB.ts rename to packages/browser-utils/src/metrics/web-vitals/onTTFB.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types.ts b/packages/browser-utils/src/metrics/web-vitals/types.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types.ts rename to packages/browser-utils/src/metrics/web-vitals/types.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/base.ts rename to packages/browser-utils/src/metrics/web-vitals/types/base.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/cls.ts b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/cls.ts rename to packages/browser-utils/src/metrics/web-vitals/types/cls.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/fcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/fcp.ts rename to packages/browser-utils/src/metrics/web-vitals/types/fcp.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/fid.ts b/packages/browser-utils/src/metrics/web-vitals/types/fid.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/fid.ts rename to packages/browser-utils/src/metrics/web-vitals/types/fid.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/inp.ts b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/inp.ts rename to packages/browser-utils/src/metrics/web-vitals/types/inp.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/lcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/lcp.ts rename to packages/browser-utils/src/metrics/web-vitals/types/lcp.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/polyfills.ts b/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/polyfills.ts rename to packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts diff --git a/packages/browser-utils/src/browser/web-vitals/types/ttfb.ts b/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts similarity index 100% rename from packages/browser-utils/src/browser/web-vitals/types/ttfb.ts rename to packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts diff --git a/packages/browser-utils/test/browser/backgroundtab.test.ts b/packages/browser-utils/test/browser/backgroundtab.test.ts deleted file mode 100644 index e25eaa168fd4..000000000000 --- a/packages/browser-utils/test/browser/backgroundtab.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { addTracingExtensions, getCurrentScope } from '@sentry/core'; -import { setCurrentClient, spanToJSON, startSpan } from '@sentry/core'; -import { JSDOM } from 'jsdom'; - -import { registerBackgroundTabDetection } from '../../src/browser/backgroundtab'; -import { TestClient, getDefaultClientOptions } from '../utils/TestClient'; - -describe('registerBackgroundTabDetection', () => { - let events: Record = {}; - beforeEach(() => { - const dom = new JSDOM(); - global.document = dom.window.document; - - const options = getDefaultClientOptions({ tracesSampleRate: 1 }); - const client = new TestClient(options); - setCurrentClient(client); - client.init(); - - addTracingExtensions(); - - global.document.addEventListener = jest.fn((event, callback) => { - events[event] = callback; - }); - }); - - afterEach(() => { - events = {}; - getCurrentScope().clear(); - }); - - it('does not create an event listener if global document is undefined', () => { - // @ts-expect-error need to override global document - global.document = undefined; - registerBackgroundTabDetection(); - expect(events).toMatchObject({}); - }); - - it('creates an event listener', () => { - registerBackgroundTabDetection(); - expect(events).toMatchObject({ visibilitychange: expect.any(Function) }); - }); - - it('finishes a transaction on visibility change', () => { - registerBackgroundTabDetection(); - startSpan({ name: 'test' }, span => { - // Simulate document visibility hidden event - // @ts-expect-error need to override global document - global.document.hidden = true; - events.visibilitychange(); - - const { status, timestamp, data } = spanToJSON(span); - - expect(status).toBe('cancelled'); - expect(status).toBeDefined(); - expect(data!['sentry.cancellation_reason']).toBe('document.hidden'); - expect(timestamp).toBeDefined(); - }); - }); -}); diff --git a/packages/browser-utils/test/browser/metrics/index.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts similarity index 95% rename from packages/browser-utils/test/browser/metrics/index.test.ts rename to packages/browser-utils/test/browser/browserMetrics.test.ts index b0015ca0a514..b6d6aaa087aa 100644 --- a/packages/browser-utils/test/browser/metrics/index.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -9,10 +9,10 @@ import { spanToJSON, } from '@sentry/core'; import type { Span } from '@sentry/types'; -import type { ResourceEntry } from '../../../src/browser/metrics'; -import { _addMeasureSpans, _addResourceSpans } from '../../../src/browser/metrics'; -import { WINDOW } from '../../../src/browser/types'; -import { TestClient, getDefaultClientOptions } from '../../utils/TestClient'; +import type { ResourceEntry } from '../../src/metrics/browserMetrics'; +import { _addMeasureSpans, _addResourceSpans } from '../../src/metrics/browserMetrics'; +import { WINDOW } from '../../src/metrics/types'; +import { TestClient, getDefaultClientOptions } from '../utils/TestClient'; const mockWindowLocation = { ancestorOrigins: {}, @@ -32,7 +32,7 @@ const originalLocation = WINDOW.location; const resourceEntryName = 'https://example.com/assets/to/css'; describe('_addMeasureSpans', () => { - const span = new SentrySpan({ op: 'pageload', name: '/' }); + const span = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); beforeEach(() => { getCurrentScope().clear(); @@ -86,7 +86,7 @@ describe('_addMeasureSpans', () => { }); describe('_addResourceSpans', () => { - const span = new SentrySpan({ op: 'pageload', name: '/' }); + const span = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); beforeAll(() => { setGlobalLocation(mockWindowLocation); diff --git a/packages/browser-utils/test/browser/metrics/utils.test.ts b/packages/browser-utils/test/browser/utils.test.ts similarity index 80% rename from packages/browser-utils/test/browser/metrics/utils.test.ts rename to packages/browser-utils/test/browser/utils.test.ts index ad9d0dc801f8..bb7a757e4b6a 100644 --- a/packages/browser-utils/test/browser/metrics/utils.test.ts +++ b/packages/browser-utils/test/browser/utils.test.ts @@ -1,18 +1,9 @@ -import { - SentrySpan, - addTracingExtensions, - getCurrentScope, - getIsolationScope, - setCurrentClient, - spanToJSON, -} from '@sentry/core'; -import { startAndEndSpan } from '../../../src/browser/metrics/utils'; -import { TestClient, getDefaultClientOptions } from '../../utils/TestClient'; +import { SentrySpan, getCurrentScope, getIsolationScope, setCurrentClient, spanToJSON } from '@sentry/core'; +import { startAndEndSpan } from '../../src/metrics/utils'; +import { TestClient, getDefaultClientOptions } from '../utils/TestClient'; describe('startAndEndSpan()', () => { beforeEach(() => { - addTracingExtensions(); - getCurrentScope().clear(); getIsolationScope().clear(); @@ -26,7 +17,7 @@ describe('startAndEndSpan()', () => { }); it('creates a span with given properties', () => { - const parentSpan = new SentrySpan({ name: 'test' }); + const parentSpan = new SentrySpan({ name: 'test', sampled: true }); const span = startAndEndSpan(parentSpan, 100, 200, { name: 'evaluation', op: 'script', @@ -40,7 +31,7 @@ describe('startAndEndSpan()', () => { }); it('adjusts the start timestamp if child span starts before transaction', () => { - const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123 }); + const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123, sampled: true }); const span = startAndEndSpan(parentSpan, 100, 200, { name: 'script.js', op: 'resource', @@ -52,7 +43,7 @@ describe('startAndEndSpan()', () => { }); it('does not adjust start timestamp if child span starts after transaction', () => { - const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123 }); + const parentSpan = new SentrySpan({ name: 'test', startTimestamp: 123, sampled: true }); const span = startAndEndSpan(parentSpan, 150, 200, { name: 'script.js', op: 'resource', diff --git a/packages/utils/test/instrument/dom.test.ts b/packages/browser-utils/test/instrument/dom.test.ts similarity index 75% rename from packages/utils/test/instrument/dom.test.ts rename to packages/browser-utils/test/instrument/dom.test.ts index 28745109e0f8..ad1dc2fd82fa 100644 --- a/packages/utils/test/instrument/dom.test.ts +++ b/packages/browser-utils/test/instrument/dom.test.ts @@ -1,7 +1,7 @@ import { instrumentDOM } from '../../src/instrument/dom'; -jest.mock('../../src/worldwide', () => { - const original = jest.requireActual('../../src/worldwide'); +jest.mock('@sentry/utils', () => { + const original = jest.requireActual('@sentry/utils'); return { ...original, diff --git a/packages/utils/test/instrument/xhr.test.ts b/packages/browser-utils/test/instrument/xhr.test.ts similarity index 75% rename from packages/utils/test/instrument/xhr.test.ts rename to packages/browser-utils/test/instrument/xhr.test.ts index 60485ff1d398..46fa1a477521 100644 --- a/packages/utils/test/instrument/xhr.test.ts +++ b/packages/browser-utils/test/instrument/xhr.test.ts @@ -1,8 +1,7 @@ import { instrumentXHR } from '../../src/instrument/xhr'; -jest.mock('../../src/worldwide', () => { - const original = jest.requireActual('../../src/worldwide'); - +jest.mock('@sentry/utils', () => { + const original = jest.requireActual('@sentry/utils'); return { ...original, GLOBAL_OBJ: { diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 869d28c16c50..78ab902e01a1 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -34,7 +34,10 @@ export type BrowserOptions = Options & */ export type BrowserClientOptions = ClientOptions & BrowserClientReplayOptions & - BrowserClientProfilingOptions; + BrowserClientProfilingOptions & { + /** If configured, this URL will be used as base URL for lazy loading integration. */ + cdnBaseUrl?: string; + }; /** * The Sentry Browser SDK Client. @@ -49,12 +52,17 @@ export class BrowserClient extends BaseClient { * @param options Configuration options for this SDK. */ public constructor(options: BrowserClientOptions) { + const opts = { + // We default this to true, as it is the safer scenario + parentSpanIsAlwaysRootSpan: true, + ...options, + }; const sdkSource = WINDOW.SENTRY_SDK_SOURCE || getSDKSource(); - applySdkMetadata(options, 'browser', ['browser'], sdkSource); + applySdkMetadata(opts, 'browser', ['browser'], sdkSource); - super(options); + super(opts); - if (options.sendClientReports && WINDOW.document) { + if (opts.sendClientReports && WINDOW.document) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { this._flushOutcomes(); diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index b39baee2a3fe..fcb7808178f2 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -48,10 +48,7 @@ export function exceptionFromError(stackParser: StackParser, ex: Error): Excepti return exception; } -/** - * @hidden - */ -export function eventFromPlainObject( +function eventFromPlainObject( stackParser: StackParser, exception: Record, syntheticException?: Error, @@ -60,35 +57,46 @@ export function eventFromPlainObject( const client = getClient(); const normalizeDepth = client && client.getOptions().normalizeDepth; - const event: Event = { + // If we can, we extract an exception from the object properties + const errorFromProp = getErrorPropertyFromObject(exception); + + const extra = { + __serialized__: normalizeToSize(exception, normalizeDepth), + }; + + if (errorFromProp) { + return { + exception: { + values: [exceptionFromError(stackParser, errorFromProp)], + }, + extra, + }; + } + + const event = { exception: { values: [ { type: isEvent(exception) ? exception.constructor.name : isUnhandledRejection ? 'UnhandledRejection' : 'Error', value: getNonErrorObjectExceptionValue(exception, { isUnhandledRejection }), - }, + } as Exception, ], }, - extra: { - __serialized__: normalizeToSize(exception, normalizeDepth), - }, - }; + extra, + } satisfies Event; if (syntheticException) { const frames = parseStackFrames(stackParser, syntheticException); if (frames.length) { // event.exception.values[0] has been set above - (event.exception as { values: Exception[] }).values[0].stacktrace = { frames }; + event.exception.values[0].stacktrace = { frames }; } } return event; } -/** - * @hidden - */ -export function eventFromError(stackParser: StackParser, ex: Error): Event { +function eventFromError(stackParser: StackParser, ex: Error): Event { return { exception: { values: [exceptionFromError(stackParser, ex)], @@ -97,7 +105,7 @@ export function eventFromError(stackParser: StackParser, ex: Error): Event { } /** Parses stack frames from an error */ -export function parseStackFrames( +function parseStackFrames( stackParser: StackParser, ex: Error & { framesToPop?: number; stacktrace?: string }, ): StackFrame[] { @@ -283,10 +291,7 @@ export function eventFromUnknownInput( return event; } -/** - * @hidden - */ -export function eventFromString( +function eventFromString( stackParser: StackParser, message: ParameterizedString, syntheticException?: Error, @@ -346,3 +351,17 @@ function getObjectClassName(obj: unknown): string | undefined | void { // ignore errors here } } + +/** If a plain object has a property that is an `Error`, return this error. */ +function getErrorPropertyFromObject(obj: Record): Error | undefined { + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + const value = obj[prop]; + if (value instanceof Error) { + return value; + } + } + } + + return undefined; +} diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index a83b1e549eba..e3087a0e58b4 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -35,7 +35,6 @@ export { getCurrentScope, getIsolationScope, getGlobalScope, - Hub, setCurrentClient, Scope, continueTrace, @@ -56,6 +55,7 @@ export { captureSession, endSession, spanToJSON, + spanToTraceHeader, } from '@sentry/core'; export { @@ -95,3 +95,5 @@ export { globalHandlersIntegration } from './integrations/globalhandlers'; export { httpContextIntegration } from './integrations/httpcontext'; export { linkedErrorsIntegration } from './integrations/linkederrors'; export { browserApiErrorsIntegration } from './integrations/browserapierrors'; + +export { lazyLoadIntegration } from './utils/lazyLoadIntegration'; diff --git a/packages/browser/src/index.bundle.feedback.ts b/packages/browser/src/index.bundle.feedback.ts index de24e36e2a60..850da7bf1e26 100644 --- a/packages/browser/src/index.bundle.feedback.ts +++ b/packages/browser/src/index.bundle.feedback.ts @@ -1,17 +1,12 @@ -// This is exported so the loader does not fail when switching off Replay/Tracing -import { feedbackIntegration, getFeedback } from '@sentry-internal/feedback'; -import { - addTracingExtensionsShim, - browserTracingIntegrationShim, - replayIntegrationShim, -} from '@sentry-internal/integration-shims'; +import { browserTracingIntegrationShim, replayIntegrationShim } from '@sentry-internal/integration-shims'; export * from './index.bundle.base'; + export { - browserTracingIntegrationShim as browserTracingIntegration, - addTracingExtensionsShim as addTracingExtensions, - replayIntegrationShim as replayIntegration, feedbackIntegration, + feedbackModalIntegration, + feedbackScreenshotIntegration, getFeedback, -}; -// Note: We do not export a shim for `Span` here, as that is quite complex and would blow up the bundle +} from '@sentry-internal/feedback'; + +export { browserTracingIntegrationShim as browserTracingIntegration, replayIntegrationShim as replayIntegration }; diff --git a/packages/browser/src/index.bundle.replay.ts b/packages/browser/src/index.bundle.replay.ts index ec0a50c92905..3a26a2db77ac 100644 --- a/packages/browser/src/index.bundle.replay.ts +++ b/packages/browser/src/index.bundle.replay.ts @@ -1,16 +1,17 @@ -// This is exported so the loader does not fail when switching off Replay/Tracing import { - addTracingExtensionsShim, browserTracingIntegrationShim, feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, } from '@sentry-internal/integration-shims'; -import { replayIntegration } from '@sentry-internal/replay'; export * from './index.bundle.base'; + +export { replayIntegration } from '@sentry-internal/replay'; + export { browserTracingIntegrationShim as browserTracingIntegration, - addTracingExtensionsShim as addTracingExtensions, - replayIntegration, feedbackIntegrationShim as feedbackIntegration, + feedbackModalIntegrationShim as feedbackModalIntegration, + feedbackScreenshotIntegrationShim as feedbackScreenshotIntegration, }; -// Note: We do not export a shim for `Span` here, as that is quite complex and would blow up the bundle diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index 6133fc2870f5..4c9c1e716534 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -1,14 +1,8 @@ -import { - browserTracingIntegration, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, -} from '@sentry-internal/browser-utils'; -import { feedbackIntegration, getFeedback } from '@sentry-internal/feedback'; -import { replayIntegration } from '@sentry-internal/replay'; -import { addTracingExtensions } from '@sentry/core'; +import { registerSpanErrorInstrumentation } from '@sentry/core'; -// We are patching the global object with our hub extension methods -addTracingExtensions(); +registerSpanErrorInstrumentation(); + +export * from './index.bundle.base'; export { getActiveSpan, @@ -23,12 +17,15 @@ export { export { feedbackIntegration, - replayIntegration, + feedbackModalIntegration, + feedbackScreenshotIntegration, + getFeedback, +} from '@sentry-internal/feedback'; + +export { browserTracingIntegration, - addTracingExtensions, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, - getFeedback, -}; +} from './tracing/browserTracingIntegration'; -export * from './index.bundle.base'; +export { replayIntegration } from '@sentry-internal/replay'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index f949ea43541a..1b986f9e794a 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -1,14 +1,13 @@ import { - browserTracingIntegration, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, -} from '@sentry-internal/browser-utils'; -import { feedbackIntegrationShim } from '@sentry-internal/integration-shims'; -import { replayIntegration } from '@sentry-internal/replay'; -import { addTracingExtensions } from '@sentry/core'; + feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, +} from '@sentry-internal/integration-shims'; +import { registerSpanErrorInstrumentation } from '@sentry/core'; -// We are patching the global object with our hub extension methods -addTracingExtensions(); +registerSpanErrorInstrumentation(); + +export * from './index.bundle.base'; export { getActiveSpan, @@ -22,12 +21,15 @@ export { } from '@sentry/core'; export { - replayIntegration, - feedbackIntegrationShim as feedbackIntegration, browserTracingIntegration, - addTracingExtensions, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, +} from './tracing/browserTracingIntegration'; + +export { + feedbackIntegrationShim as feedbackIntegration, + feedbackModalIntegrationShim as feedbackModalIntegration, + feedbackScreenshotIntegrationShim as feedbackScreenshotIntegration, }; -export * from './index.bundle.base'; +export { replayIntegration } from '@sentry-internal/replay'; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index 1b4f89f935df..f956bc4db1f6 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -1,14 +1,14 @@ import { - browserTracingIntegration, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, -} from '@sentry-internal/browser-utils'; -// This is exported so the loader does not fail when switching off Replay -import { feedbackIntegrationShim, replayIntegrationShim } from '@sentry-internal/integration-shims'; -import { addTracingExtensions } from '@sentry/core'; + feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, + replayIntegrationShim, +} from '@sentry-internal/integration-shims'; +import { registerSpanErrorInstrumentation } from '@sentry/core'; + +registerSpanErrorInstrumentation(); -// We are patching the global object with our hub extension methods -addTracingExtensions(); +export * from './index.bundle.base'; export { getActiveSpan, @@ -22,12 +22,14 @@ export { } from '@sentry/core'; export { - feedbackIntegrationShim as feedbackIntegration, - replayIntegrationShim as replayIntegration, browserTracingIntegration, - addTracingExtensions, - startBrowserTracingPageLoadSpan, startBrowserTracingNavigationSpan, -}; + startBrowserTracingPageLoadSpan, +} from './tracing/browserTracingIntegration'; -export * from './index.bundle.base'; +export { + feedbackIntegrationShim as feedbackIntegration, + feedbackModalIntegrationShim as feedbackModalIntegration, + feedbackScreenshotIntegrationShim as feedbackScreenshotIntegration, + replayIntegrationShim as replayIntegration, +}; diff --git a/packages/browser/src/index.bundle.ts b/packages/browser/src/index.bundle.ts index b27672414b20..9433e3605c3b 100644 --- a/packages/browser/src/index.bundle.ts +++ b/packages/browser/src/index.bundle.ts @@ -1,16 +1,17 @@ -// This is exported so the loader does not fail when switching off Replay/Tracing import { - addTracingExtensionsShim, browserTracingIntegrationShim, feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, replayIntegrationShim, } from '@sentry-internal/integration-shims'; export * from './index.bundle.base'; + export { - addTracingExtensionsShim as addTracingExtensions, browserTracingIntegrationShim as browserTracingIntegration, feedbackIntegrationShim as feedbackIntegration, + feedbackModalIntegrationShim as feedbackModalIntegration, + feedbackScreenshotIntegrationShim as feedbackScreenshotIntegration, replayIntegrationShim as replayIntegration, }; -// Note: We do not export a shim for `Span` here, as that is quite complex and would blow up the bundle diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 54dd27f8b8b5..455214f60816 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -32,6 +32,8 @@ export { replayCanvasIntegration } from '@sentry-internal/replay-canvas'; export { feedbackIntegration, + feedbackModalIntegration, + feedbackScreenshotIntegration, getFeedback, sendFeedback, } from '@sentry-internal/feedback'; @@ -39,13 +41,17 @@ export { export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests, +} from './tracing/request'; +export { browserTracingIntegration, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, -} from '@sentry-internal/browser-utils'; -export type { RequestInstrumentationOptions } from '@sentry-internal/browser-utils'; +} from './tracing/browserTracingIntegration'; +export type { RequestInstrumentationOptions } from './tracing/request'; export { + // eslint-disable-next-line deprecation/deprecation addTracingExtensions, + registerSpanErrorInstrumentation, getActiveSpan, getRootSpan, startSpan, diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 474432fedb48..e3c1120fca57 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -1,28 +1,28 @@ +import { + SENTRY_XHR_DATA_KEY, + addClickKeypressInstrumentationHandler, + addHistoryInstrumentationHandler, + addXhrInstrumentationHandler, +} from '@sentry-internal/browser-utils'; import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; import type { + Breadcrumb, Client, Event as SentryEvent, + FetchBreadcrumbData, + FetchBreadcrumbHint, HandlerDataConsole, HandlerDataDom, HandlerDataFetch, HandlerDataHistory, HandlerDataXhr, IntegrationFn, -} from '@sentry/types'; -import type { - Breadcrumb, - FetchBreadcrumbData, - FetchBreadcrumbHint, XhrBreadcrumbData, XhrBreadcrumbHint, -} from '@sentry/types/build/types/breadcrumb'; +} from '@sentry/types'; import { - SENTRY_XHR_DATA_KEY, - addClickKeypressInstrumentationHandler, addConsoleInstrumentationHandler, addFetchInstrumentationHandler, - addHistoryInstrumentationHandler, - addXhrInstrumentationHandler, getComponentName, getEventDescription, htmlTreeAsString, diff --git a/packages/browser/src/integrations/httpclient.ts b/packages/browser/src/integrations/httpclient.ts index e8d5d596d975..b48cc69d09c0 100644 --- a/packages/browser/src/integrations/httpclient.ts +++ b/packages/browser/src/integrations/httpclient.ts @@ -1,11 +1,10 @@ +import { SENTRY_XHR_DATA_KEY, addXhrInstrumentationHandler } from '@sentry-internal/browser-utils'; import { captureEvent, defineIntegration, getClient, isSentryRequestUrl } from '@sentry/core'; import type { Client, Event as SentryEvent, IntegrationFn, SentryWrappedXMLHttpRequest } from '@sentry/types'; import { GLOBAL_OBJ, - SENTRY_XHR_DATA_KEY, addExceptionMechanism, addFetchInstrumentationHandler, - addXhrInstrumentationHandler, logger, supportsNativeFetch, } from '@sentry/utils'; diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index af88bd8d91e6..585ad28802b3 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,6 +1,5 @@ import { defineIntegration, getActiveSpan, getRootSpan } from '@sentry/core'; -import type { EventEnvelope, IntegrationFn, Span } from '@sentry/types'; -import type { Profile } from '@sentry/types/src/profiling'; +import type { EventEnvelope, IntegrationFn, Profile, Span } from '@sentry/types'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index a9dd735a3812..4682633d101c 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,8 +1,17 @@ /* eslint-disable max-lines */ import { DEFAULT_ENVIRONMENT, getClient, spanToJSON } from '@sentry/core'; -import type { DebugImage, Envelope, Event, EventEnvelope, Span, StackFrame, StackParser } from '@sentry/types'; -import type { Profile, ThreadCpuProfile } from '@sentry/types/src/profiling'; +import type { + DebugImage, + Envelope, + Event, + EventEnvelope, + Profile, + Span, + StackFrame, + StackParser, + ThreadCpuProfile, +} from '@sentry/types'; import { GLOBAL_OBJ, browserPerformanceTimeOrigin, forEachEnvelopeItem, logger, uuid4 } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 88411c082106..d64981cb0c7a 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -9,14 +9,9 @@ import { startSession, } from '@sentry/core'; import type { DsnLike, Integration, Options, UserFeedback } from '@sentry/types'; -import { - addHistoryInstrumentationHandler, - consoleSandbox, - logger, - stackParserFromStackParserOptions, - supportsFetch, -} from '@sentry/utils'; +import { consoleSandbox, logger, stackParserFromStackParserOptions, supportsFetch } from '@sentry/utils'; +import { addHistoryInstrumentationHandler } from '@sentry-internal/browser-utils'; import { dedupeIntegration } from '@sentry/core'; import type { BrowserClientOptions, BrowserOptions } from './client'; import { BrowserClient } from './client'; @@ -140,7 +135,7 @@ export function init(browserOptions: BrowserOptions = {}): void { consoleSandbox(() => { // eslint-disable-next-line no-console console.error( - '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/troubleshooting/#setting-up-sentry-in-shared-environments-eg-browser-extensions', + '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/', ); }); return; diff --git a/packages/browser-utils/src/browser/backgroundtab.ts b/packages/browser/src/tracing/backgroundtab.ts similarity index 94% rename from packages/browser-utils/src/browser/backgroundtab.ts rename to packages/browser/src/tracing/backgroundtab.ts index 928503ba6869..307892862649 100644 --- a/packages/browser-utils/src/browser/backgroundtab.ts +++ b/packages/browser/src/tracing/backgroundtab.ts @@ -2,8 +2,8 @@ import { SPAN_STATUS_ERROR, getActiveSpan, getRootSpan } from '@sentry/core'; import { spanToJSON } from '@sentry/core'; import { logger } from '@sentry/utils'; -import { DEBUG_BUILD } from '../common/debug-build'; -import { WINDOW } from './types'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; /** * Add a listener that cancels and finishes a transaction when the global diff --git a/packages/browser-utils/src/browser/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts similarity index 91% rename from packages/browser-utils/src/browser/browserTracingIntegration.ts rename to packages/browser/src/tracing/browserTracingIntegration.ts index 5954ca9d4502..16e715b15ad2 100644 --- a/packages/browser-utils/src/browser/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -1,39 +1,34 @@ +import { + addHistoryInstrumentationHandler, + addPerformanceEntries, + startTrackingInteractions, + startTrackingLongTasks, + startTrackingWebVitals, +} from '@sentry-internal/browser-utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, TRACING_DEFAULTS, - addTracingExtensions, continueTrace, getActiveSpan, getClient, getCurrentScope, getIsolationScope, getRootSpan, + registerSpanErrorInstrumentation, spanToJSON, startIdleSpan, withScope, } from '@sentry/core'; import type { Client, IntegrationFn, StartSpanOptions, TransactionSource } from '@sentry/types'; import type { Span } from '@sentry/types'; -import { - addHistoryInstrumentationHandler, - browserPerformanceTimeOrigin, - getDomElement, - logger, - uuid4, -} from '@sentry/utils'; +import { browserPerformanceTimeOrigin, getDomElement, logger, uuid4 } from '@sentry/utils'; -import { DEBUG_BUILD } from '../common/debug-build'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; import { registerBackgroundTabDetection } from './backgroundtab'; -import { - addPerformanceEntries, - startTrackingInteractions, - startTrackingLongTasks, - startTrackingWebVitals, -} from './metrics'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; -import { WINDOW } from './types'; export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -158,7 +153,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * We explicitly export the proper type here, as this has to be extended in some cases. */ export const browserTracingIntegration = ((_options: Partial = {}) => { - addTracingExtensions(); + registerSpanErrorInstrumentation(); const options = { ...DEFAULT_BROWSER_TRACING_OPTIONS, @@ -174,8 +169,10 @@ export const browserTracingIntegration = ((_options: Partial {}, afterAllSetup(client) { const { markBackgroundSpan, traceFetch, traceXHR, shouldCreateSpanForRequest, enableHTTPTimings, _experiments } = options; @@ -252,7 +247,7 @@ export const browserTracingIntegration = ((_options: Partial { + client.on('startPageLoadSpan', (startSpanOptions, traceOptions = {}) => { if (getClient() !== client) { return; } @@ -263,17 +258,18 @@ export const browserTracingIntegration = ((_options: Partial { - // We update the outer current scope to have the correct propagation context... + // We update the outer current scope to have the correct propagation context + // this means, the scope active when the pageload span is created will continue to hold the + // propagationContext from the incoming trace, even after the pageload span ended. scope.setPropagationContext(getCurrentScope().getPropagationContext()); // Ensure we are on the original current scope again, so the span is set as active on it @@ -284,9 +280,6 @@ export const browserTracingIntegration = ((_options: Partial { @@ -434,17 +426,17 @@ function registerInteractionListener( inflightInteractionSpan = undefined; } - if (!latestRouteName) { + if (!latestRoute.name) { DEBUG_BUILD && logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`); return undefined; } inflightInteractionSpan = startIdleSpan( { - name: latestRouteName, + name: latestRoute.name, op, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRouteSource || 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source || 'url', }, }, { diff --git a/packages/browser-utils/src/browser/request.ts b/packages/browser/src/tracing/request.ts similarity index 87% rename from packages/browser-utils/src/browser/request.ts rename to packages/browser/src/tracing/request.ts index b808136b0325..2c013fe27232 100644 --- a/packages/browser-utils/src/browser/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -1,6 +1,12 @@ +import { + SENTRY_XHR_DATA_KEY, + addPerformanceInstrumentationHandler, + addXhrInstrumentationHandler, +} from '@sentry-internal/browser-utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SentryNonRecordingSpan, + getActiveSpan, getClient, getCurrentScope, getDynamicSamplingContextFromClient, @@ -13,20 +19,16 @@ import { spanToTraceHeader, startInactiveSpan, } from '@sentry/core'; -import type { HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/types'; +import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/types'; import { BAGGAGE_HEADER_NAME, - SENTRY_XHR_DATA_KEY, addFetchInstrumentationHandler, - addXhrInstrumentationHandler, browserPerformanceTimeOrigin, dynamicSamplingContextToSentryBaggageHeader, generateSentryTraceHeader, stringMatchesSomePattern, } from '@sentry/utils'; - -import { addPerformanceInstrumentationHandler } from './instrument'; -import { WINDOW } from './types'; +import { WINDOW } from '../helpers'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { @@ -284,11 +286,11 @@ export function xhrCallback( const xhr = handlerData.xhr; const sentryXhrData = xhr && xhr[SENTRY_XHR_DATA_KEY]; - if (!hasTracingEnabled() || !xhr || xhr.__sentry_own_request__ || !sentryXhrData) { + if (!xhr || xhr.__sentry_own_request__ || !sentryXhrData) { return undefined; } - const shouldCreateSpanResult = shouldCreateSpan(sentryXhrData.url); + const shouldCreateSpanResult = hasTracingEnabled() && shouldCreateSpan(sentryXhrData.url); // check first if the request has finished and is tracked by an existing span which should now end if (handlerData.endTimestamp && shouldCreateSpanResult) { @@ -306,22 +308,21 @@ export function xhrCallback( return undefined; } - const scope = getCurrentScope(); - const isolationScope = getIsolationScope(); - - const span = shouldCreateSpanResult - ? startInactiveSpan({ - name: `${sentryXhrData.method} ${sentryXhrData.url}`, - onlyIfParent: true, - attributes: { - type: 'xhr', - 'http.method': sentryXhrData.method, - url: sentryXhrData.url, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', - }, - op: 'http.client', - }) - : new SentryNonRecordingSpan(); + const hasParent = !!getActiveSpan(); + + const span = + shouldCreateSpanResult && hasParent + ? startInactiveSpan({ + name: `${sentryXhrData.method} ${sentryXhrData.url}`, + attributes: { + type: 'xhr', + 'http.method': sentryXhrData.method, + url: sentryXhrData.url, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', + }, + op: 'http.client', + }) + : new SentryNonRecordingSpan(); xhr.__sentry_xhr_span_id__ = span.spanContext().spanId; spans[xhr.__sentry_xhr_span_id__] = span; @@ -329,23 +330,38 @@ export function xhrCallback( const client = getClient(); if (xhr.setRequestHeader && shouldAttachHeaders(sentryXhrData.url) && client) { - const { traceId, spanId, sampled, dsc } = { - ...isolationScope.getPropagationContext(), - ...scope.getPropagationContext(), - }; - - const sentryTraceHeader = span ? spanToTraceHeader(span) : generateSentryTraceHeader(traceId, spanId, sampled); - - const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader( - dsc || (span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromClient(traceId, client)), + addTracingHeadersToXhrRequest( + xhr, + client, + // In the following cases, we do not want to use the span as base for the trace headers, + // which means that the headers will be generated from the scope: + // - If tracing is disabled (TWP) + // - If the span has no parent span - which means we ran into `onlyIfParent` check + hasTracingEnabled() && hasParent ? span : undefined, ); - - setHeaderOnXhr(xhr, sentryTraceHeader, sentryBaggageHeader); } return span; } +function addTracingHeadersToXhrRequest(xhr: SentryWrappedXMLHttpRequest, client: Client, span?: Span): void { + const scope = getCurrentScope(); + const isolationScope = getIsolationScope(); + const { traceId, spanId, sampled, dsc } = { + ...isolationScope.getPropagationContext(), + ...scope.getPropagationContext(), + }; + + const sentryTraceHeader = + span && hasTracingEnabled() ? spanToTraceHeader(span) : generateSentryTraceHeader(traceId, spanId, sampled); + + const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader( + dsc || (span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromClient(traceId, client)), + ); + + setHeaderOnXhr(xhr, sentryTraceHeader, sentryBaggageHeader); +} + function setHeaderOnXhr( xhr: SentryWrappedXMLHttpRequest, sentryTraceHeader: string, diff --git a/packages/browser/src/transports/offline.ts b/packages/browser/src/transports/offline.ts index dd7d9c70d929..4a435b2972fa 100644 --- a/packages/browser/src/transports/offline.ts +++ b/packages/browser/src/transports/offline.ts @@ -48,8 +48,8 @@ function keys(store: IDBObjectStore): Promise { return promisifyRequest(store.getAllKeys() as IDBRequest); } -/** Insert into the store */ -export function insert(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise { +/** Insert into the end of the store */ +export function push(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise { return store(store => { return keys(store).then(keys => { if (keys.length >= maxQueueSize) { @@ -63,8 +63,23 @@ export function insert(store: Store, value: Uint8Array | string, maxQueueSize: n }); } +/** Insert into the front of the store */ +export function unshift(store: Store, value: Uint8Array | string, maxQueueSize: number): Promise { + return store(store => { + return keys(store).then(keys => { + if (keys.length >= maxQueueSize) { + return; + } + + // We insert with an decremented key so that the entries are popped in order + store.put(value, Math.min(...keys, 0) - 1); + return promisifyRequest(store.transaction); + }); + }); +} + /** Pop the oldest value from the store */ -export function pop(store: Store): Promise { +export function shift(store: Store): Promise { return store(store => { return keys(store).then(keys => { if (keys.length === 0) { @@ -79,7 +94,7 @@ export function pop(store: Store): Promise { }); } -export interface BrowserOfflineTransportOptions extends OfflineTransportOptions { +export interface BrowserOfflineTransportOptions extends Omit { /** * Name of indexedDb database to store envelopes in * Default: 'sentry-offline' @@ -110,17 +125,25 @@ function createIndexedDbStore(options: BrowserOfflineTransportOptions): OfflineS } return { - insert: async (env: Envelope) => { + push: async (env: Envelope) => { + try { + const serialized = await serializeEnvelope(env); + await push(getStore(), serialized, options.maxQueueSize || 30); + } catch (_) { + // + } + }, + unshift: async (env: Envelope) => { try { const serialized = await serializeEnvelope(env); - await insert(getStore(), serialized, options.maxQueueSize || 30); + await unshift(getStore(), serialized, options.maxQueueSize || 30); } catch (_) { // } }, - pop: async () => { + shift: async () => { try { - const deserialized = await pop(getStore()); + const deserialized = await shift(getStore()); if (deserialized) { return parseEnvelope(deserialized); } diff --git a/packages/browser/src/utils/lazyLoadIntegration.ts b/packages/browser/src/utils/lazyLoadIntegration.ts new file mode 100644 index 000000000000..aaa5987a183c --- /dev/null +++ b/packages/browser/src/utils/lazyLoadIntegration.ts @@ -0,0 +1,79 @@ +import { SDK_VERSION, getClient } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; +import type { BrowserClient } from '../client'; +import { WINDOW } from '../helpers'; + +// This is a map of integration function method to bundle file name. +const LazyLoadableIntegrations = { + replayIntegration: 'replay', + replayCanvasIntegration: 'replay-canvas', + feedbackIntegration: 'feedback', + feedbackModalIntegration: 'feedback-modal', + feedbackScreenshotIntegration: 'feedback-screenshot', + captureConsoleIntegration: 'captureconsole', + contextLinesIntegration: 'contextlines', + linkedErrorsIntegration: 'linkederrors', + debugIntegration: 'debug', + dedupeIntegration: 'dedupe', + extraErrorDataIntegration: 'extraerrordata', + httpClientIntegration: 'httpclient', + reportingObserverIntegration: 'reportingobserver', + rewriteFramesIntegration: 'rewriteframes', + sessionTimingIntegration: 'sessiontiming', +} as const; + +const WindowWithMaybeIntegration = WINDOW as { + Sentry?: Partial>; +}; + +/** + * Lazy load an integration from the CDN. + * Rejects if the integration cannot be loaded. + */ +export async function lazyLoadIntegration(name: keyof typeof LazyLoadableIntegrations): Promise { + const bundle = LazyLoadableIntegrations[name]; + + if (!bundle || !WindowWithMaybeIntegration.Sentry) { + throw new Error(`Cannot lazy load integration: ${name}`); + } + + // Bail if the integration already exists + const existing = WindowWithMaybeIntegration.Sentry[name]; + if (typeof existing === 'function') { + return existing; + } + + const url = getScriptURL(bundle); + const script = WINDOW.document.createElement('script'); + script.src = url; + script.crossOrigin = 'anonymous'; + + const waitForLoad = new Promise((resolve, reject) => { + script.addEventListener('load', () => resolve()); + script.addEventListener('error', reject); + }); + + WINDOW.document.body.appendChild(script); + + try { + await waitForLoad; + } catch { + throw new Error(`Error when loading integration: ${name}`); + } + + const integrationFn = WindowWithMaybeIntegration.Sentry[name]; + + if (typeof integrationFn !== 'function') { + throw new Error(`Could not load integration: ${name}`); + } + + return integrationFn; +} + +function getScriptURL(bundle: string): string { + const client = getClient(); + const options = client && client.getOptions(); + const baseURL = (options && options.cdnBaseUrl) || 'https://browser.sentry-cdn.com'; + + return new URL(`/${SDK_VERSION}/${bundle}.min.js`, baseURL).toString(); +} diff --git a/packages/browser/test/integration/suites/config.js b/packages/browser/test/integration/suites/config.js deleted file mode 100644 index 2637ea8c13b7..000000000000 --- a/packages/browser/test/integration/suites/config.js +++ /dev/null @@ -1,55 +0,0 @@ -describe('config', function () { - it('should allow to ignore specific errors', function () { - return runInSandbox(sandbox, function () { - Sentry.captureException(new Error('foo')); - Sentry.captureException(new Error('ignoreErrorTest')); - Sentry.captureException(new Error('bar')); - }).then(function (summary) { - assert.equal(summary.events[0].exception.values[0].type, 'Error'); - assert.equal(summary.events[0].exception.values[0].value, 'foo'); - assert.equal(summary.events[1].exception.values[0].type, 'Error'); - assert.equal(summary.events[1].exception.values[0].value, 'bar'); - }); - }); - - it('should allow to ignore specific urls', function () { - return runInSandbox(sandbox, function () { - /** - * We always filter on the caller, not the cause of the error - * - * > foo.js file called a function in bar.js - * > bar.js file called a function in baz.js - * > baz.js threw an error - * - * foo.js is denied in the `init` call (init.js), thus we filter it - * */ - var urlWithDeniedUrl = new Error('filter'); - urlWithDeniedUrl.stack = - 'Error: bar\n' + - ' at http://localhost:5000/foo.js:7:19\n' + - ' at bar(http://localhost:5000/bar.js:2:3)\n' + - ' at baz(http://localhost:5000/baz.js:2:9)\n'; - - /** - * > foo-pass.js file called a function in bar-pass.js - * > bar-pass.js file called a function in baz-pass.js - * > baz-pass.js threw an error - * - * foo-pass.js is *not* denied in the `init` call (init.js), thus we don't filter it - * */ - var urlWithoutDeniedUrl = new Error('pass'); - urlWithoutDeniedUrl.stack = - 'Error: bar\n' + - ' at http://localhost:5000/foo-pass.js:7:19\n' + - ' at bar(http://localhost:5000/bar-pass.js:2:3)\n' + - ' at baz(http://localhost:5000/baz-pass.js:2:9)\n'; - - Sentry.captureException(urlWithDeniedUrl); - Sentry.captureException(urlWithoutDeniedUrl); - }).then(function (summary) { - assert.lengthOf(summary.events, 1); - assert.equal(summary.events[0].exception.values[0].type, 'Error'); - assert.equal(summary.events[0].exception.values[0].value, 'pass'); - }); - }); -}); diff --git a/packages/browser/test/integration/suites/shell.js b/packages/browser/test/integration/suites/shell.js index e1555623b495..7df81ae2b53e 100644 --- a/packages/browser/test/integration/suites/shell.js +++ b/packages/browser/test/integration/suites/shell.js @@ -22,7 +22,6 @@ function runVariant(variant) { /** * The test runner will replace each of these placeholders with the contents of the corresponding file. */ - {{ suites/config.js }} // biome-ignore format: No trailing commas {{ suites/onerror.js }} // biome-ignore format: No trailing commas {{ suites/onunhandledrejection.js }} // biome-ignore format: No trailing commas {{ suites/builtins.js }} // biome-ignore format: No trailing commas diff --git a/packages/browser/test/unit/eventbuilder.test.ts b/packages/browser/test/unit/eventbuilder.test.ts index 0a5a7911ea08..d941c792aeff 100644 --- a/packages/browser/test/unit/eventbuilder.test.ts +++ b/packages/browser/test/unit/eventbuilder.test.ts @@ -1,5 +1,5 @@ import { defaultStackParser } from '../../src'; -import { eventFromPlainObject } from '../../src/eventbuilder'; +import { eventFromUnknownInput } from '../../src/eventbuilder'; jest.mock('@sentry/core', () => { const original = jest.requireActual('@sentry/core'); @@ -12,17 +12,6 @@ jest.mock('@sentry/core', () => { }, }; }, - getCurrentHub() { - return { - getClient(): any { - return { - getOptions(): any { - return { normalizeDepth: 6 }; - }, - }; - }, - }; - }, }; }); @@ -35,7 +24,7 @@ afterEach(() => { jest.resetAllMocks(); }); -describe('eventFromPlainObject', () => { +describe('eventFromUnknownInput', () => { it('should use normalizeDepth from init options', () => { const deepObject = { a: { @@ -53,7 +42,7 @@ describe('eventFromPlainObject', () => { }, }; - const event = eventFromPlainObject(defaultStackParser, deepObject); + const event = eventFromUnknownInput(defaultStackParser, deepObject); expect(event?.extra?.__serialized__).toEqual({ a: { @@ -71,16 +60,107 @@ describe('eventFromPlainObject', () => { }); it.each([ - ['empty object', {}, 'Object captured as exception with keys: [object has no keys]'], - ['pojo', { prop1: 'hello', prop2: 2 }, 'Object captured as exception with keys: prop1, prop2'], - ['Custom Class', new MyTestClass(), 'Object captured as exception with keys: prop1, prop2'], - ['Event', new Event('custom'), 'Event `Event` (type=custom) captured as exception'], - ['MouseEvent', new MouseEvent('click'), 'Event `MouseEvent` (type=click) captured as exception'], - ] as [string, Record, string][])( + ['empty object', {}, {}, 'Object captured as exception with keys: [object has no keys]'], + [ + 'pojo', + { prop1: 'hello', prop2: 2 }, + { prop1: 'hello', prop2: 2 }, + 'Object captured as exception with keys: prop1, prop2', + ], + [ + 'Custom Class', + new MyTestClass(), + { prop1: 'hello', prop2: 2 }, + 'Object captured as exception with keys: prop1, prop2', + ], + [ + 'Event', + new Event('custom'), + { + currentTarget: '[object Null]', + isTrusted: false, + target: '[object Null]', + type: 'custom', + }, + 'Event `Event` (type=custom) captured as exception', + ], + [ + 'MouseEvent', + new MouseEvent('click'), + { + currentTarget: '[object Null]', + isTrusted: false, + target: '[object Null]', + type: 'click', + }, + 'Event `MouseEvent` (type=click) captured as exception', + ], + ] as [string, Record, Record, string][])( 'has correct exception value for %s', - (_name, exception, expected) => { - const actual = eventFromPlainObject(defaultStackParser, exception); + (_name, exception, serializedException, expected) => { + const actual = eventFromUnknownInput(defaultStackParser, exception); expect(actual.exception?.values?.[0]?.value).toEqual(expected); + + expect(actual.extra).toEqual({ + __serialized__: serializedException, + }); }, ); + + it('handles object with error prop', () => { + const error = new Error('Some error'); + const event = eventFromUnknownInput(defaultStackParser, { + foo: { bar: 'baz' }, + name: 'BadType', + err: error, + }); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Some error', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { + foo: { bar: 'baz' }, + name: 'BadType', + err: { + message: 'Some error', + name: 'Error', + stack: expect.stringContaining('Error: Some error'), + }, + }, + }); + }); + + it('handles class with error prop', () => { + const error = new Error('Some error'); + + class MyTestClass { + prop1 = 'hello'; + prop2 = error; + } + + const event = eventFromUnknownInput(defaultStackParser, new MyTestClass()); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Some error', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { + prop1: 'hello', + prop2: { + message: 'Some error', + name: 'Error', + stack: expect.stringContaining('Error: Some error'), + }, + }, + }); + }); }); diff --git a/packages/browser/test/unit/helper/browser-client-options.ts b/packages/browser/test/unit/helper/browser-client-options.ts index 8ca73faa2d23..867e6a9e6e6e 100644 --- a/packages/browser/test/unit/helper/browser-client-options.ts +++ b/packages/browser/test/unit/helper/browser-client-options.ts @@ -5,6 +5,7 @@ import type { BrowserClientOptions } from '../../../src/client'; export function getDefaultBrowserClientOptions(options: Partial = {}): BrowserClientOptions { return { + dsn: 'http://examplePublicKey@localhost/0', integrations: [], transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), stackParser: () => [], diff --git a/packages/browser/test/unit/index.bundle.feedback.test.ts b/packages/browser/test/unit/index.bundle.feedback.test.ts index 5a3451cb3ef0..c403144d4306 100644 --- a/packages/browser/test/unit/index.bundle.feedback.test.ts +++ b/packages/browser/test/unit/index.bundle.feedback.test.ts @@ -1,11 +1,13 @@ import { replayIntegrationShim } from '@sentry-internal/integration-shims'; -import { feedbackIntegration } from '@sentry/browser'; +import { feedbackIntegration, feedbackModalIntegration, feedbackScreenshotIntegration } from '@sentry/browser'; -import * as TracingReplayBundle from '../../src/index.bundle.feedback'; +import * as FeedbackBundle from '../../src/index.bundle.feedback'; describe('index.bundle.feedback', () => { it('has correct exports', () => { - expect(TracingReplayBundle.replayIntegration).toBe(replayIntegrationShim); - expect(TracingReplayBundle.feedbackIntegration).toBe(feedbackIntegration); + expect(FeedbackBundle.replayIntegration).toBe(replayIntegrationShim); + expect(FeedbackBundle.feedbackIntegration).toBe(feedbackIntegration); + expect(FeedbackBundle.feedbackModalIntegration).toBe(feedbackModalIntegration); + expect(FeedbackBundle.feedbackScreenshotIntegration).toBe(feedbackScreenshotIntegration); }); }); diff --git a/packages/browser/test/unit/index.bundle.replay.test.ts b/packages/browser/test/unit/index.bundle.replay.test.ts index 479e6b23393b..4e3d4e344734 100644 --- a/packages/browser/test/unit/index.bundle.replay.test.ts +++ b/packages/browser/test/unit/index.bundle.replay.test.ts @@ -1,11 +1,17 @@ -import { feedbackIntegrationShim } from '@sentry-internal/integration-shims'; +import { + feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, +} from '@sentry-internal/integration-shims'; import { replayIntegration } from '@sentry/browser'; -import * as TracingReplayBundle from '../../src/index.bundle.replay'; +import * as ReplayBundle from '../../src/index.bundle.replay'; describe('index.bundle.replay', () => { it('has correct exports', () => { - expect(TracingReplayBundle.replayIntegration).toBe(replayIntegration); - expect(TracingReplayBundle.feedbackIntegration).toBe(feedbackIntegrationShim); + expect(ReplayBundle.replayIntegration).toBe(replayIntegration); + expect(ReplayBundle.feedbackIntegration).toBe(feedbackIntegrationShim); + expect(ReplayBundle.feedbackModalIntegration).toBe(feedbackModalIntegrationShim); + expect(ReplayBundle.feedbackScreenshotIntegration).toBe(feedbackScreenshotIntegrationShim); }); }); diff --git a/packages/browser/test/unit/index.bundle.test.ts b/packages/browser/test/unit/index.bundle.test.ts index 9ef9f38d8db5..f02e2528f802 100644 --- a/packages/browser/test/unit/index.bundle.test.ts +++ b/packages/browser/test/unit/index.bundle.test.ts @@ -1,10 +1,17 @@ -import { feedbackIntegrationShim, replayIntegrationShim } from '@sentry-internal/integration-shims'; +import { + feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, + replayIntegrationShim, +} from '@sentry-internal/integration-shims'; -import * as TracingBundle from '../../src/index.bundle'; +import * as Bundle from '../../src/index.bundle'; describe('index.bundle', () => { it('has correct exports', () => { - expect(TracingBundle.replayIntegration).toBe(replayIntegrationShim); - expect(TracingBundle.feedbackIntegration).toBe(feedbackIntegrationShim); + expect(Bundle.replayIntegration).toBe(replayIntegrationShim); + expect(Bundle.feedbackIntegration).toBe(feedbackIntegrationShim); + expect(Bundle.feedbackModalIntegration).toBe(feedbackModalIntegrationShim); + expect(Bundle.feedbackScreenshotIntegration).toBe(feedbackScreenshotIntegrationShim); }); }); diff --git a/packages/browser/test/unit/index.bundle.tracing.replay.feedback.test.ts b/packages/browser/test/unit/index.bundle.tracing.replay.feedback.test.ts index a8440d160e2b..847f610ca702 100644 --- a/packages/browser/test/unit/index.bundle.tracing.replay.feedback.test.ts +++ b/packages/browser/test/unit/index.bundle.tracing.replay.feedback.test.ts @@ -1,6 +1,10 @@ -import { browserTracingIntegration } from '@sentry-internal/browser-utils'; -import { feedbackIntegration, replayIntegration } from '@sentry/browser'; - +import { + browserTracingIntegration, + feedbackIntegration, + feedbackModalIntegration, + feedbackScreenshotIntegration, + replayIntegration, +} from '../../src'; import * as TracingReplayFeedbackBundle from '../../src/index.bundle.tracing.replay.feedback'; describe('index.bundle.tracing.replay.feedback', () => { @@ -8,5 +12,7 @@ describe('index.bundle.tracing.replay.feedback', () => { expect(TracingReplayFeedbackBundle.replayIntegration).toBe(replayIntegration); expect(TracingReplayFeedbackBundle.browserTracingIntegration).toBe(browserTracingIntegration); expect(TracingReplayFeedbackBundle.feedbackIntegration).toBe(feedbackIntegration); + expect(TracingReplayFeedbackBundle.feedbackModalIntegration).toBe(feedbackModalIntegration); + expect(TracingReplayFeedbackBundle.feedbackScreenshotIntegration).toBe(feedbackScreenshotIntegration); }); }); diff --git a/packages/browser/test/unit/index.bundle.tracing.replay.test.ts b/packages/browser/test/unit/index.bundle.tracing.replay.test.ts index 18c286edffc9..f0228e575e68 100644 --- a/packages/browser/test/unit/index.bundle.tracing.replay.test.ts +++ b/packages/browser/test/unit/index.bundle.tracing.replay.test.ts @@ -1,7 +1,10 @@ -import { browserTracingIntegration } from '@sentry-internal/browser-utils'; -import { feedbackIntegrationShim } from '@sentry-internal/integration-shims'; -import { replayIntegration } from '@sentry/browser'; +import { + feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, +} from '@sentry-internal/integration-shims'; +import { browserTracingIntegration, replayIntegration } from '../../src'; import * as TracingReplayBundle from '../../src/index.bundle.tracing.replay'; describe('index.bundle.tracing.replay', () => { @@ -11,5 +14,7 @@ describe('index.bundle.tracing.replay', () => { expect(TracingReplayBundle.browserTracingIntegration).toBe(browserTracingIntegration); expect(TracingReplayBundle.feedbackIntegration).toBe(feedbackIntegrationShim); + expect(TracingReplayBundle.feedbackModalIntegration).toBe(feedbackModalIntegrationShim); + expect(TracingReplayBundle.feedbackScreenshotIntegration).toBe(feedbackScreenshotIntegrationShim); }); }); diff --git a/packages/browser/test/unit/index.bundle.tracing.test.ts b/packages/browser/test/unit/index.bundle.tracing.test.ts index 1bb1ca19eec1..67a530b0e376 100644 --- a/packages/browser/test/unit/index.bundle.tracing.test.ts +++ b/packages/browser/test/unit/index.bundle.tracing.test.ts @@ -1,6 +1,11 @@ -import { browserTracingIntegration } from '@sentry-internal/browser-utils'; -import { feedbackIntegrationShim, replayIntegrationShim } from '@sentry-internal/integration-shims'; +import { + feedbackIntegrationShim, + feedbackModalIntegrationShim, + feedbackScreenshotIntegrationShim, + replayIntegrationShim, +} from '@sentry-internal/integration-shims'; +import { browserTracingIntegration } from '../../src'; import * as TracingBundle from '../../src/index.bundle.tracing'; describe('index.bundle.tracing', () => { @@ -8,5 +13,7 @@ describe('index.bundle.tracing', () => { expect(TracingBundle.replayIntegration).toBe(replayIntegrationShim); expect(TracingBundle.browserTracingIntegration).toBe(browserTracingIntegration); expect(TracingBundle.feedbackIntegration).toBe(feedbackIntegrationShim); + expect(TracingBundle.feedbackModalIntegration).toBe(feedbackModalIntegrationShim); + expect(TracingBundle.feedbackScreenshotIntegration).toBe(feedbackScreenshotIntegrationShim); }); }); diff --git a/packages/browser/test/unit/sdk.test.ts b/packages/browser/test/unit/sdk.test.ts index f4a04d088135..f8f5125ff896 100644 --- a/packages/browser/test/unit/sdk.test.ts +++ b/packages/browser/test/unit/sdk.test.ts @@ -154,7 +154,7 @@ describe('init', () => { expect(consoleErrorSpy).toBeCalledTimes(1); expect(consoleErrorSpy).toHaveBeenCalledWith( - '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/troubleshooting/#setting-up-sentry-in-shared-environments-eg-browser-extensions', + '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/', ); consoleErrorSpy.mockRestore(); @@ -169,7 +169,7 @@ describe('init', () => { expect(consoleErrorSpy).toBeCalledTimes(1); expect(consoleErrorSpy).toHaveBeenCalledWith( - '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/troubleshooting/#setting-up-sentry-in-shared-environments-eg-browser-extensions', + '[Sentry] You cannot run Sentry this way in a browser extension, check: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/', ); consoleErrorSpy.mockRestore(); diff --git a/packages/browser/test/unit/tracing/backgroundtab.test.ts b/packages/browser/test/unit/tracing/backgroundtab.test.ts new file mode 100644 index 000000000000..2c998744a723 --- /dev/null +++ b/packages/browser/test/unit/tracing/backgroundtab.test.ts @@ -0,0 +1,54 @@ +import { getCurrentScope } from '@sentry/core'; +import { setCurrentClient } from '@sentry/core'; + +import { TextDecoder, TextEncoder } from 'util'; +const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true; +// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true; + +import { JSDOM } from 'jsdom'; + +import { BrowserClient } from '../../../src/client'; +import { registerBackgroundTabDetection } from '../../../src/tracing/backgroundtab'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; + +describe('registerBackgroundTabDetection', () => { + afterAll(() => { + // @ts-expect-error patch the encoder on the window, else importing JSDOM fails + patchedEncoder && delete global.window.TextEncoder; + // @ts-expect-error patch the encoder on the window, else importing JSDOM fails + patchedDecoder && delete global.window.TextDecoder; + }); + + let events: Record = {}; + beforeEach(() => { + const dom = new JSDOM(); + global.document = dom.window.document; + + const options = getDefaultBrowserClientOptions({ tracesSampleRate: 1 }); + const client = new BrowserClient(options); + setCurrentClient(client); + client.init(); + + global.document.addEventListener = jest.fn((event, callback) => { + events[event] = callback; + }); + }); + + afterEach(() => { + events = {}; + getCurrentScope().clear(); + }); + + it('does not create an event listener if global document is undefined', () => { + // @ts-expect-error need to override global document + global.document = undefined; + registerBackgroundTabDetection(); + expect(events).toMatchObject({}); + }); + + it('creates an event listener', () => { + registerBackgroundTabDetection(); + expect(events).toMatchObject({ visibilitychange: expect.any(Function) }); + }); +}); diff --git a/packages/browser-utils/test/browser/browserTracingIntegration.test.ts b/packages/browser/test/unit/tracing/browserTracingIntegration.test.ts similarity index 86% rename from packages/browser-utils/test/browser/browserTracingIntegration.test.ts rename to packages/browser/test/unit/tracing/browserTracingIntegration.test.ts index b537d684c8eb..7fde92ab764e 100644 --- a/packages/browser-utils/test/browser/browserTracingIntegration.test.ts +++ b/packages/browser/test/unit/tracing/browserTracingIntegration.test.ts @@ -1,3 +1,14 @@ +import { TextDecoder, TextEncoder } from 'util'; +const oldTextEncoder = global.window.TextEncoder; +const oldTextDecoder = global.window.TextDecoder; +// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +delete global.window.TextEncoder; +// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +delete global.window.TextDecoder; +global.window.TextEncoder = TextEncoder; +// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +global.window.TextDecoder = TextDecoder; + import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -16,9 +27,14 @@ import { import type { Span, StartSpanOptions } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; import { JSDOM } from 'jsdom'; -import { browserTracingIntegration, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '../..'; -import { WINDOW } from '../../src/browser/types'; -import { TestClient, getDefaultClientOptions } from '../utils/TestClient'; +import { BrowserClient } from '../../../src/client'; +import { WINDOW } from '../../../src/helpers'; +import { + browserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '../../../src/tracing/browserTracingIntegration'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; // We're setting up JSDom here because the Next.js routing instrumentations requires a few things to be present on pageload: // 1. Access to window.document API for `window.document.getElementById` @@ -51,9 +67,14 @@ describe('browserTracingIntegration', () => { getActiveSpan()?.end(); }); + afterAll(() => { + global.window.TextEncoder = oldTextEncoder; + global.window.TextDecoder = oldTextDecoder; + }); + it('works with tracing enabled', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration()], }), @@ -81,8 +102,8 @@ describe('browserTracingIntegration', () => { }); it('works with tracing disabled', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ integrations: [browserTracingIntegration()], }), ); @@ -94,8 +115,8 @@ describe('browserTracingIntegration', () => { }); it("doesn't create a pageload span when instrumentPageLoad is false", () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ integrations: [browserTracingIntegration({ instrumentPageLoad: false })], }), ); @@ -107,8 +128,8 @@ describe('browserTracingIntegration', () => { }); it('works with tracing enabled but unsampled', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [browserTracingIntegration()], }), @@ -122,8 +143,8 @@ describe('browserTracingIntegration', () => { }); it('starts navigation when URL changes', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration()], }), @@ -206,8 +227,8 @@ describe('browserTracingIntegration', () => { }); it("trims pageload transactions to the max duration of the transaction's children", async () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ idleTimeout: 10 })], }), @@ -230,8 +251,8 @@ describe('browserTracingIntegration', () => { describe('startBrowserTracingPageLoadSpan', () => { it('works without integration setup', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ integrations: [], }), ); @@ -244,8 +265,8 @@ describe('browserTracingIntegration', () => { }); it('works with unsampled span', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [browserTracingIntegration({ instrumentPageLoad: false })], }), @@ -260,8 +281,8 @@ describe('browserTracingIntegration', () => { }); it('works with integration setup', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ instrumentPageLoad: false })], }), @@ -290,8 +311,8 @@ describe('browserTracingIntegration', () => { }); it('allows to overwrite properties', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ instrumentPageLoad: false })], }), @@ -328,8 +349,8 @@ describe('browserTracingIntegration', () => { it('calls before beforeStartSpan', () => { const mockBeforeStartSpan = jest.fn((options: StartSpanOptions) => options); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [ browserTracingIntegration({ instrumentPageLoad: false, beforeStartSpan: mockBeforeStartSpan }), @@ -355,8 +376,8 @@ describe('browserTracingIntegration', () => { op: 'test op', })); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [ browserTracingIntegration({ @@ -378,8 +399,8 @@ describe('browserTracingIntegration', () => { }); it('sets the pageload span name on `scope.transactionName`', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ integrations: [browserTracingIntegration()], }), ); @@ -398,8 +419,8 @@ describe('browserTracingIntegration', () => { name: 'changed', })); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [ browserTracingIntegration({ @@ -423,8 +444,8 @@ describe('browserTracingIntegration', () => { describe('startBrowserTracingNavigationSpan', () => { it('works without integration setup', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ integrations: [], }), ); @@ -437,8 +458,8 @@ describe('browserTracingIntegration', () => { }); it('works with unsampled span', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [browserTracingIntegration({ instrumentNavigation: false })], }), @@ -453,8 +474,8 @@ describe('browserTracingIntegration', () => { }); it('works with integration setup', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ instrumentNavigation: false })], }), @@ -483,8 +504,8 @@ describe('browserTracingIntegration', () => { }); it('allows to overwrite properties', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ instrumentNavigation: false })], }), @@ -521,8 +542,8 @@ describe('browserTracingIntegration', () => { it('calls before beforeStartSpan', () => { const mockBeforeStartSpan = jest.fn((options: StartSpanOptions) => options); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [ browserTracingIntegration({ @@ -552,8 +573,8 @@ describe('browserTracingIntegration', () => { op: 'test op', })); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [ browserTracingIntegration({ @@ -580,8 +601,8 @@ describe('browserTracingIntegration', () => { name: 'changed', })); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 0, integrations: [ browserTracingIntegration({ @@ -604,8 +625,8 @@ describe('browserTracingIntegration', () => { }); it('sets the navigation span name on `scope.transactionName`', () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ integrations: [browserTracingIntegration()], }), ); @@ -618,8 +639,8 @@ describe('browserTracingIntegration', () => { }); it("resets the scopes' propagationContexts", () => { - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ integrations: [browserTracingIntegration()], }), ); @@ -663,8 +684,8 @@ describe('browserTracingIntegration', () => { '' + ''; - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration()], }), @@ -689,9 +710,9 @@ describe('browserTracingIntegration', () => { expect(dynamicSamplingContext).toBeDefined(); expect(dynamicSamplingContext).toStrictEqual({ release: '2.1.14' }); - // Propagation context is reset and does not contain the meta tag data - expect(propagationContext.traceId).not.toEqual('12312012123120121231201212312012'); - expect(propagationContext.parentSpanId).not.toEqual('1121201211212012'); + // Propagation context keeps the meta tag trace data for later events on the same route to add them to the trace + expect(propagationContext.traceId).toEqual('12312012123120121231201212312012'); + expect(propagationContext.parentSpanId).toEqual('1121201211212012'); }); it('puts frozen Dynamic Sampling Context on pageload span if sentry-trace data and only 3rd party baggage is present', () => { @@ -700,8 +721,8 @@ describe('browserTracingIntegration', () => { '' + ''; - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration()], }), @@ -726,9 +747,9 @@ describe('browserTracingIntegration', () => { expect(dynamicSamplingContext).toBeDefined(); expect(dynamicSamplingContext).toStrictEqual({}); - // Propagation context is reset and does not contain the meta tag data - expect(propagationContext.traceId).not.toEqual('12312012123120121231201212312012'); - expect(propagationContext.parentSpanId).not.toEqual('1121201211212012'); + // Propagation context keeps the meta tag trace data for later events on the same route to add them to the trace + expect(propagationContext.traceId).toEqual('12312012123120121231201212312012'); + expect(propagationContext.parentSpanId).toEqual('1121201211212012'); }); it('ignores the meta tag data for navigation spans', () => { @@ -736,8 +757,8 @@ describe('browserTracingIntegration', () => { '' + ''; - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ instrumentPageLoad: false })], }), @@ -768,7 +789,7 @@ describe('browserTracingIntegration', () => { expect(dynamicSamplingContext).toBeDefined(); expect(dynamicSamplingContext).toStrictEqual({ environment: 'production', - public_key: 'username', + public_key: 'examplePublicKey', sample_rate: '1', sampled: 'true', trace_id: expect.not.stringContaining('12312012123120121231201212312012'), @@ -785,8 +806,8 @@ describe('browserTracingIntegration', () => { '' + ''; - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ instrumentPageLoad: false })], }), @@ -822,17 +843,17 @@ describe('browserTracingIntegration', () => { expect(dynamicSamplingContext).toBeDefined(); expect(dynamicSamplingContext).toStrictEqual({ release: '2.2.14' }); - // Propagation context is reset and does not contain the meta tag data - expect(propagationContext.traceId).not.toEqual('12312012123120121231201212312012'); - expect(propagationContext.parentSpanId).not.toEqual('1121201211212012'); + // Propagation context keeps the custom trace data for later events on the same route to add them to the trace + expect(propagationContext.traceId).toEqual('12312012123120121231201212312011'); + expect(propagationContext.parentSpanId).toEqual('1121201211212011'); }); }); describe('idleTimeout', () => { it('is created by default', () => { jest.useFakeTimers(); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration()], }), @@ -866,8 +887,8 @@ describe('browserTracingIntegration', () => { it('can be a custom value', () => { jest.useFakeTimers(); - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ idleTimeout: 2000 })], }), @@ -906,8 +927,8 @@ describe('browserTracingIntegration', () => { const interval = 200; - const client = new TestClient( - getDefaultClientOptions({ + const client = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, integrations: [browserTracingIntegration({ heartbeatInterval: interval })], }), diff --git a/packages/browser-utils/test/browser/request.test.ts b/packages/browser/test/unit/tracing/request.test.ts similarity index 96% rename from packages/browser-utils/test/browser/request.test.ts rename to packages/browser/test/unit/tracing/request.test.ts index 8855203ce136..521ff1327b01 100644 --- a/packages/browser-utils/test/browser/request.test.ts +++ b/packages/browser/test/unit/tracing/request.test.ts @@ -1,8 +1,8 @@ -/* eslint-disable deprecation/deprecation */ +import * as browserUtils from '@sentry-internal/browser-utils'; import * as utils from '@sentry/utils'; +import { WINDOW } from '../../../src/helpers'; -import { extractNetworkProtocol, instrumentOutgoingRequests, shouldAttachHeaders } from '../../src/browser/request'; -import { WINDOW } from '../../src/browser/types'; +import { extractNetworkProtocol, instrumentOutgoingRequests, shouldAttachHeaders } from '../../../src/tracing/request'; beforeAll(() => { // @ts-expect-error need to override global Request because it's not in the jest environment (even with an @@ -17,7 +17,7 @@ describe('instrumentOutgoingRequests', () => { it('instruments fetch and xhr requests', () => { const addFetchSpy = jest.spyOn(utils, 'addFetchInstrumentationHandler'); - const addXhrSpy = jest.spyOn(utils, 'addXhrInstrumentationHandler'); + const addXhrSpy = jest.spyOn(browserUtils, 'addXhrInstrumentationHandler'); instrumentOutgoingRequests(); @@ -34,7 +34,7 @@ describe('instrumentOutgoingRequests', () => { }); it('does not instrument xhr requests if traceXHR is false', () => { - const addXhrSpy = jest.spyOn(utils, 'addXhrInstrumentationHandler'); + const addXhrSpy = jest.spyOn(browserUtils, 'addXhrInstrumentationHandler'); instrumentOutgoingRequests({ traceXHR: false }); @@ -113,6 +113,8 @@ describe('shouldAttachHeaders', () => { beforeAll(() => { originalWindowLocation = WINDOW.location; + // @ts-expect-error Override delete + delete WINDOW.location; // @ts-expect-error We are missing some fields of the Origin interface but it doesn't matter for these tests. WINDOW.location = new URL('https://my-origin.com'); }); @@ -153,6 +155,8 @@ describe('shouldAttachHeaders', () => { beforeAll(() => { originalWindowLocation = WINDOW.location; + // @ts-expect-error Override delete + delete WINDOW.location; // @ts-expect-error We are missing some fields of the Origin interface but it doesn't matter for these tests. WINDOW.location = new URL('https://my-origin.com/api/my-route'); }); @@ -276,6 +280,8 @@ describe('shouldAttachHeaders', () => { beforeAll(() => { originalWindowLocation = WINDOW.location; + // @ts-expect-error Override delete + delete WINDOW.location; // @ts-expect-error We need to simulate an edge-case WINDOW.location = undefined; }); diff --git a/packages/browser/test/unit/transports/offline.test.ts b/packages/browser/test/unit/transports/offline.test.ts index ccc206dbdf97..ed4cd770101d 100644 --- a/packages/browser/test/unit/transports/offline.test.ts +++ b/packages/browser/test/unit/transports/offline.test.ts @@ -11,7 +11,7 @@ import type { import { createEnvelope } from '@sentry/utils'; import { MIN_DELAY } from '../../../../core/src/transports/offline'; -import { createStore, insert, makeBrowserOfflineTransport, pop } from '../../../src/transports/offline'; +import { createStore, makeBrowserOfflineTransport, push, shift, unshift } from '../../../src/transports/offline'; function deleteDatabase(name: string): Promise { return new Promise((resolve, reject) => { @@ -63,21 +63,24 @@ describe('makeOfflineTransport', () => { (global as any).TextDecoder = TextDecoder; }); - it('indexedDb wrappers insert and pop', async () => { + it('indexedDb wrappers push, unshift and pop', async () => { const store = createStore('test', 'test'); - const found = await pop(store); + const found = await shift(store); expect(found).toBeUndefined(); - await insert(store, 'test1', 30); - await insert(store, new Uint8Array([1, 2, 3, 4, 5]), 30); + await push(store, 'test1', 30); + await push(store, new Uint8Array([1, 2, 3, 4, 5]), 30); + await unshift(store, 'test2', 30); - const found2 = await pop(store); - expect(found2).toEqual('test1'); - const found3 = await pop(store); - expect(found3).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + const found2 = await shift(store); + expect(found2).toEqual('test2'); + const found3 = await shift(store); + expect(found3).toEqual('test1'); + const found4 = await shift(store); + expect(found4).toEqual(new Uint8Array([1, 2, 3, 4, 5])); - const found4 = await pop(store); - expect(found4).toBeUndefined(); + const found5 = await shift(store); + expect(found5).toBeUndefined(); }); it('Queues and retries envelope if wrapped transport throws error', async () => { @@ -104,7 +107,7 @@ describe('makeOfflineTransport', () => { const result2 = await transport.send(ERROR_ENVELOPE); expect(result2).toEqual({ statusCode: 200 }); - await delay(MIN_DELAY * 2); + await delay(MIN_DELAY * 5); expect(queuedCount).toEqual(1); expect(getSendCount()).toEqual(2); diff --git a/packages/browser/test/unit/utils/lazyLoadIntegration.test.ts b/packages/browser/test/unit/utils/lazyLoadIntegration.test.ts new file mode 100644 index 000000000000..f2afefcbe9a2 --- /dev/null +++ b/packages/browser/test/unit/utils/lazyLoadIntegration.test.ts @@ -0,0 +1,83 @@ +import { TextDecoder, TextEncoder } from 'util'; +import { SDK_VERSION, lazyLoadIntegration } from '../../../src'; +import * as Sentry from '../../../src'; +const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true; +// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true; + +import { JSDOM } from 'jsdom'; + +const globalDocument = global.document; +const globalWindow = global.window; +const globalLocation = global.location; + +describe('lazyLoadIntegration', () => { + beforeEach(() => { + const dom = new JSDOM('', { + runScripts: 'dangerously', + resources: 'usable', + }); + + global.document = dom.window.document; + // @ts-expect-error need to override global document + global.window = dom.window; + global.location = dom.window.location; + // @ts-expect-error For testing sake + global.Sentry = undefined; + }); + + // Reset back to previous values + afterEach(() => { + global.document = globalDocument; + global.window = globalWindow; + global.location = globalLocation; + }); + + afterAll(() => { + // @ts-expect-error patch the encoder on the window, else importing JSDOM fails + patchedEncoder && delete global.window.TextEncoder; + // @ts-expect-error patch the encoder on the window, else importing JSDOM fails + patchedDecoder && delete global.window.TextDecoder; + }); + + test('it rejects invalid name', async () => { + // @ts-expect-error For testing sake - otherwise this bails out anyhow + global.Sentry = Sentry; + + // @ts-expect-error we want to test this + await expect(() => lazyLoadIntegration('invalid!!!')).rejects.toThrow('Cannot lazy load integration: invalid!!!'); + }); + + test('it rejects without global Sentry variable', async () => { + await expect(() => lazyLoadIntegration('httpClientIntegration')).rejects.toThrow( + 'Cannot lazy load integration: httpClientIntegration', + ); + }); + + test('it does not inject a script tag if integration already exists', async () => { + // @ts-expect-error For testing sake + global.Sentry = Sentry; + + const integration = await lazyLoadIntegration('httpClientIntegration'); + + expect(integration).toBe(Sentry.httpClientIntegration); + expect(global.document.querySelectorAll('script')).toHaveLength(0); + }); + + test('it injects a script tag if integration is not yet loaded', async () => { + // @ts-expect-error For testing sake + global.Sentry = { + ...Sentry, + httpClientIntegration: undefined, + }; + + // We do not await here, as this this does not seem to work with JSDOM :( + // We have browser integration tests to check that this actually works + void lazyLoadIntegration('httpClientIntegration'); + + expect(global.document.querySelectorAll('script')).toHaveLength(1); + expect(global.document.querySelector('script')?.src).toEqual( + `https://browser.sentry-cdn.com/${SDK_VERSION}/httpclient.min.js`, + ); + }); +}); diff --git a/packages/bun/package.json b/packages/bun/package.json index d5454c17c88c..9bad7751c615 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -44,6 +44,7 @@ "dependencies": { "@sentry/core": "8.0.0-alpha.9", "@sentry/node": "8.0.0-alpha.9", + "@sentry/opentelemetry": "8.0.0-alpha.9", "@sentry/types": "8.0.0-alpha.9", "@sentry/utils": "8.0.0-alpha.9" }, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index db54dcdd6fb5..f13e3a666c50 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -37,7 +37,6 @@ export { getCurrentScope, getGlobalScope, getIsolationScope, - Hub, setCurrentClient, Scope, SDK_VERSION, @@ -113,6 +112,7 @@ export { spotlightIntegration, initOpenTelemetry, spanToJSON, + spanToTraceHeader, trpcMiddleware, } from '@sentry/node'; diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index ad9832795fc4..b4968f2001c0 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -1,4 +1,4 @@ -import { createTransport } from '@sentry/core'; +import { createTransport, suppressTracing } from '@sentry/core'; import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; import { rejectedSyncPromise } from '@sentry/utils'; @@ -19,14 +19,16 @@ export function makeFetchTransport(options: BunTransportOptions): Transport { }; try { - return fetch(options.url, requestOptions).then(response => { - return { - statusCode: response.status, - headers: { - 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), - 'retry-after': response.headers.get('Retry-After'), - }, - }; + return suppressTracing(() => { + return fetch(options.url, requestOptions).then(response => { + return { + statusCode: response.status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + }; + }); }); } catch (e) { return rejectedSyncPromise(e); diff --git a/packages/core/src/asyncContext.ts b/packages/core/src/asyncContext.ts index 854b03ea9600..82d336ded509 100644 --- a/packages/core/src/asyncContext.ts +++ b/packages/core/src/asyncContext.ts @@ -1,7 +1,7 @@ -import type { Hub, Integration } from '@sentry/types'; +import type { Integration } from '@sentry/types'; import type { Scope } from '@sentry/types'; import { GLOBAL_OBJ } from '@sentry/utils'; -import type { startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './tracing/trace'; +import type { startInactiveSpan, startSpan, startSpanManual, suppressTracing, withActiveSpan } from './tracing/trace'; import type { getActiveSpan } from './utils/spanUtils'; /** @@ -10,11 +10,6 @@ import type { getActiveSpan } from './utils/spanUtils'; * Strategy used to track async context. */ export interface AsyncContextStrategy { - /** - * Gets the currently active hub. - */ - getCurrentHub: () => Hub; - /** * Fork the isolation scope inside of the provided callback. */ @@ -62,6 +57,9 @@ export interface AsyncContextStrategy { /** Make a span the active span in the context of the callback. */ withActiveSpan?: typeof withActiveSpan; + + /** Suppress tracing in the given callback, ensuring no spans are generated inside of it. */ + suppressTracing?: typeof suppressTracing; } /** diff --git a/packages/core/src/currentScopes.ts b/packages/core/src/currentScopes.ts index 1e68c9583372..e0b598c1ad39 100644 --- a/packages/core/src/currentScopes.ts +++ b/packages/core/src/currentScopes.ts @@ -74,15 +74,44 @@ export function withScope( * * This function is intended for Sentry SDK and SDK integration development. It is not recommended to be used in "normal" * applications directly because it comes with pitfalls. Use at your own risk! + */ +export function withIsolationScope(callback: (isolationScope: Scope) => T): T; +/** + * Set the provided isolation scope as active in the given callback. If no + * async context strategy is set, the isolation scope and the current scope will not be forked (this is currently the + * case, for example, in the browser). + * + * Usage of this function in environments without async context strategy is discouraged and may lead to unexpected behaviour. * - * @param callback The callback in which the passed isolation scope is active. (Note: In environments without async - * context strategy, the currently active isolation scope may change within execution of the callback.) - * @returns The same value that `callback` returns. + * This function is intended for Sentry SDK and SDK integration development. It is not recommended to be used in "normal" + * applications directly because it comes with pitfalls. Use at your own risk! + * + * If you pass in `undefined` as a scope, it will fork a new isolation scope, the same as if no scope is passed. + */ +export function withIsolationScope(isolationScope: Scope | undefined, callback: (isolationScope: Scope) => T): T; +/** + * Either creates a new active isolation scope, or sets the given isolation scope as active scope in the given callback. */ -export function withIsolationScope(callback: (isolationScope: Scope) => T): T { +export function withIsolationScope( + ...rest: + | [callback: (isolationScope: Scope) => T] + | [isolationScope: Scope | undefined, callback: (isolationScope: Scope) => T] +): T { const carrier = getMainCarrier(); const acs = getAsyncContextStrategy(carrier); - return acs.withIsolationScope(callback); + + // If a scope is defined, we want to make this the active scope instead of the default one + if (rest.length === 2) { + const [isolationScope, callback] = rest; + + if (!isolationScope) { + return acs.withIsolationScope(callback); + } + + return acs.withSetIsolationScope(isolationScope, callback); + } + + return acs.withIsolationScope(rest[0]); } /** diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 2ccb6ef530e8..b1c7e55776b1 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -19,7 +19,6 @@ import { GLOBAL_OBJ, isThenable, logger, timestampInSeconds, uuid4 } from '@sent import { DEFAULT_ENVIRONMENT } from './constants'; import { getClient, getCurrentScope, getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; -import type { Hub } from './hub'; import { closeSession, makeSession, updateSession } from './session'; import type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; import { parseEventHintOrCaptureContext } from './utils/prepareEvent'; @@ -71,7 +70,7 @@ export function captureEvent(event: Event, hint?: EventHint): string { * @param context Any kind of data. This data will be normalized. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function setContext(name: string, context: { [key: string]: any } | null): ReturnType { +export function setContext(name: string, context: { [key: string]: any } | null): void { getIsolationScope().setContext(name, context); } @@ -79,7 +78,7 @@ export function setContext(name: string, context: { [key: string]: any } | null) * Set an object that will be merged sent as extra data with the event. * @param extras Extras object to merge into current context. */ -export function setExtras(extras: Extras): ReturnType { +export function setExtras(extras: Extras): void { getIsolationScope().setExtras(extras); } @@ -88,7 +87,7 @@ export function setExtras(extras: Extras): ReturnType { * @param key String of extra * @param extra Any kind of data. This data will be normalized. */ -export function setExtra(key: string, extra: Extra): ReturnType { +export function setExtra(key: string, extra: Extra): void { getIsolationScope().setExtra(key, extra); } @@ -96,7 +95,7 @@ export function setExtra(key: string, extra: Extra): ReturnType * Set an object that will be merged sent as tags data with the event. * @param tags Tags context object to merge into current context. */ -export function setTags(tags: { [key: string]: Primitive }): ReturnType { +export function setTags(tags: { [key: string]: Primitive }): void { getIsolationScope().setTags(tags); } @@ -108,7 +107,7 @@ export function setTags(tags: { [key: string]: Primitive }): ReturnType { +export function setTag(key: string, value: Primitive): void { getIsolationScope().setTag(key, value); } @@ -117,7 +116,7 @@ export function setTag(key: string, value: Primitive): ReturnType * * @param user User context object to be set in the current context. Pass `null` to unset the user. */ -export function setUser(user: User | null): ReturnType { +export function setUser(user: User | null): void { getIsolationScope().setUser(user); } diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 8002d9280297..f9069b6b6efa 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -16,7 +16,7 @@ import { } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import { hasTracingEnabled } from './utils/hasTracingEnabled'; -import { spanToTraceHeader } from './utils/spanUtils'; +import { getActiveSpan, spanToTraceHeader } from './utils/spanUtils'; type PolymorphicRequestHeaders = | Record @@ -41,11 +41,11 @@ export function instrumentFetchRequest( spans: Record, spanOrigin: SpanOrigin = 'auto.http.browser', ): Span | undefined { - if (!hasTracingEnabled() || !handlerData.fetchData) { + if (!handlerData.fetchData) { return undefined; } - const shouldCreateSpanResult = shouldCreateSpan(handlerData.fetchData.url); + const shouldCreateSpanResult = hasTracingEnabled() && shouldCreateSpan(handlerData.fetchData.url); if (handlerData.endTimestamp && shouldCreateSpanResult) { const spanId = handlerData.fetchData.__span; @@ -81,19 +81,21 @@ export function instrumentFetchRequest( const { method, url } = handlerData.fetchData; - const span = shouldCreateSpanResult - ? startInactiveSpan({ - name: `${method} ${url}`, - onlyIfParent: true, - attributes: { - url, - type: 'fetch', - 'http.method': method, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, - }, - op: 'http.client', - }) - : new SentryNonRecordingSpan(); + const hasParent = !!getActiveSpan(); + + const span = + shouldCreateSpanResult && hasParent + ? startInactiveSpan({ + name: `${method} ${url}`, + attributes: { + url, + type: 'fetch', + 'http.method': method, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, + }, + op: 'http.client', + }) + : new SentryNonRecordingSpan(); handlerData.fetchData.__span = span.spanContext().spanId; spans[span.spanContext().spanId] = span; @@ -107,7 +109,17 @@ export function instrumentFetchRequest( // eslint-disable-next-line @typescript-eslint/no-explicit-any const options: { [key: string]: any } = handlerData.args[1]; - options.headers = addTracingHeadersToFetchRequest(request, client, scope, options, span); + options.headers = addTracingHeadersToFetchRequest( + request, + client, + scope, + options, + // In the following cases, we do not want to use the span as base for the trace headers, + // which means that the headers will be generated from the scope: + // - If tracing is disabled (TWP) + // - If the span has no parent span - which means we ran into `onlyIfParent` check + hasTracingEnabled() && hasParent ? span : undefined, + ); } return span; diff --git a/packages/opentelemetry/src/custom/getCurrentHub.ts b/packages/core/src/getCurrentHubShim.ts similarity index 61% rename from packages/opentelemetry/src/custom/getCurrentHub.ts rename to packages/core/src/getCurrentHubShim.ts index 9db09297d670..3da344fcba1a 100644 --- a/packages/opentelemetry/src/custom/getCurrentHub.ts +++ b/packages/core/src/getCurrentHubShim.ts @@ -1,12 +1,9 @@ import type { Client, EventHint, Hub, Integration, IntegrationClass, SeverityLevel } from '@sentry/types'; - +import { addBreadcrumb } from './breadcrumbs'; +import { getClient, getCurrentScope, getIsolationScope, withScope } from './currentScopes'; import { - addBreadcrumb, captureEvent, endSession, - getClient, - getCurrentScope, - getIsolationScope, setContext, setExtra, setExtras, @@ -14,14 +11,18 @@ import { setTags, setUser, startSession, - withScope, -} from '@sentry/core'; +} from './exports'; /** * This is for legacy reasons, and returns a proxy object instead of a hub to be used. - * @deprecated Use the methods directly. + * + * @deprecated Use the methods directly from the top level Sentry API (e.g. `Sentry.withScope`) + * For more information see our migration guide for + * [replacing `getCurrentHub` and `Hub`](https://github.com/getsentry/sentry-javascript/blob/develop/MIGRATION.md#deprecate-hub) + * usage */ -export function getCurrentHub(): Hub { +// eslint-disable-next-line deprecation/deprecation +export function getCurrentHubShim(): Hub { return { bindClient(client: Client): void { const scope = getCurrentScope(); @@ -48,7 +49,8 @@ export function getCurrentHub(): Hub { setContext, getIntegration(integration: IntegrationClass): T | null { - return getClient()?.getIntegrationByName(integration.id) || null; + const client = getClient(); + return (client && client.getIntegrationByName(integration.id)) || null; }, startSession, @@ -68,6 +70,18 @@ export function getCurrentHub(): Hub { }; } +/** + * Returns the default hub instance. + * + * If a hub is already registered in the global carrier but this module + * contains a more recent version, it replaces the registered version. + * Otherwise, the currently registered hub will be returned. + * + * @deprecated Use the respective replacement method directly instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const getCurrentHub = getCurrentHubShim; + /** * Sends the current Session on the scope */ diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index de36626415ee..1bd3ec7c64b6 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -55,7 +55,9 @@ export interface Layer { /** * @inheritDoc + * @deprecated This class will be removed in v8 (tmp-deprecating so we're aware of where this is a problem) */ +// eslint-disable-next-line deprecation/deprecation export class Hub implements HubInterface { /** Is a {@link Layer}[] containing the client and scope */ private readonly _stack: Layer[]; @@ -494,23 +496,6 @@ export class Hub implements HubInterface { } } -/** - * Returns the default hub instance. - * - * If a hub is already registered in the global carrier but this module - * contains a more recent version, it replaces the registered version. - * Otherwise, the currently registered hub will be returned. - * - * @deprecated Use the respective replacement method directly instead. - */ -export function getCurrentHub(): HubInterface { - // Get main carrier (global for every environment) - const carrier = getMainCarrier(); - - const acs = getAsyncContextStrategy(carrier); - return acs.getCurrentHub() || getGlobalHub(); -} - /** Get the default current scope. */ export function getDefaultCurrentScope(): Scope { return getGlobalSingleton('defaultCurrentScope', () => new Scope()); @@ -525,8 +510,10 @@ export function getDefaultIsolationScope(): Scope { * Get the global hub. * This will be removed during the v8 cycle and is only here to make migration easier. */ +// eslint-disable-next-line deprecation/deprecation export function getGlobalHub(): HubInterface { const registry = getMainCarrier(); + // eslint-disable-next-line deprecation/deprecation const sentry = getSentryCarrier(registry) as { hub?: HubInterface }; // If there's no hub, or its an old API, assign a new one @@ -560,6 +547,7 @@ function withScope(callback: (scope: ScopeInterface) => T): T { } function withSetScope(scope: ScopeInterface, callback: (scope: ScopeInterface) => T): T { + // eslint-disable-next-line deprecation/deprecation const hub = getGlobalHub() as Hub; // eslint-disable-next-line deprecation/deprecation return hub.withScope(() => { @@ -580,7 +568,6 @@ function withIsolationScope(callback: (isolationScope: ScopeInterface) => T): /* eslint-disable deprecation/deprecation */ function getHubStackAsyncContextStrategy(): AsyncContextStrategy { return { - getCurrentHub: getGlobalHub, withIsolationScope, withScope, withSetScope, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 48bb5baf6afc..cf3415302314 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,10 +30,6 @@ export { addEventProcessor, } from './exports'; export { - // eslint-disable-next-line deprecation/deprecation - getCurrentHub, - Hub, - getGlobalHub, getDefaultCurrentScope, getDefaultIsolationScope, } from './hub'; @@ -69,6 +65,7 @@ export { export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; +export { createSpanEnvelope } from './span'; export { hasTracingEnabled } from './utils/hasTracingEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; @@ -106,3 +103,6 @@ export { BrowserMetricsAggregator } from './metrics/browser-aggregator'; export { getMetricSummaryJsonForSpan } from './metrics/metric-summary'; export { addTracingHeadersToFetchRequest, instrumentFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; + +// eslint-disable-next-line deprecation/deprecation +export { getCurrentHubShim, getCurrentHub } from './getCurrentHubShim'; diff --git a/packages/core/src/integrations/inboundfilters.ts b/packages/core/src/integrations/inboundfilters.ts index 9c64476568c7..66eafcf4db40 100644 --- a/packages/core/src/integrations/inboundfilters.ts +++ b/packages/core/src/integrations/inboundfilters.ts @@ -155,10 +155,6 @@ function _getPossibleEventMessages(event: Event): string[] { } } - if (DEBUG_BUILD && possibleMessages.length === 0) { - logger.error(`Could not extract message for event ${getEventDescription(event)}`); - } - return possibleMessages; } diff --git a/packages/core/src/integrations/rewriteframes.ts b/packages/core/src/integrations/rewriteframes.ts index ee842e86942f..3b81d141cdad 100644 --- a/packages/core/src/integrations/rewriteframes.ts +++ b/packages/core/src/integrations/rewriteframes.ts @@ -1,5 +1,5 @@ -import type { Event, IntegrationFn, StackFrame, Stacktrace } from '@sentry/types'; -import { basename, relative } from '@sentry/utils'; +import type { Event, StackFrame, Stacktrace } from '@sentry/types'; +import { GLOBAL_OBJ, basename, relative } from '@sentry/utils'; import { defineIntegration } from '../integration'; type StackFrameIteratee = (frame: StackFrame) => StackFrame; @@ -7,39 +7,55 @@ type StackFrameIteratee = (frame: StackFrame) => StackFrame; const INTEGRATION_NAME = 'RewriteFrames'; interface RewriteFramesOptions { + /** + * Root path (the beginning of the path) that will be stripped from the frames' filename. + * + * This option has slightly different behaviour in the browser and on servers: + * - In the browser, the value you provide in `root` will be stripped from the beginning stack frames' paths (if the path started with the value). + * - On the server, the root value will only replace the beginning of stack frame filepaths, when the path is absolute. If no `root` value is provided and the path is absolute, the frame will be reduced to only the filename and the provided `prefix` option. + * + * Browser example: + * - Original frame: `'http://example.com/my/path/static/asset.js'` + * - `root: 'http://example.com/my/path'` + * - `assetPrefix: 'app://'` + * - Resulting frame: `'app:///static/asset.js'` + * + * Server example: + * - Original frame: `'/User/local/my/path/static/asset.js'` + * - `root: '/User/local/my/path'` + * - `assetPrefix: 'app://'` + * - Resulting frame: `'app:///static/asset.js'` + */ root?: string; + + /** + * A custom prefix that stack frames will be prepended with. + * + * Default: `'app://'` + * + * This option has slightly different behaviour in the browser and on servers: + * - In the browser, the value you provide in `prefix` will prefix the resulting filename when the value you provided in `root` was applied. Effectively replacing whatever `root` matched in the beginning of the frame with `prefix`. + * - On the server, the prefix is applied to all stackframes with absolute paths. On Windows, the drive identifier (e.g. "C://") is replaced with the prefix. + */ prefix?: string; + + /** + * Defines an iterator that is used to iterate through all of the stack frames for modification before being sent to Sentry. + * Setting this option will effectively disable both the `root` and the `prefix` options. + */ iteratee?: StackFrameIteratee; } -const _rewriteFramesIntegration = ((options: RewriteFramesOptions = {}) => { +/** + * Rewrite event frames paths. + */ +export const rewriteFramesIntegration = defineIntegration((options: RewriteFramesOptions = {}) => { const root = options.root; const prefix = options.prefix || 'app:///'; - const iteratee: StackFrameIteratee = - options.iteratee || - ((frame: StackFrame) => { - if (!frame.filename) { - return frame; - } - // Determine if this is a Windows frame by checking for a Windows-style prefix such as `C:\` - const isWindowsFrame = - /^[a-zA-Z]:\\/.test(frame.filename) || - // or the presence of a backslash without a forward slash (which are not allowed on Windows) - (frame.filename.includes('\\') && !frame.filename.includes('/')); - // Check if the frame filename begins with `/` - const startsWithSlash = /^\//.test(frame.filename); - if (isWindowsFrame || startsWithSlash) { - const filename = isWindowsFrame - ? frame.filename - .replace(/^[a-zA-Z]:/, '') // remove Windows-style prefix - .replace(/\\/g, '/') // replace all `\\` instances with `/` - : frame.filename; - const base = root ? relative(root, filename) : basename(filename); - frame.filename = `${prefix}${base}`; - } - return frame; - }); + const isBrowser = 'window' in GLOBAL_OBJ && GLOBAL_OBJ.window !== undefined; + + const iteratee: StackFrameIteratee = options.iteratee || generateIteratee({ isBrowser, root, prefix }); /** Process an exception event. */ function _processExceptionsEvent(event: Event): Event { @@ -81,9 +97,53 @@ const _rewriteFramesIntegration = ((options: RewriteFramesOptions = {}) => { return processedEvent; }, }; -}) satisfies IntegrationFn; +}); /** - * Rewrite event frames paths. + * Exported only for tests. */ -export const rewriteFramesIntegration = defineIntegration(_rewriteFramesIntegration); +export function generateIteratee({ + isBrowser, + root, + prefix, +}: { + isBrowser: boolean; + root?: string; + prefix: string; +}): StackFrameIteratee { + return (frame: StackFrame) => { + if (!frame.filename) { + return frame; + } + + // Determine if this is a Windows frame by checking for a Windows-style prefix such as `C:\` + const isWindowsFrame = + /^[a-zA-Z]:\\/.test(frame.filename) || + // or the presence of a backslash without a forward slash (which are not allowed on Windows) + (frame.filename.includes('\\') && !frame.filename.includes('/')); + + // Check if the frame filename begins with `/` + const startsWithSlash = /^\//.test(frame.filename); + + if (isBrowser) { + if (root) { + const oldFilename = frame.filename; + if (oldFilename.indexOf(root) === 0) { + frame.filename = oldFilename.replace(root, prefix); + } + } + } else { + if (isWindowsFrame || startsWithSlash) { + const filename = isWindowsFrame + ? frame.filename + .replace(/^[a-zA-Z]:/, '') // remove Windows-style prefix + .replace(/\\/g, '/') // replace all `\\` instances with `/` + : frame.filename; + const base = root ? relative(root, filename) : basename(filename); + frame.filename = `${prefix}${base}`; + } + } + + return frame; + }; +} diff --git a/packages/core/src/metrics/aggregator.ts b/packages/core/src/metrics/aggregator.ts index 8b56d190b88a..8752d2a10df7 100644 --- a/packages/core/src/metrics/aggregator.ts +++ b/packages/core/src/metrics/aggregator.ts @@ -1,11 +1,11 @@ import type { Client, MeasurementUnit, MetricsAggregator as MetricsAggregatorBase, Primitive } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; import { updateMetricSummaryOnActiveSpan } from '../utils/spanUtils'; -import { DEFAULT_FLUSH_INTERVAL, MAX_WEIGHT, NAME_AND_TAG_KEY_NORMALIZATION_REGEX, SET_METRIC_TYPE } from './constants'; +import { DEFAULT_FLUSH_INTERVAL, MAX_WEIGHT, SET_METRIC_TYPE } from './constants'; import { captureAggregateMetrics } from './envelope'; import { METRIC_MAP } from './instance'; import type { MetricBucket, MetricType } from './types'; -import { getBucketKey, sanitizeTags } from './utils'; +import { getBucketKey, sanitizeMetricKey, sanitizeTags, sanitizeUnit } from './utils'; /** * A metrics aggregator that aggregates metrics in memory and flushes them periodically. @@ -46,6 +46,7 @@ export class MetricsAggregator implements MetricsAggregatorBase { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access this._interval.unref(); } + this._flushShift = Math.floor((Math.random() * DEFAULT_FLUSH_INTERVAL) / 1000); this._forceFlush = false; } @@ -57,13 +58,14 @@ export class MetricsAggregator implements MetricsAggregatorBase { metricType: MetricType, unsanitizedName: string, value: number | string, - unit: MeasurementUnit = 'none', + unsanitizedUnit: MeasurementUnit = 'none', unsanitizedTags: Record = {}, maybeFloatTimestamp = timestampInSeconds(), ): void { const timestamp = Math.floor(maybeFloatTimestamp); - const name = unsanitizedName.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); + const name = sanitizeMetricKey(unsanitizedName); const tags = sanitizeTags(unsanitizedTags); + const unit = sanitizeUnit(unsanitizedUnit as string); const bucketKey = getBucketKey(metricType, name, unit, tags); diff --git a/packages/core/src/metrics/browser-aggregator.ts b/packages/core/src/metrics/browser-aggregator.ts index 7d599f5aeba8..e087b45ca010 100644 --- a/packages/core/src/metrics/browser-aggregator.ts +++ b/packages/core/src/metrics/browser-aggregator.ts @@ -1,11 +1,11 @@ import type { Client, MeasurementUnit, MetricsAggregator, Primitive } from '@sentry/types'; import { timestampInSeconds } from '@sentry/utils'; import { updateMetricSummaryOnActiveSpan } from '../utils/spanUtils'; -import { DEFAULT_BROWSER_FLUSH_INTERVAL, NAME_AND_TAG_KEY_NORMALIZATION_REGEX, SET_METRIC_TYPE } from './constants'; +import { DEFAULT_BROWSER_FLUSH_INTERVAL, SET_METRIC_TYPE } from './constants'; import { captureAggregateMetrics } from './envelope'; import { METRIC_MAP } from './instance'; import type { MetricBucket, MetricType } from './types'; -import { getBucketKey, sanitizeTags } from './utils'; +import { getBucketKey, sanitizeMetricKey, sanitizeTags, sanitizeUnit } from './utils'; /** * A simple metrics aggregator that aggregates metrics in memory and flushes them periodically. @@ -32,13 +32,14 @@ export class BrowserMetricsAggregator implements MetricsAggregator { metricType: MetricType, unsanitizedName: string, value: number | string, - unit: MeasurementUnit | undefined = 'none', + unsanitizedUnit: MeasurementUnit | undefined = 'none', unsanitizedTags: Record | undefined = {}, maybeFloatTimestamp: number | undefined = timestampInSeconds(), ): void { const timestamp = Math.floor(maybeFloatTimestamp); - const name = unsanitizedName.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); + const name = sanitizeMetricKey(unsanitizedName); const tags = sanitizeTags(unsanitizedTags); + const unit = sanitizeUnit(unsanitizedUnit as string); const bucketKey = getBucketKey(metricType, name, unit, tags); @@ -79,8 +80,7 @@ export class BrowserMetricsAggregator implements MetricsAggregator { return; } - // TODO(@anonrig): Use Object.values() when we support ES6+ - const metricBuckets = Array.from(this._buckets).map(([, bucketItem]) => bucketItem); + const metricBuckets = Array.from(this._buckets.values()); captureAggregateMetrics(this._client, metricBuckets); this._buckets.clear(); diff --git a/packages/core/src/metrics/constants.ts b/packages/core/src/metrics/constants.ts index a5f3a87f57d5..ae1cd968723c 100644 --- a/packages/core/src/metrics/constants.ts +++ b/packages/core/src/metrics/constants.ts @@ -3,26 +3,6 @@ export const GAUGE_METRIC_TYPE = 'g' as const; export const SET_METRIC_TYPE = 's' as const; export const DISTRIBUTION_METRIC_TYPE = 'd' as const; -/** - * Normalization regex for metric names and metric tag names. - * - * This enforces that names and tag keys only contain alphanumeric characters, - * underscores, forward slashes, periods, and dashes. - * - * See: https://develop.sentry.dev/sdk/metrics/#normalization - */ -export const NAME_AND_TAG_KEY_NORMALIZATION_REGEX = /[^a-zA-Z0-9_/.-]+/g; - -/** - * Normalization regex for metric tag values. - * - * This enforces that values only contain words, digits, or the following - * special characters: _:/@.{}[\]$- - * - * See: https://develop.sentry.dev/sdk/metrics/#normalization - */ -export const TAG_VALUE_NORMALIZATION_REGEX = /[^\w\d\s_:/@.{}[\]$-]+/g; - /** * This does not match spec in https://develop.sentry.dev/sdk/metrics * but was chosen to optimize for the most common case in browser environments. diff --git a/packages/core/src/metrics/utils.ts b/packages/core/src/metrics/utils.ts index 7b1cf96a8462..bc1ff93e0002 100644 --- a/packages/core/src/metrics/utils.ts +++ b/packages/core/src/metrics/utils.ts @@ -1,6 +1,5 @@ import type { MeasurementUnit, MetricBucketItem, Primitive } from '@sentry/types'; import { dropUndefinedKeys } from '@sentry/utils'; -import { NAME_AND_TAG_KEY_NORMALIZATION_REGEX, TAG_VALUE_NORMALIZATION_REGEX } from './constants'; import type { MetricType } from './types'; /** @@ -54,6 +53,63 @@ export function serializeMetricBuckets(metricBucketItems: MetricBucketItem[]): s return out; } +/** + * Sanitizes units + * + * These Regex's are straight from the normalisation docs: + * https://develop.sentry.dev/sdk/metrics/#normalization + */ +export function sanitizeUnit(unit: string): string { + return unit.replace(/[^\w]+/gi, '_'); +} + +/** + * Sanitizes metric keys + * + * These Regex's are straight from the normalisation docs: + * https://develop.sentry.dev/sdk/metrics/#normalization + */ +export function sanitizeMetricKey(key: string): string { + return key.replace(/[^\w\-.]+/gi, '_'); +} + +/** + * Sanitizes metric keys + * + * These Regex's are straight from the normalisation docs: + * https://develop.sentry.dev/sdk/metrics/#normalization + */ +function sanitizeTagKey(key: string): string { + return key.replace(/[^\w\-./]+/gi, ''); +} + +/** + * These Regex's are straight from the normalisation docs: + * https://develop.sentry.dev/sdk/metrics/#normalization + */ +const tagValueReplacements: [string, string][] = [ + ['\n', '\\n'], + ['\r', '\\r'], + ['\t', '\\t'], + ['\\', '\\\\'], + ['|', '\\u{7c}'], + [',', '\\u{2c}'], +]; + +function getCharOrReplacement(input: string): string { + for (const [search, replacement] of tagValueReplacements) { + if (input === search) { + return replacement; + } + } + + return input; +} + +function sanitizeTagValue(value: string): string { + return [...value].reduce((acc, char) => acc + getCharOrReplacement(char), ''); +} + /** * Sanitizes tags. */ @@ -61,8 +117,8 @@ export function sanitizeTags(unsanitizedTags: Record): Record const tags: Record = {}; for (const key in unsanitizedTags) { if (Object.prototype.hasOwnProperty.call(unsanitizedTags, key)) { - const sanitizedKey = key.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); - tags[sanitizedKey] = String(unsanitizedTags[key]).replace(TAG_VALUE_NORMALIZATION_REGEX, ''); + const sanitizedKey = sanitizeTagKey(key); + tags[sanitizedKey] = sanitizeTagValue(String(unsanitizedTags[key])); } } return tags; diff --git a/packages/core/src/sdk.ts b/packages/core/src/sdk.ts index 3356774e14da..ebe8f9a6ca22 100644 --- a/packages/core/src/sdk.ts +++ b/packages/core/src/sdk.ts @@ -1,10 +1,10 @@ -import type { Client, ClientOptions, Hub as HubInterface } from '@sentry/types'; +import type { Client, ClientOptions } from '@sentry/types'; import { consoleSandbox, logger } from '@sentry/utils'; import { getCurrentScope } from './currentScopes'; +import { getMainCarrier, getSentryCarrier } from './asyncContext'; import { DEBUG_BUILD } from './debug-build'; import type { Hub } from './hub'; -import { getCurrentHub } from './hub'; /** A class object that can instantiate Client objects. */ export type ClientClass = new (options: O) => F; @@ -44,18 +44,22 @@ export function initAndBind( */ export function setCurrentClient(client: Client): void { getCurrentScope().setClient(client); + registerClientOnGlobalHub(client); +} - // is there a hub too? +/** + * Unfortunately, we still have to manually bind the client to the "hub" set on the global + * Sentry carrier object. This is because certain scripts (e.g. our loader script) obtain + * the client via `window.__SENTRY__.hub.getClient()`. + * + * @see {@link hub.ts getGlobalHub} + */ +function registerClientOnGlobalHub(client: Client): void { // eslint-disable-next-line deprecation/deprecation - const hub = getCurrentHub(); - if (isHubClass(hub)) { + const sentryGlobal = getSentryCarrier(getMainCarrier()) as { hub?: Hub }; + // eslint-disable-next-line deprecation/deprecation + if (sentryGlobal.hub && typeof sentryGlobal.hub.getStackTop === 'function') { // eslint-disable-next-line deprecation/deprecation - const top = hub.getStackTop(); - top.client = client; + sentryGlobal.hub.getStackTop().client = client; } } - -function isHubClass(hub: HubInterface): hub is Hub { - // eslint-disable-next-line deprecation/deprecation - return !!(hub as Hub).getStackTop; -} diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 9cd05374ddd3..396e805e7fb5 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -20,9 +20,9 @@ import { DEBUG_BUILD } from './debug-build'; import type { Scope } from './scope'; import { SessionFlusher } from './sessionflusher'; import { - addTracingExtensions, getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan, + registerSpanErrorInstrumentation, } from './tracing'; import { _getSpanForScope } from './utils/spanOnScope'; import { getRootSpan, spanToTraceContext } from './utils/spanUtils'; @@ -47,7 +47,7 @@ export class ServerRuntimeClient< */ public constructor(options: O) { // Server clients always support tracing - addTracingExtensions(); + registerSpanErrorInstrumentation(); super(options); } diff --git a/packages/core/src/span.ts b/packages/core/src/span.ts new file mode 100644 index 000000000000..405ceeddab30 --- /dev/null +++ b/packages/core/src/span.ts @@ -0,0 +1,22 @@ +import type { SpanEnvelope, SpanItem } from '@sentry/types'; +import type { Span } from '@sentry/types'; +import { createEnvelope } from '@sentry/utils'; + +/** + * Create envelope from Span item. + */ +export function createSpanEnvelope(spans: Span[]): SpanEnvelope { + const headers: SpanEnvelope[0] = { + sent_at: new Date().toISOString(), + }; + + const items = spans.map(createSpanItem); + return createEnvelope(headers, items); +} + +function createSpanItem(span: Span): SpanItem { + const spanHeaders: SpanItem[0] = { + type: 'span', + }; + return [spanHeaders, span]; +} diff --git a/packages/core/src/tracing/errors.ts b/packages/core/src/tracing/errors.ts index de184c01391f..835038175331 100644 --- a/packages/core/src/tracing/errors.ts +++ b/packages/core/src/tracing/errors.ts @@ -16,9 +16,9 @@ export function _resetErrorsInstrumented(): void { } /** - * Configures global error listeners + * Ensure that global errors automatically set the active span status. */ -export function registerErrorInstrumentation(): void { +export function registerSpanErrorInstrumentation(): void { if (errorsInstrumented) { return; } diff --git a/packages/core/src/tracing/hubextensions.ts b/packages/core/src/tracing/hubextensions.ts index 394a2be026fe..3b08bc16a319 100644 --- a/packages/core/src/tracing/hubextensions.ts +++ b/packages/core/src/tracing/hubextensions.ts @@ -1,9 +1,8 @@ -import { registerErrorInstrumentation } from './errors'; +import { registerSpanErrorInstrumentation } from './errors'; /** - * Adds tracing extensions. - * TODO (v8): Do we still need this?? Can we solve this differently? + * @deprecated Use `registerSpanErrorInstrumentation()` instead. In v9, this function will be removed. Note that you don't need to call this in Node-based SDKs or when using `browserTracingIntegration`. */ export function addTracingExtensions(): void { - registerErrorInstrumentation(); + registerSpanErrorInstrumentation(); } diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index cd9ca5ea6351..e1925c4b16d5 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -1,3 +1,6 @@ +export { registerSpanErrorInstrumentation } from './errors'; +export { setCapturedScopesOnSpan, getCapturedScopesOnSpan } from './utils'; +// eslint-disable-next-line deprecation/deprecation export { addTracingExtensions } from './hubextensions'; export { startIdleSpan, TRACING_DEFAULTS } from './idleSpan'; export { SentrySpan } from './sentrySpan'; @@ -13,6 +16,7 @@ export { startSpanManual, continueTrace, withActiveSpan, + suppressTracing, } from './trace'; export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; export { setMeasurement, timedEventsToMeasurements } from './measurement'; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index d21a567cecc5..791cae88a573 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -1,8 +1,8 @@ import type { ClientOptions, Scope, SentrySpanArguments, Span, SpanTimeInput, StartSpanOptions } from '@sentry/types'; - import { propagationContextFromHeaders } from '@sentry/utils'; import type { AsyncContextStrategy } from '../asyncContext'; import { getMainCarrier } from '../asyncContext'; + import { getClient, getCurrentScope, getIsolationScope, withScope } from '../currentScopes'; import { getAsyncContextStrategy } from '../hub'; @@ -10,13 +10,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasTracingEnabled } from '../utils/hasTracingEnabled'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; -import { - addChildSpanToSpan, - getActiveSpan, - spanIsSampled, - spanTimeInputToSeconds, - spanToJSON, -} from '../utils/spanUtils'; +import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanStart } from './logSpans'; import { sampleSpan } from './sampling'; @@ -25,6 +19,8 @@ import { SentrySpan } from './sentrySpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; import { setCapturedScopesOnSpan } from './utils'; +const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; + /** * Wraps a function with a transaction/span and finishes the span after the function is done. * The created span is the active span and will be used as parent by other spans created inside the function @@ -44,7 +40,7 @@ export function startSpan(context: StartSpanOptions, callback: (span: Span) = const spanContext = normalizeContext(context); return withScope(context.scope, scope => { - const parentSpan = _getSpanForScope(scope) as SentrySpan | undefined; + const parentSpan = getParentSpan(scope); const shouldSkipSpan = context.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan @@ -91,7 +87,7 @@ export function startSpanManual(context: StartSpanOptions, callback: (span: S const spanContext = normalizeContext(context); return withScope(context.scope, scope => { - const parentSpan = _getSpanForScope(scope) as SentrySpan | undefined; + const parentSpan = getParentSpan(scope); const shouldSkipSpan = context.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan @@ -138,9 +134,9 @@ export function startInactiveSpan(context: StartSpanOptions): Span { } const spanContext = normalizeContext(context); - const parentSpan = context.scope - ? (_getSpanForScope(context.scope) as SentrySpan | undefined) - : (getActiveSpan() as SentrySpan | undefined); + + const scope = context.scope || getCurrentScope(); + const parentSpan = getParentSpan(scope); const shouldSkipSpan = context.onlyIfParent && !parentSpan; @@ -148,8 +144,6 @@ export function startInactiveSpan(context: StartSpanOptions): Span { return new SentryNonRecordingSpan(); } - const scope = context.scope || getCurrentScope(); - return createChildSpanOrTransaction({ parentSpan, spanContext, @@ -204,6 +198,20 @@ export function withActiveSpan(span: Span | null, callback: (scope: Scope) => }); } +/** Suppress tracing in the given callback, ensuring no spans are generated inside of it. */ +export function suppressTracing(callback: () => T): T { + const acs = getAcs(); + + if (acs.suppressTracing) { + return acs.suppressTracing(callback); + } + + return withScope(scope => { + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); + return callback(); + }); +} + function createChildSpanOrTransaction({ parentSpan, spanContext, @@ -223,7 +231,7 @@ function createChildSpanOrTransaction({ let span: Span; if (parentSpan && !forceTransaction) { - span = _startChildSpan(parentSpan, spanContext); + span = _startChildSpan(parentSpan, scope, spanContext); addChildSpanToSpan(parentSpan, span); } else if (parentSpan) { // If we forced a transaction but have a parent span, make sure to continue from the parent span, not the scope @@ -237,6 +245,7 @@ function createChildSpanOrTransaction({ parentSpanId, ...spanContext, }, + scope, parentSampled, ); @@ -258,6 +267,7 @@ function createChildSpanOrTransaction({ parentSpanId, ...spanContext, }, + scope, parentSampled, ); @@ -296,22 +306,24 @@ function getAcs(): AsyncContextStrategy { return getAsyncContextStrategy(carrier); } -function _startRootSpan(spanArguments: SentrySpanArguments, parentSampled?: boolean): SentrySpan { +function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parentSampled?: boolean): SentrySpan { const client = getClient(); const options: Partial = (client && client.getOptions()) || {}; const { name = '', attributes } = spanArguments; - const [sampled, sampleRate] = sampleSpan(options, { - name, - parentSampled, - attributes, - transactionContext: { - name, - parentSampled, - }, - }); - - const transaction = new SentrySpan({ + const [sampled, sampleRate] = scope.getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] + ? [false] + : sampleSpan(options, { + name, + parentSampled, + attributes, + transactionContext: { + name, + parentSampled, + }, + }); + + const rootSpan = new SentrySpan({ ...spanArguments, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', @@ -320,30 +332,32 @@ function _startRootSpan(spanArguments: SentrySpanArguments, parentSampled?: bool sampled, }); if (sampleRate !== undefined) { - transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, sampleRate); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, sampleRate); } if (client) { - client.emit('spanStart', transaction); + client.emit('spanStart', rootSpan); } - return transaction; + return rootSpan; } /** * Creates a new `Span` while setting the current `Span.id` as `parentSpanId`. * This inherits the sampling decision from the parent span. */ -function _startChildSpan(parentSpan: Span, spanArguments: SentrySpanArguments): SentrySpan { +function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySpanArguments): Span { const { spanId, traceId } = parentSpan.spanContext(); - const sampled = spanIsSampled(parentSpan); + const sampled = scope.getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] ? false : spanIsSampled(parentSpan); - const childSpan = new SentrySpan({ - ...spanArguments, - parentSpanId: spanId, - traceId, - sampled, - }); + const childSpan = sampled + ? new SentrySpan({ + ...spanArguments, + parentSpanId: spanId, + traceId, + sampled, + }) + : new SentryNonRecordingSpan({ traceId }); addChildSpanToSpan(parentSpan, childSpan); @@ -358,3 +372,19 @@ function _startChildSpan(parentSpan: Span, spanArguments: SentrySpanArguments): return childSpan; } + +function getParentSpan(scope: Scope): SentrySpan | undefined { + const span = _getSpanForScope(scope) as SentrySpan | undefined; + + if (!span) { + return undefined; + } + + const client = getClient(); + const options: Partial = client ? client.getOptions() : {}; + if (options.parentSpanIsAlwaysRootSpan) { + return getRootSpan(span) as SentrySpan; + } + + return span; +} diff --git a/packages/core/src/transports/base.ts b/packages/core/src/transports/base.ts index b942266457f9..0ad22d3d7a15 100644 --- a/packages/core/src/transports/base.ts +++ b/packages/core/src/transports/base.ts @@ -49,10 +49,10 @@ export function createTransport( // Drop rate limited items from envelope forEachEnvelopeItem(envelope, (item, type) => { - const envelopeItemDataCategory = envelopeItemTypeToDataCategory(type); - if (isRateLimited(rateLimits, envelopeItemDataCategory)) { + const dataCategory = envelopeItemTypeToDataCategory(type); + if (isRateLimited(rateLimits, dataCategory)) { const event: Event | undefined = getEventForEnvelopeItem(item, type); - options.recordDroppedEvent('ratelimit_backoff', envelopeItemDataCategory, event); + options.recordDroppedEvent('ratelimit_backoff', dataCategory, event); } else { filteredEnvelopeItems.push(item); } diff --git a/packages/core/src/transports/offline.ts b/packages/core/src/transports/offline.ts index 2e0db450ddfd..9d30b8cb34ec 100644 --- a/packages/core/src/transports/offline.ts +++ b/packages/core/src/transports/offline.ts @@ -7,13 +7,10 @@ export const MIN_DELAY = 100; // 100 ms export const START_DELAY = 5_000; // 5 seconds const MAX_DELAY = 3.6e6; // 1 hour -function log(msg: string, error?: Error): void { - DEBUG_BUILD && logger.info(`[Offline]: ${msg}`, error); -} - export interface OfflineStore { - insert(env: Envelope): Promise; - pop(): Promise; + push(env: Envelope): Promise; + unshift(env: Envelope): Promise; + shift(): Promise; } export type CreateOfflineStore = (options: OfflineTransportOptions) => OfflineStore; @@ -53,19 +50,25 @@ type Timer = number | { unref?: () => void }; export function makeOfflineTransport( createTransport: (options: TO) => Transport, ): (options: TO & OfflineTransportOptions) => Transport { + function log(...args: unknown[]): void { + DEBUG_BUILD && logger.info('[Offline]:', ...args); + } + return options => { const transport = createTransport(options); - const store = options.createStore ? options.createStore(options) : undefined; + + if (!options.createStore) { + throw new Error('No `createStore` function was provided'); + } + + const store = options.createStore(options); let retryDelay = START_DELAY; let flushTimer: Timer | undefined; function shouldQueue(env: Envelope, error: Error, retryDelay: number): boolean | Promise { - // We don't queue Session Replay envelopes because they are: - // - Ordered and Replay relies on the response status to know when they're successfully sent. - // - Likely to fill the queue quickly and block other events from being sent. - // We also want to drop client reports because they can be generated when we retry sending events while offline. - if (envelopeContainsItemType(env, ['replay_event', 'replay_recording', 'client_report'])) { + // We want to drop client reports because they can be generated when we retry sending events while offline. + if (envelopeContainsItemType(env, ['client_report'])) { return false; } @@ -77,10 +80,6 @@ export function makeOfflineTransport( } function flushIn(delay: number): void { - if (!store) { - return; - } - if (flushTimer) { clearTimeout(flushTimer as ReturnType); } @@ -88,10 +87,14 @@ export function makeOfflineTransport( flushTimer = setTimeout(async () => { flushTimer = undefined; - const found = await store.pop(); + const found = await store.shift(); if (found) { log('Attempting to send previously queued event'); - void send(found).catch(e => { + + // We should to update the sent_at timestamp to the current time. + found[0].sent_at = new Date().toISOString(); + + void send(found, true).catch(e => { log('Failed to retry sending', e); }); } @@ -113,7 +116,15 @@ export function makeOfflineTransport( retryDelay = Math.min(retryDelay * 2, MAX_DELAY); } - async function send(envelope: Envelope): Promise { + async function send(envelope: Envelope, isRetry: boolean = false): Promise { + // We queue all replay envelopes to avoid multiple replay envelopes being sent at the same time. If one fails, we + // need to retry them in order. + if (!isRetry && envelopeContainsItemType(envelope, ['replay_event', 'replay_recording'])) { + await store.push(envelope); + flushIn(MIN_DELAY); + return {}; + } + try { const result = await transport.send(envelope); @@ -123,6 +134,8 @@ export function makeOfflineTransport( // If there's a retry-after header, use that as the next delay. if (result.headers && result.headers['retry-after']) { delay = parseRetryAfterHeader(result.headers['retry-after']); + } else if (result.headers && result.headers['x-sentry-rate-limits']) { + delay = 60_000; // 60 seconds } // If we have a server error, return now so we don't flush the queue. else if ((result.statusCode || 0) >= 400) { return result; @@ -133,10 +146,15 @@ export function makeOfflineTransport( retryDelay = START_DELAY; return result; } catch (e) { - if (store && (await shouldQueue(envelope, e as Error, retryDelay))) { - await store.insert(envelope); + if (await shouldQueue(envelope, e as Error, retryDelay)) { + // If this envelope was a retry, we want to add it to the front of the queue so it's retried again first. + if (isRetry) { + await store.unshift(envelope); + } else { + await store.push(envelope); + } flushWithBackOff(); - log('Error sending. Event queued', e as Error); + log('Error sending. Event queued.', e as Error); return {}; } else { throw e; diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index f36722b34594..f2cc6656d62d 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -1,12 +1,9 @@ import { isThenable, normalize } from '@sentry/utils'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - captureException, - setContext, - startSpanManual, -} from '.'; + import { getClient } from './currentScopes'; +import { captureException, setContext } from './exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; +import { startSpanManual } from './tracing'; interface SentryTrpcMiddlewareOptions { /** Whether to include procedure inputs in reported events. Defaults to `false`. */ diff --git a/packages/core/test/lib/integrations/rewriteframes.test.ts b/packages/core/test/lib/integrations/rewriteframes.test.ts index 17d6ed4b9c5b..4c30de3a42a6 100644 --- a/packages/core/test/lib/integrations/rewriteframes.test.ts +++ b/packages/core/test/lib/integrations/rewriteframes.test.ts @@ -1,6 +1,6 @@ import type { Event, StackFrame } from '@sentry/types'; -import { rewriteFramesIntegration } from '../../../src/integrations/rewriteframes'; +import { generateIteratee, rewriteFramesIntegration } from '../../../src/integrations/rewriteframes'; let rewriteFrames: ReturnType; let exceptionEvent: Event; @@ -11,6 +11,17 @@ let windowsExceptionEventWithoutPrefix: Event; let windowsExceptionEventWithBackslashPrefix: Event; let multipleStacktracesEvent: Event; +const originalWindow = global.window; + +beforeAll(() => { + // @ts-expect-error We need to do this because the integration has different behaviour on the browser and on the client + global.window = undefined; +}); + +afterAll(() => { + global.window = originalWindow; +}); + describe('RewriteFrames', () => { beforeEach(() => { exceptionEvent = { @@ -298,4 +309,30 @@ describe('RewriteFrames', () => { expect(rewriteFrames.processEvent?.(brokenEvent, {}, {} as any)).toEqual(brokenEvent); }); }); + + describe('generateIteratee()', () => { + describe('on the browser', () => { + it('should replace the `root` value in the filename with the `assetPrefix` value', () => { + const iteratee = generateIteratee({ + isBrowser: true, + prefix: 'my-prefix://', + root: 'http://example.com/my/path', + }); + + const result = iteratee({ filename: 'http://example.com/my/path/static/asset.js' }); + expect(result.filename).toBe('my-prefix:///static/asset.js'); + }); + + it('should replace not the `root` value in the filename with the `assetPrefix` value, if the root value is not at the beginning of the frame', () => { + const iteratee = generateIteratee({ + isBrowser: true, + prefix: 'my-prefix://', + root: '/my/path', + }); + + const result = iteratee({ filename: 'http://example.com/my/path/static/asset.js' }); + expect(result.filename).toBe('http://example.com/my/path/static/asset.js'); // unchanged + }); + }); + }); }); diff --git a/packages/core/test/lib/metrics/utils.test.ts b/packages/core/test/lib/metrics/utils.test.ts index fe96404b72ea..e25014715748 100644 --- a/packages/core/test/lib/metrics/utils.test.ts +++ b/packages/core/test/lib/metrics/utils.test.ts @@ -4,7 +4,7 @@ import { GAUGE_METRIC_TYPE, SET_METRIC_TYPE, } from '../../../src/metrics/constants'; -import { getBucketKey } from '../../../src/metrics/utils'; +import { getBucketKey, sanitizeTags } from '../../../src/metrics/utils'; describe('getBucketKey', () => { it.each([ @@ -18,4 +18,26 @@ describe('getBucketKey', () => { ])('should return', (metricType, name, unit, tags, expected) => { expect(getBucketKey(metricType, name, unit, tags)).toEqual(expected); }); + + it('should sanitize tags', () => { + const inputTags = { + 'f-oo|bar': '%$foo/', + 'foo$.$.$bar': 'blah{}', + 'foö-bar': 'snöwmän', + route: 'GET /foo', + __bar__: 'this | or , that', + 'foo/': 'hello!\n\r\t\\', + }; + + const outputTags = { + 'f-oobar': '%$foo/', + 'foo..bar': 'blah{}', + 'fo-bar': 'snöwmän', + route: 'GET /foo', + __bar__: 'this \\u{7c} or \\u{2c} that', + 'foo/': 'hello!\\n\\r\\t\\\\', + }; + + expect(sanitizeTags(inputTags)).toEqual(outputTags); + }); }); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index bbd40af742d1..aadc26856c6e 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -5,6 +5,7 @@ import { getGlobalScope, getIsolationScope, withIsolationScope, + withScope, } from '../../src'; import { Scope } from '../../src/scope'; @@ -853,40 +854,135 @@ describe('Scope', () => { }); }); -describe('isolation scope', () => { - describe('withIsolationScope()', () => { - it('will pass an isolation scope without Sentry.init()', done => { - expect.assertions(1); - withIsolationScope(scope => { - expect(scope).toBeDefined(); - done(); - }); +describe('withScope()', () => { + beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); + }); + + it('will make the passed scope the active scope within the callback', done => { + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + done(); + }); + }); + + it('will pass a scope that is different from the current active isolation scope', done => { + withScope(scope => { + expect(getIsolationScope()).not.toBe(scope); + done(); }); + }); - it('will make the passed isolation scope the active isolation scope within the callback', done => { - expect.assertions(1); - withIsolationScope(scope => { - expect(getIsolationScope()).toBe(scope); + it('will always make the inner most passed scope the current isolation scope when nesting calls', done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); done(); }); }); + }); + + it('forks the scope when not passing any scope', done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); - it('will pass an isolation scope that is different from the current active scope', done => { - expect.assertions(1); - withIsolationScope(scope => { - expect(getCurrentScope()).not.toBe(scope); + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + }); + + it('forks the scope when passing undefined', done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withScope(undefined, scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + }); + + it('sets the passed in scope as active scope', done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + const customScope = new Scope(); + + withScope(customScope, scope => { + expect(getCurrentScope()).toBe(customScope); + expect(scope).toBe(customScope); + done(); + }); + }); +}); + +describe('withIsolationScope()', () => { + beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); + }); + + it('will make the passed isolation scope the active isolation scope within the callback', done => { + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + done(); + }); + }); + + it('will pass an isolation scope that is different from the current active scope', done => { + withIsolationScope(scope => { + expect(getCurrentScope()).not.toBe(scope); + done(); + }); + }); + + it('will always make the inner most passed scope the current scope when nesting calls', done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); done(); }); }); + }); + + // Note: This is expected! In browser, we do not actually fork this + it('does not fork isolation scope when not passing any isolation scope', done => { + const isolationScope = getIsolationScope(); + + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + expect(scope).toBe(isolationScope); + done(); + }); + }); - it('will always make the inner most passed scope the current scope when nesting calls', done => { - expect.assertions(1); - withIsolationScope(_scope1 => { - withIsolationScope(scope2 => { - expect(getIsolationScope()).toBe(scope2); - done(); - }); - }); + it('does not fork isolation scope when passing undefined', done => { + const isolationScope = getIsolationScope(); + + withIsolationScope(undefined, scope => { + expect(getIsolationScope()).toBe(scope); + expect(scope).toBe(isolationScope); + done(); + }); + }); + + it('ignores passed in isolation scope', done => { + const isolationScope = getIsolationScope(); + const customIsolationScope = new Scope(); + + withIsolationScope(customIsolationScope, scope => { + expect(getIsolationScope()).toBe(isolationScope); + expect(scope).toBe(isolationScope); + done(); }); }); }); diff --git a/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts b/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts index 329d13c769af..107c039f948c 100644 --- a/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts +++ b/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts @@ -4,12 +4,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '../../../src'; -import { - SentrySpan, - addTracingExtensions, - getDynamicSamplingContextFromSpan, - startInactiveSpan, -} from '../../../src/tracing'; +import { SentrySpan, getDynamicSamplingContextFromSpan, startInactiveSpan } from '../../../src/tracing'; import { freezeDscOnSpan } from '../../../src/tracing/dynamicSamplingContext'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; @@ -19,7 +14,6 @@ describe('getDynamicSamplingContextFromSpan', () => { const client = new TestClient(options); setCurrentClient(client); client.init(); - addTracingExtensions(); }); afterEach(() => { diff --git a/packages/core/test/lib/tracing/errors.test.ts b/packages/core/test/lib/tracing/errors.test.ts index 76c4aa9609f2..3e3f85ef73b7 100644 --- a/packages/core/test/lib/tracing/errors.test.ts +++ b/packages/core/test/lib/tracing/errors.test.ts @@ -1,7 +1,7 @@ import type { HandlerDataError, HandlerDataUnhandledRejection } from '@sentry/types'; -import { addTracingExtensions, setCurrentClient, spanToJSON, startInactiveSpan, startSpan } from '../../../src'; +import { setCurrentClient, spanToJSON, startInactiveSpan, startSpan } from '../../../src'; -import { _resetErrorsInstrumented, registerErrorInstrumentation } from '../../../src/tracing/errors'; +import { _resetErrorsInstrumented, registerSpanErrorInstrumentation } from '../../../src/tracing/errors'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; const mockAddGlobalErrorInstrumentationHandler = jest.fn(); @@ -25,10 +25,6 @@ jest.mock('@sentry/utils', () => { }; }); -beforeAll(() => { - addTracingExtensions(); -}); - describe('registerErrorHandlers()', () => { beforeEach(() => { mockAddGlobalErrorInstrumentationHandler.mockClear(); @@ -41,7 +37,7 @@ describe('registerErrorHandlers()', () => { }); it('registers error instrumentation', () => { - registerErrorInstrumentation(); + registerSpanErrorInstrumentation(); expect(mockAddGlobalErrorInstrumentationHandler).toHaveBeenCalledTimes(1); expect(mockAddGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledTimes(1); expect(mockAddGlobalErrorInstrumentationHandler).toHaveBeenCalledWith(expect.any(Function)); @@ -49,7 +45,7 @@ describe('registerErrorHandlers()', () => { }); it('does not set status if transaction is not on scope', () => { - registerErrorInstrumentation(); + registerSpanErrorInstrumentation(); const transaction = startInactiveSpan({ name: 'test' })!; expect(spanToJSON(transaction).status).toBe(undefined); @@ -64,7 +60,7 @@ describe('registerErrorHandlers()', () => { }); it('sets status for transaction on scope on error', () => { - registerErrorInstrumentation(); + registerSpanErrorInstrumentation(); startSpan({ name: 'test' }, span => { mockErrorCallback({} as HandlerDataError); @@ -73,7 +69,7 @@ describe('registerErrorHandlers()', () => { }); it('sets status for transaction on scope on unhandledrejection', () => { - registerErrorInstrumentation(); + registerSpanErrorInstrumentation(); startSpan({ name: 'test' }, span => { mockUnhandledRejectionCallback({}); diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index a0d5b5bf3123..5896e80654e5 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -5,7 +5,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, SentryNonRecordingSpan, SentrySpan, - addTracingExtensions, getActiveSpan, getClient, getCurrentScope, @@ -24,7 +23,6 @@ const dsn = 'https://123@sentry.io/42'; describe('startIdleSpan', () => { beforeEach(() => { jest.useFakeTimers(); - addTracingExtensions(); getCurrentScope().clear(); getIsolationScope().clear(); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 02144174ef1f..b4b73307e174 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -3,7 +3,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, Scope, - addTracingExtensions, getCurrentScope, getGlobalScope, getIsolationScope, @@ -17,20 +16,18 @@ import { getAsyncContextStrategy } from '../../../src/hub'; import { SentrySpan, continueTrace, + registerSpanErrorInstrumentation, startInactiveSpan, startSpan, startSpanManual, + suppressTracing, withActiveSpan, } from '../../../src/tracing'; import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan'; import { _setSpanForScope } from '../../../src/utils/spanOnScope'; -import { getActiveSpan, getRootSpan, getSpanDescendants } from '../../../src/utils/spanUtils'; +import { getActiveSpan, getRootSpan, getSpanDescendants, spanIsSampled } from '../../../src/utils/spanUtils'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; -beforeAll(() => { - addTracingExtensions(); -}); - const enum Type { Sync = 'sync', Async = 'async', @@ -40,7 +37,7 @@ let client: TestClient; describe('startSpan', () => { beforeEach(() => { - addTracingExtensions(); + registerSpanErrorInstrumentation(); getCurrentScope().clear(); getIsolationScope().clear(); @@ -259,7 +256,7 @@ describe('startSpan', () => { const initialScope = getCurrentScope(); const manualScope = initialScope.clone(); - const parentSpan = new SentrySpan({ spanId: 'parent-span-id' }); + const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true }); _setSpanForScope(manualScope, parentSpan); startSpan({ name: 'GET users/[id]', scope: manualScope }, span => { @@ -416,6 +413,48 @@ describe('startSpan', () => { }); }); + describe('parentSpanIsAlwaysRootSpan', () => { + it('creates a span as child of root span if parentSpanIsAlwaysRootSpan=true', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: true, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpan({ name: 'parent span' }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + startSpan({ name: 'child span' }, childSpan => { + expect(spanToJSON(childSpan).parent_span_id).toBe(span.spanContext().spanId); + startSpan({ name: 'grand child span' }, grandChildSpan => { + expect(spanToJSON(grandChildSpan).parent_span_id).toBe(span.spanContext().spanId); + }); + }); + }); + }); + + it('does not creates a span as child of root span if parentSpanIsAlwaysRootSpan=false', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpan({ name: 'parent span' }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + startSpan({ name: 'child span' }, childSpan => { + expect(spanToJSON(childSpan).parent_span_id).toBe(span.spanContext().spanId); + startSpan({ name: 'grand child span' }, grandChildSpan => { + expect(spanToJSON(grandChildSpan).parent_span_id).toBe(childSpan.spanContext().spanId); + }); + }); + }); + }); + }); + it('samples with a tracesSampler', () => { const tracesSampler = jest.fn(() => { return true; @@ -490,7 +529,7 @@ describe('startSpan', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -516,7 +555,7 @@ describe('startSpan', () => { describe('startSpanManual', () => { beforeEach(() => { - addTracingExtensions(); + registerSpanErrorInstrumentation(); getCurrentScope().clear(); getIsolationScope().clear(); @@ -575,7 +614,7 @@ describe('startSpanManual', () => { const initialScope = getCurrentScope(); const manualScope = initialScope.clone(); - const parentSpan = new SentrySpan({ spanId: 'parent-span-id' }); + const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true }); _setSpanForScope(manualScope, parentSpan); startSpanManual({ name: 'GET users/[id]', scope: manualScope }, (span, finish) => { @@ -750,6 +789,54 @@ describe('startSpanManual', () => { }); }); + describe('parentSpanIsAlwaysRootSpan', () => { + it('creates a span as child of root span if parentSpanIsAlwaysRootSpan=true', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: true, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpanManual({ name: 'parent span' }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + startSpanManual({ name: 'child span' }, childSpan => { + expect(spanToJSON(childSpan).parent_span_id).toBe(span.spanContext().spanId); + startSpanManual({ name: 'grand child span' }, grandChildSpan => { + expect(spanToJSON(grandChildSpan).parent_span_id).toBe(span.spanContext().spanId); + grandChildSpan.end(); + }); + childSpan.end(); + }); + span.end(); + }); + }); + + it('does not creates a span as child of root span if parentSpanIsAlwaysRootSpan=false', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpanManual({ name: 'parent span' }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + startSpanManual({ name: 'child span' }, childSpan => { + expect(spanToJSON(childSpan).parent_span_id).toBe(span.spanContext().spanId); + startSpanManual({ name: 'grand child span' }, grandChildSpan => { + expect(spanToJSON(grandChildSpan).parent_span_id).toBe(childSpan.spanContext().spanId); + grandChildSpan.end(); + }); + childSpan.end(); + }); + span.end(); + }); + }); + }); + it('sets a child span reference on the parent span', () => { expect.assertions(1); startSpan({ name: 'outer' }, (outerSpan: any) => { @@ -761,7 +848,7 @@ describe('startSpanManual', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -787,7 +874,7 @@ describe('startSpanManual', () => { describe('startInactiveSpan', () => { beforeEach(() => { - addTracingExtensions(); + registerSpanErrorInstrumentation(); getCurrentScope().clear(); getIsolationScope().clear(); @@ -840,7 +927,7 @@ describe('startInactiveSpan', () => { const initialScope = getCurrentScope(); const manualScope = initialScope.clone(); - const parentSpan = new SentrySpan({ spanId: 'parent-span-id' }); + const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true }); _setSpanForScope(manualScope, parentSpan); const span = startInactiveSpan({ name: 'GET users/[id]', scope: manualScope }); @@ -994,7 +1081,65 @@ describe('startInactiveSpan', () => { }); }); - it('includes the scope at the time the span was started when finished xxx', async () => { + describe('parentSpanIsAlwaysRootSpan', () => { + it('creates a span as child of root span if parentSpanIsAlwaysRootSpan=true', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: true, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(undefined); + + startSpan({ name: 'parent span' }, span => { + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(span.spanContext().spanId); + + startSpan({ name: 'child span' }, () => { + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(span.spanContext().spanId); + + startSpan({ name: 'grand child span' }, () => { + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(span.spanContext().spanId); + }); + }); + }); + }); + + it('does not creates a span as child of root span if parentSpanIsAlwaysRootSpan=false', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: false, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(undefined); + + startSpan({ name: 'parent span' }, span => { + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(span.spanContext().spanId); + + startSpan({ name: 'child span' }, childSpan => { + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(childSpan.spanContext().spanId); + + startSpan({ name: 'grand child span' }, grandChildSpan => { + const inactiveSpan = startInactiveSpan({ name: 'inactive span' }); + expect(spanToJSON(inactiveSpan).parent_span_id).toBe(grandChildSpan.spanContext().spanId); + }); + }); + }); + }); + }); + + it('includes the scope at the time the span was started when finished', async () => { const beforeSendTransaction = jest.fn(event => event); const client = new TestClient( @@ -1048,7 +1193,7 @@ describe('startInactiveSpan', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -1069,8 +1214,6 @@ describe('startInactiveSpan', () => { describe('continueTrace', () => { beforeEach(() => { - addTracingExtensions(); - getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -1186,8 +1329,6 @@ describe('continueTrace', () => { describe('getActiveSpan', () => { beforeEach(() => { - addTracingExtensions(); - getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -1206,7 +1347,7 @@ describe('getActiveSpan', () => { }); it('works with an active span on the scope', () => { - const activeSpan = new SentrySpan({ spanId: 'aha' }); + const activeSpan = new SentrySpan({ spanId: 'aha', sampled: true }); withActiveSpan(activeSpan, () => { const span = getActiveSpan(); @@ -1215,7 +1356,7 @@ describe('getActiveSpan', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const carrier = getMainCarrier(); @@ -1235,10 +1376,6 @@ describe('getActiveSpan', () => { }); describe('withActiveSpan()', () => { - beforeAll(() => { - addTracingExtensions(); - }); - beforeEach(() => { getCurrentScope().clear(); getIsolationScope().clear(); @@ -1285,7 +1422,7 @@ describe('withActiveSpan()', () => { }); it('uses implementation from ACS, if it exists', () => { - const staticSpan = new SentrySpan({ spanId: 'aha' }); + const staticSpan = new SentrySpan({ spanId: 'aha', sampled: true }); const staticScope = new Scope(); const carrier = getMainCarrier(); @@ -1311,8 +1448,6 @@ describe('withActiveSpan()', () => { describe('span hooks', () => { beforeEach(() => { - addTracingExtensions(); - getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -1359,3 +1494,64 @@ describe('span hooks', () => { expect(endedSpans).toEqual(['span5', 'span3', 'span2', 'span1']); }); }); + +describe('suppressTracing', () => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + setAsyncContextStrategy(undefined); + + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('works for a root span', () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(span.isRecording()).toBe(false); + expect(spanIsSampled(span)).toBe(false); + }); + + it('works for a child span', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child1 = startInactiveSpan({ name: 'inner1' }); + + expect(child1.isRecording()).toBe(true); + expect(spanIsSampled(child1)).toBe(true); + + const child2 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(child2.isRecording()).toBe(false); + expect(spanIsSampled(child2)).toBe(false); + }); + }); + + it('works for a child span with forceTransaction=true', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child = suppressTracing(() => { + return startInactiveSpan({ name: 'span', forceTransaction: true }); + }); + + expect(child.isRecording()).toBe(false); + expect(spanIsSampled(child)).toBe(false); + }); + }); +}); diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index 7e87115b9cdb..2368f61d5892 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -6,7 +6,6 @@ import type { InternalBaseTransportOptions, ReplayEnvelope, ReplayEvent, - Transport, TransportMakeRequestResponse, } from '@sentry/types'; import { @@ -15,6 +14,7 @@ import { createEventEnvelopeHeaders, dsnFromString, getSdkMetadataForEnvelopeHeader, + parseEnvelope, } from '@sentry/utils'; import { createTransport } from '../../../src'; @@ -25,34 +25,40 @@ const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, ]); -const REPLAY_EVENT: ReplayEvent = { - type: 'replay_event', - timestamp: 1670837008.634, - error_ids: ['errorId'], - trace_ids: ['traceId'], - urls: ['https://example.com'], - replay_id: 'MY_REPLAY_ID', - segment_id: 3, - replay_type: 'buffer', -}; +function REPLAY_EVENT(message: string): ReplayEvent { + return { + type: 'replay_event', + timestamp: 1670837008.634, + error_ids: ['errorId'], + trace_ids: ['traceId'], + urls: ['https://example.com'], + replay_id: 'MY_REPLAY_ID', + segment_id: 3, + replay_type: 'buffer', + message, + }; +} const DSN = dsnFromString('https://public@dsn.ingest.sentry.io/1337')!; const DATA = 'nothing'; -const RELAY_ENVELOPE = createEnvelope( - createEventEnvelopeHeaders(REPLAY_EVENT, getSdkMetadataForEnvelopeHeader(REPLAY_EVENT), undefined, DSN), - [ - [{ type: 'replay_event' }, REPLAY_EVENT], +function REPLAY_ENVELOPE(message: string) { + const event = REPLAY_EVENT(message); + return createEnvelope( + createEventEnvelopeHeaders(event, getSdkMetadataForEnvelopeHeader(event), undefined, DSN), [ - { - type: 'replay_recording', - length: DATA.length, - }, - DATA, + [{ type: 'replay_event' }, event], + [ + { + type: 'replay_recording', + length: DATA.length, + }, + DATA, + ], ], - ], -); + ); +} const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [ { @@ -79,22 +85,21 @@ const transportOptions = { type MockResult = T | Error; -const createTestTransport = ( - ...sendResults: MockResult[] -): { getSendCount: () => number; baseTransport: (options: InternalBaseTransportOptions) => Transport } => { - let sendCount = 0; +const createTestTransport = (...sendResults: MockResult[]) => { + const sentEnvelopes: (string | Uint8Array)[] = []; return { - getSendCount: () => sendCount, + getSentEnvelopes: () => sentEnvelopes, + getSendCount: () => sentEnvelopes.length, baseTransport: (options: InternalBaseTransportOptions) => - createTransport(options, () => { + createTransport(options, ({ body }) => { return new Promise((resolve, reject) => { const next = sendResults.shift(); if (next instanceof Error) { reject(next); } else { - sendCount += 1; + sentEnvelopes.push(body); resolve(next as TransportMakeRequestResponse); } }); @@ -102,7 +107,7 @@ const createTestTransport = ( }; }; -type StoreEvents = ('add' | 'pop')[]; +type StoreEvents = ('push' | 'unshift' | 'shift')[]; function createTestStore(...popResults: MockResult[]): { getCalls: () => StoreEvents; @@ -113,14 +118,20 @@ function createTestStore(...popResults: MockResult[]): { return { getCalls: () => calls, store: (_: OfflineTransportOptions) => ({ - insert: async env => { + push: async env => { if (popResults.length < 30) { popResults.push(env); - calls.push('add'); + calls.push('push'); } }, - pop: async () => { - calls.push('pop'); + unshift: async env => { + if (popResults.length < 30) { + popResults.unshift(env); + calls.push('unshift'); + } + }, + shift: async () => { + calls.push('shift'); const next = popResults.shift(); if (next instanceof Error) { @@ -129,6 +140,7 @@ function createTestStore(...popResults: MockResult[]): { return next; }, + count: async () => popResults.length, }), }; } @@ -170,10 +182,10 @@ describe('makeOfflineTransport', () => { await waitUntil(() => getCalls().length == 1, 1_000); // After a successful send, the store should be checked - expect(getCalls()).toEqual(['pop']); + expect(getCalls()).toEqual(['shift']); }); - it('After successfully sending, sends further envelopes found in the store', async () => { + it('Envelopes are added after existing envelopes in the queue', async () => { const { getCalls, store } = createTestStore(ERROR_ENVELOPE); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); @@ -185,7 +197,7 @@ describe('makeOfflineTransport', () => { expect(getSendCount()).toEqual(2); // After a successful send from the store, the store should be checked again to ensure it's empty - expect(getCalls()).toEqual(['pop', 'pop']); + expect(getCalls()).toEqual(['shift', 'shift']); }); it('Queues envelope if wrapped transport throws error', async () => { @@ -208,7 +220,7 @@ describe('makeOfflineTransport', () => { expect(getSendCount()).toEqual(0); expect(queuedCount).toEqual(1); - expect(getCalls()).toEqual(['add']); + expect(getCalls()).toEqual(['push']); }); it('Does not queue envelopes if status code >= 400', async () => { @@ -242,18 +254,18 @@ describe('makeOfflineTransport', () => { const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); const result = await transport.send(ERROR_ENVELOPE); expect(result).toEqual({}); - expect(getCalls()).toEqual(['add']); + expect(getCalls()).toEqual(['push']); await waitUntil(() => getCalls().length === 3 && getSendCount() === 1, START_DELAY * 2); expect(getSendCount()).toEqual(1); - expect(getCalls()).toEqual(['add', 'pop', 'pop']); + expect(getCalls()).toEqual(['push', 'shift', 'shift']); }, START_DELAY + 2_000, ); it( - 'When enabled, sends envelopes found in store shortly after startup', + 'When flushAtStartup is enabled, sends envelopes found in store shortly after startup', async () => { const { getCalls, store } = createTestStore(ERROR_ENVELOPE, ERROR_ENVELOPE); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); @@ -267,7 +279,58 @@ describe('makeOfflineTransport', () => { await waitUntil(() => getCalls().length === 3 && getSendCount() === 2, START_DELAY * 2); expect(getSendCount()).toEqual(2); - expect(getCalls()).toEqual(['pop', 'pop', 'pop']); + expect(getCalls()).toEqual(['shift', 'shift', 'shift']); + }, + START_DELAY + 2_000, + ); + + it( + 'Unshifts envelopes on retry failure', + async () => { + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); + const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); + + await waitUntil(() => getCalls().length === 2, START_DELAY * 2); + + expect(getSendCount()).toEqual(0); + expect(getCalls()).toEqual(['shift', 'unshift']); + }, + START_DELAY + 2_000, + ); + + it( + 'Updates sent_at envelope header on retry', + async () => { + const testStartTime = new Date(); + + // Create an envelope with a sent_at header very far in the past + const env: EventEnvelope = [...ERROR_ENVELOPE]; + env[0].sent_at = new Date(2020, 1, 1).toISOString(); + + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); + const { getSentEnvelopes, baseTransport } = createTestTransport({ statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); + + await waitUntil(() => getCalls().length >= 1, START_DELAY * 2); + expect(getCalls()).toEqual(['shift']); + + // When it gets shifted out of the store, the sent_at header should be updated + const envelopes = getSentEnvelopes().map(parseEnvelope) as EventEnvelope[]; + expect(envelopes[0][0]).toBeDefined(); + const sent_at = new Date(envelopes[0][0].sent_at); + + expect(sent_at.getTime()).toBeGreaterThan(testStartTime.getTime()); }, START_DELAY + 2_000, ); @@ -289,23 +352,6 @@ describe('makeOfflineTransport', () => { expect(getCalls()).toEqual([]); }); - it('should not store Relay envelopes on send failure', async () => { - const { getCalls, store } = createTestStore(); - const { getSendCount, baseTransport } = createTestTransport(new Error()); - const queuedCount = 0; - const transport = makeOfflineTransport(baseTransport)({ - ...transportOptions, - createStore: store, - shouldStore: () => true, - }); - const result = transport.send(RELAY_ENVELOPE); - - await expect(result).rejects.toBeInstanceOf(Error); - expect(queuedCount).toEqual(0); - expect(getSendCount()).toEqual(0); - expect(getCalls()).toEqual([]); - }); - it('should not store client report envelopes on send failure', async () => { const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport(new Error()); @@ -323,6 +369,47 @@ describe('makeOfflineTransport', () => { expect(getCalls()).toEqual([]); }); + it( + 'Sends replay envelopes in order', + async () => { + const { getCalls, store } = createTestStore(REPLAY_ENVELOPE('1'), REPLAY_ENVELOPE('2')); + const { getSendCount, getSentEnvelopes, baseTransport } = createTestTransport( + new Error(), + { statusCode: 200 }, + { statusCode: 200 }, + { statusCode: 200 }, + ); + const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); + const result = await transport.send(REPLAY_ENVELOPE('3')); + + expect(result).toEqual({}); + expect(getCalls()).toEqual(['push']); + + await waitUntil(() => getCalls().length === 6 && getSendCount() === 3, START_DELAY * 5); + + expect(getSendCount()).toEqual(3); + expect(getCalls()).toEqual([ + // We're sending a replay envelope and they always get queued + 'push', + // The first envelope popped out fails to send so it gets added to the front of the queue + 'shift', + 'unshift', + // The rest of the attempts succeed + 'shift', + 'shift', + 'shift', + ]); + + const envelopes = getSentEnvelopes().map(parseEnvelope); + + // Ensure they're still in the correct order + expect((envelopes[0][1][0][1] as ErrorEvent).message).toEqual('1'); + expect((envelopes[1][1][0][1] as ErrorEvent).message).toEqual('2'); + expect((envelopes[2][1][0][1] as ErrorEvent).message).toEqual('3'); + }, + START_DELAY + 2_000, + ); + // eslint-disable-next-line jest/no-disabled-tests it.skip( 'Follows the Retry-After header', @@ -360,7 +447,7 @@ describe('makeOfflineTransport', () => { expect(getSendCount()).toEqual(2); expect(queuedCount).toEqual(0); - expect(getCalls()).toEqual(['pop', 'pop']); + expect(getCalls()).toEqual(['shift', 'shift']); }, START_DELAY * 3, ); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index 9020a6e59229..2a4850947e80 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -7,7 +7,6 @@ import { SPAN_STATUS_OK, SPAN_STATUS_UNSET, SentrySpan, - addTracingExtensions, setCurrentClient, spanToTraceHeader, startInactiveSpan, @@ -224,7 +223,6 @@ describe('getRootSpan', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); setCurrentClient(client); - addTracingExtensions(); }); it('returns the root span of a span that is a root span', () => { diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index a78a2392a429..a03496fd0fdd 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -34,7 +34,6 @@ export { getCurrentScope, getGlobalScope, getIsolationScope, - Hub, setCurrentClient, Scope, SDK_VERSION, @@ -74,6 +73,8 @@ export { startSession, captureSession, endSession, + spanToJSON, + spanToTraceHeader, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/deno/src/integrations/breadcrumbs.ts b/packages/deno/src/integrations/breadcrumbs.ts index 58c75624b90d..47953d4d7ce8 100644 --- a/packages/deno/src/integrations/breadcrumbs.ts +++ b/packages/deno/src/integrations/breadcrumbs.ts @@ -1,6 +1,13 @@ import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; -import type { Client, Event as SentryEvent, HandlerDataConsole, HandlerDataFetch, IntegrationFn } from '@sentry/types'; -import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '@sentry/types/build/types/breadcrumb'; +import type { + Client, + Event as SentryEvent, + FetchBreadcrumbData, + FetchBreadcrumbHint, + HandlerDataConsole, + HandlerDataFetch, + IntegrationFn, +} from '@sentry/types'; import { addConsoleInstrumentationHandler, addFetchInstrumentationHandler, diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 5173a0b636b0..acb921f97324 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -62,7 +62,7 @@ "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "ts-node ../../scripts/prepack.ts --bundles && npm pack ./build/npm", "circularDepCheck": "madge --circular src/index.ts", - "clean": "rimraf build sentry-feedback-*.tgz", + "clean": "rimraf build sentry-internal-feedback-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", "test": "jest", diff --git a/packages/feedback/rollup.bundle.config.mjs b/packages/feedback/rollup.bundle.config.mjs index f5794d328409..6bdaefed8748 100644 --- a/packages/feedback/rollup.bundle.config.mjs +++ b/packages/feedback/rollup.bundle.config.mjs @@ -1,15 +1,43 @@ import { makeBaseBundleConfig, makeBundleConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeBundleConfigVariants( - makeBaseBundleConfig({ - bundleType: 'addon', - entrypoints: ['src/index.ts'], - jsVersion: 'es6', - licenseTitle: '@sentry-internal/feedback', - outputFileBase: () => 'bundles/feedback', - sucrase: { - jsxPragma: 'h', - jsxFragmentPragma: 'Fragment', - }, - }), -); +export default [ + ...makeBundleConfigVariants( + makeBaseBundleConfig({ + bundleType: 'addon', + entrypoints: ['src/index.bundle.ts'], + jsVersion: 'es6', + licenseTitle: '@sentry-internal/feedback', + outputFileBase: () => 'bundles/feedback', + sucrase: { + jsxPragma: 'h', + jsxFragmentPragma: 'Fragment', + }, + }), + ), + ...makeBundleConfigVariants( + makeBaseBundleConfig({ + bundleType: 'addon', + entrypoints: ['src/screenshot/integration.ts'], + jsVersion: 'es6', + licenseTitle: '@sentry-internal/feedback', + outputFileBase: () => 'bundles/feedback-screenshot', + sucrase: { + jsxPragma: 'h', + jsxFragmentPragma: 'Fragment', + }, + }), + ), + ...makeBundleConfigVariants( + makeBaseBundleConfig({ + bundleType: 'addon', + entrypoints: ['src/modal/integration.tsx'], + jsVersion: 'es6', + licenseTitle: '@sentry-internal/feedback', + outputFileBase: () => 'bundles/feedback-modal', + sucrase: { + jsxPragma: 'h', + jsxFragmentPragma: 'Fragment', + }, + }), + ), +]; diff --git a/packages/feedback/src/core/components/Actor.css.ts b/packages/feedback/src/core/components/Actor.css.ts index 38c542d6e1b7..de295e9fc1f4 100644 --- a/packages/feedback/src/core/components/Actor.css.ts +++ b/packages/feedback/src/core/components/Actor.css.ts @@ -23,9 +23,9 @@ export function createActorStyles(): HTMLStyleElement { border-radius: var(--border-radius); cursor: pointer; - font-size: 14px; - font-weight: 600; font-family: inherit; + font-size: var(--font-size); + font-weight: 600; padding: 12px 16px; text-decoration: none; z-index: 9000; diff --git a/packages/feedback/src/core/createMainStyles.ts b/packages/feedback/src/core/createMainStyles.ts index 9eb5bd4dc2ab..ca5f138cb960 100644 --- a/packages/feedback/src/core/createMainStyles.ts +++ b/packages/feedback/src/core/createMainStyles.ts @@ -1,7 +1,7 @@ +import type { FeedbackInternalOptions } from '@sentry/types'; import { DOCUMENT } from '../constants'; -import type { FeedbackTheme, FeedbackThemes } from '../types'; -function getThemedCssVariables(theme: FeedbackTheme): string { +function getThemedCssVariables(theme: FeedbackInternalOptions['themeLight']): string { return ` --background: ${theme.background}; --background-hover: ${theme.backgroundHover}; @@ -39,7 +39,10 @@ function getThemedCssVariables(theme: FeedbackTheme): string { /** * Creates