diff --git a/.craft.yml b/.craft.yml index 7c09cb4ddd4c..99efaf3d095e 100644 --- a/.craft.yml +++ b/.craft.yml @@ -44,6 +44,9 @@ targets: - name: npm id: '@sentry/node' includeNames: /^sentry-node-\d.*\.tgz$/ + - name: npm + id: '@sentry/profiling-node' + includeNames: /^sentry-profiling-node-\d.*\.tgz$/ ## 3 Browser-based Packages - name: npm diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml index 14fe63bcf69c..848983376840 100644 --- a/.github/actions/restore-cache/action.yml +++ b/.github/actions/restore-cache/action.yml @@ -6,13 +6,13 @@ runs: steps: - name: Check dependency cache id: dep-cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 with: path: ${{ env.CACHED_DEPENDENCY_PATHS }} key: ${{ env.DEPENDENCY_CACHE_KEY }} - name: Check build cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 id: build-cache with: path: ${{ env.CACHED_BUILD_PATHS }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 43d75c60ba14..6a938d15facc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,7 @@ updates: allow: - dependency-name: "@sentry/cli" - dependency-name: "@sentry/vite-plugin" + versioning-strategy: increase commit-message: prefix: feat prefix-development: feat diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf3b7d39d360..b30882983ddb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,8 @@ concurrency: env: HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} + # WARNING: this disables cross os caching as ~ and + # github.workspace evaluate to differents paths CACHED_DEPENDENCY_PATHS: | ${{ github.workspace }}/node_modules ${{ github.workspace }}/packages/*/node_modules @@ -33,6 +35,8 @@ env: # DEPENDENCY_CACHE_KEY: can't be set here because we don't have access to yarn.lock + # WARNING: this disables cross os caching as ~ and + # github.workspace evaluate to differents paths # packages/utils/cjs and packages/utils/esm: Symlinks to the folders inside of `build`, needed for tests CACHED_BUILD_PATHS: | ${{ github.workspace }}/dev-packages/*/build @@ -45,7 +49,7 @@ env: ${{ github.workspace }}/packages/utils/esm BUILD_CACHE_KEY: ${{ github.event.inputs.commit || github.sha }} - BUILD_CACHE_TARBALL_KEY: tarball-${{ github.event.inputs.commit || github.sha }} + BUILD_PROFILING_NODE_CACHE_TARBALL_KEY: profiling-node-tarball-${{ github.event.inputs.commit || github.sha }} # GH will use the first restore-key it finds that matches # So it will start by looking for one from the same branch, else take the newest one it can find elsewhere @@ -79,7 +83,7 @@ jobs: echo "COMMIT_MESSAGE=$(git log -n 1 --pretty=format:%s $COMMIT_SHA)" >> $GITHUB_ENV - name: Determine changed packages - uses: getsentry/paths-filter@v2.11.1 + uses: dorny/paths-filter@v3.0.0 id: changed with: filters: | @@ -126,7 +130,15 @@ jobs: node: - *shared - 'packages/node/**' + - 'packages/node-experimental/**' + - 'packages/profiling-node/**' - 'dev-packages/node-integration-tests/**' + profiling_node: + - *shared + - 'packages/node/**' + - 'packages/profiling-node/**' + profiling_node_bindings: + - 'packages/profiling-node/bindings/**' deno: - *shared - *browser @@ -136,7 +148,7 @@ jobs: - name: Get PR labels id: pr-labels - uses: mydea/pr-labels-action@update-core + uses: mydea/pr-labels-action@fn/bump-node20 outputs: commit_label: '${{ env.COMMIT_SHA }}: ${{ env.COMMIT_MESSAGE }}' @@ -144,6 +156,8 @@ jobs: changed_ember: ${{ steps.changed.outputs.ember }} changed_remix: ${{ steps.changed.outputs.remix }} changed_node: ${{ steps.changed.outputs.node }} + changed_profiling_node: ${{ steps.changed.outputs.profiling_node }} + changed_profiling_node_bindings: ${{ steps.changed.outputs.profiling_node_bindings }} changed_deno: ${{ steps.changed.outputs.deno }} changed_browser: ${{ steps.changed.outputs.browser }} changed_browser_integration: ${{ steps.changed.outputs.browser_integration }} @@ -165,8 +179,7 @@ jobs: runs-on: ubuntu-20.04 timeout-minutes: 15 if: | - (needs.job_get_metadata.outputs.is_gitflow_sync == 'false' && needs.job_get_metadata.outputs.has_gitflow_label == 'false') && - (needs.job_get_metadata.outputs.changed_any_code == 'true' || github.event_name != 'pull_request') + (needs.job_get_metadata.outputs.is_gitflow_sync == 'false' && needs.job_get_metadata.outputs.has_gitflow_label == 'false') steps: - name: 'Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})' uses: actions/checkout@v4 @@ -183,7 +196,7 @@ jobs: run: echo "hash=${{ hashFiles('yarn.lock', '**/package.json') }}" >> "$GITHUB_OUTPUT" - name: Check dependency cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache_dependencies with: path: ${{ env.CACHED_DEPENDENCY_PATHS }} @@ -204,7 +217,7 @@ jobs: pull-requests: write steps: - name: PR is opened against master - uses: mshick/add-pr-comment@a65df5f64fc741e91c59b8359a4bc56e57aaf5b1 + uses: mshick/add-pr-comment@dd126dd8c253650d181ad9538d8b4fa218fc31e8 if: ${{ github.base_ref == 'master' && !startsWith(github.head_ref, 'prepare-release/') }} with: message: | @@ -213,8 +226,10 @@ jobs: job_build: name: Build needs: [job_get_metadata, job_install_deps] - runs-on: ubuntu-20.04 + runs-on: ubuntu-20.04-large-js timeout-minutes: 30 + if: | + (needs.job_get_metadata.outputs.changed_any_code == 'true' || github.event_name != 'pull_request') steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v4 @@ -225,21 +240,21 @@ jobs: with: node-version-file: 'package.json' - name: Check dependency cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 with: path: ${{ env.CACHED_DEPENDENCY_PATHS }} key: ${{ needs.job_install_deps.outputs.dependency_cache_key }} fail-on-cache-miss: true - name: Check build cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache_built_packages with: path: ${{ env.CACHED_BUILD_PATHS }} key: ${{ env.BUILD_CACHE_KEY }} - name: NX cache - uses: actions/cache@v3 + uses: actions/cache@v4 # Disable cache when: # - on release branches # - when PR has `ci-skip-cache` label or on nightly builds @@ -319,10 +334,35 @@ jobs: env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Lint source files - run: yarn lint + run: yarn lint:lerna + - name: Lint C++ files + run: yarn lint:clang - name: Validate ES5 builds run: yarn validate:es5 + job_check_format: + name: Check file formatting + needs: [job_get_metadata, job_install_deps] + timeout-minutes: 10 + runs-on: ubuntu-20.04 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v4 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: 'package.json' + - name: Check dependency cache + uses: actions/cache/restore@v4 + with: + path: ${{ env.CACHED_DEPENDENCY_PATHS }} + key: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + fail-on-cache-miss: true + - name: Check file formatting + run: yarn lint:prettier && yarn lint:biome + job_circular_dep_check: name: Circular Dependency Check needs: [job_get_metadata, job_build] @@ -346,7 +386,7 @@ jobs: job_artifacts: name: Upload Artifacts - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_compile_bindings_profiling_node] runs-on: ubuntu-20.04 # Build artifacts are only needed for releasing workflow. if: needs.job_get_metadata.outputs.is_release == 'true' @@ -363,10 +403,18 @@ jobs: uses: ./.github/actions/restore-cache env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Pack - run: yarn build:tarball + - name: Pack tarballs + # Profiling tarball is built separately as we assemble the precompiled binaries + run: yarn build:tarball --ignore @sentry/profiling-node + + - name: Restore profiling tarball + uses: actions/cache/restore@v4 + with: + key: ${{ env.BUILD_PROFILING_NODE_CACHE_TARBALL_KEY }} + path: ${{ github.workspace }}/packages/*/*.tgz + - name: Archive artifacts - uses: actions/upload-artifact@v4.0.0 + uses: actions/upload-artifact@v4 with: name: ${{ github.sha }} path: | @@ -381,7 +429,7 @@ jobs: name: Browser Unit Tests needs: [job_get_metadata, job_build] timeout-minutes: 10 - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v4 @@ -400,7 +448,9 @@ jobs: NODE_VERSION: 16 run: yarn test-ci-browser - name: Compute test coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} job_bun_unit_tests: name: Bun Unit Tests @@ -428,7 +478,9 @@ jobs: run: | yarn test-ci-bun - name: Compute test coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} job_deno_unit_tests: name: Deno Unit Tests @@ -448,7 +500,7 @@ jobs: with: node-version-file: 'package.json' - name: Set up Deno - uses: denoland/setup-deno@v1.1.3 + uses: denoland/setup-deno@v1.1.4 with: deno-version: v1.38.5 - name: Restore caches @@ -461,10 +513,13 @@ jobs: yarn build yarn test - name: Compute test coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} job_node_unit_tests: name: Node (${{ matrix.node }}) Unit Tests + if: needs.job_get_metadata.outputs.changed_node == 'true' || github.event_name != 'pull_request' needs: [job_get_metadata, job_build] timeout-minutes: 10 runs-on: ubuntu-20.04 @@ -492,7 +547,37 @@ jobs: [[ $NODE_VERSION == 8 ]] && yarn add --dev --ignore-engines --ignore-scripts --ignore-workspace-root-check ts-node@8.10.2 yarn test-ci-node - name: Compute test coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + job_profiling_node_unit_tests: + name: Node Profiling Unit Tests + needs: [job_get_metadata, job_build] + if: needs.job_get_metadata.outputs.changed_node =='true' || needs.job_get_metadata.outputs.changed_profiling_node == 'true' || github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out current commit + uses: actions/checkout@v4 + with: + ref: ${{ env.HEAD_COMMIT }} + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/setup-python@v5 + with: + python-version: '3.11.7' + - name: Restore caches + uses: ./.github/actions/restore-cache + env: + DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Build Configure node-gyp + run: yarn lerna run build:bindings:configure --scope @sentry/profiling-node + - name: Build Bindings for Current Environment + run: yarn build --scope @sentry/profiling-node + - name: Unit Test + run: yarn lerna run test --scope @sentry/profiling-node job_nextjs_integration_test: name: Nextjs (Node ${{ matrix.node }}) Tests @@ -523,7 +608,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -545,10 +630,10 @@ jobs: yarn test:integration job_browser_playwright_tests: - name: Playwright (${{ matrix.bundle }}) Tests + name: Playwright (${{ matrix.bundle }}${{ matrix.shard && format(' {0}/{1}', matrix.shard, matrix.shards) || ''}}) Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-20.04 + runs-on: ubuntu-20.04-large-js timeout-minutes: 25 strategy: fail-fast: false @@ -567,6 +652,36 @@ jobs: - bundle_tracing_es6_min - bundle_tracing_replay_es6 - bundle_tracing_replay_es6_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_es6_min + project: '' + shard: 1 + shards: 2 + - bundle: bundle_tracing_replay_es6_min + project: '' + shard: 2 + shards: 2 + - bundle: esm + project: '' + shard: 1 + shards: 3 + - bundle: esm + shard: 2 + shards: 3 + - bundle: esm + project: '' + shard: 3 + shards: 3 + exclude: + # Do not run the default chromium-only tests + - bundle: bundle_tracing_replay_es6_min + project: 'chromium' + - bundle: esm + project: 'chromium' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) @@ -587,7 +702,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -604,9 +719,8 @@ jobs: - name: Run Playwright tests env: PW_BUNDLE: ${{ matrix.bundle }} - run: | - cd dev-packages/browser-integration-tests - yarn test:ci + working-directory: dev-packages/browser-integration-tests + run: yarn test:ci${{ matrix.project && format(' --project={0}', matrix.project) || '' }}${{ matrix.shard && format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }} job_browser_loader_tests: name: Playwright Loader (${{ matrix.bundle }}) Tests @@ -644,7 +758,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -669,7 +783,7 @@ jobs: name: Browser (${{ matrix.browser }}) Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_browser == 'true' || github.event_name != 'pull_request' - runs-on: ubuntu-20.04 + runs-on: ubuntu-20.04-large-js timeout-minutes: 20 strategy: fail-fast: false @@ -796,7 +910,7 @@ jobs: yarn test job_remix_integration_tests: - name: Remix v${{ matrix.remix }} (Node ${{ matrix.node }}) Tests + name: Remix v${{ matrix.remix }} (Node ${{ matrix.node }}) ${{ matrix.tracingIntegration && 'TracingIntegration'}} Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_remix == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-20.04 @@ -812,6 +926,8 @@ jobs: remix: 1 - node: 16 remix: 1 + - tracingIntegration: true + remix: 2 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v4 @@ -829,17 +945,24 @@ jobs: env: NODE_VERSION: ${{ matrix.node }} REMIX_VERSION: ${{ matrix.remix }} + TRACING_INTEGRATION: ${{ matrix.tracingIntegration }} run: | cd packages/remix yarn test:integration:ci job_e2e_prepare: name: Prepare E2E tests - if: + # We want to run this if: + # - The build job was successful, not skipped + # - AND if the profiling node bindings were either successful or skipped + # AND if this is not a PR from a fork or dependabot + if: | + always() && needs.job_build.result == 'success' && + (needs.job_compile_bindings_profiling_node.result == 'success' || needs.job_compile_bindings_profiling_node.result == 'skipped') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' - needs: [job_get_metadata, job_build] - runs-on: ubuntu-20.04 + needs: [job_get_metadata, job_build, job_compile_bindings_profiling_node] + runs-on: ubuntu-20.04-large-js timeout-minutes: 15 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) @@ -855,25 +978,52 @@ jobs: env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} - name: NX cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 with: path: .nxcache key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} # On develop branch, we want to _store_ the cache (so it can be used by other branches), but never _restore_ from it restore-keys: ${{ env.NX_CACHE_RESTORE_KEYS }} - name: Build tarballs - run: yarn build:tarball + run: yarn build:tarball --ignore @sentry/profiling-node + + # Rebuild profiling by compiling TS and pull the precompiled binary artifacts + - name: Build Profiling Node + if: | + (needs.job_get_metadata.outputs.changed_profiling_node_bindings == 'true') || + (needs.job_get_metadata.outputs.is_release == 'true') || + (github.event_name != 'pull_request') + run: yarn lerna run build:lib --scope @sentry/profiling-node + + - name: Extract Profiling Node Prebuilt Binaries + # @TODO: v4 breaks convenient merging of same name artifacts + # https://github.com/actions/upload-artifact/issues/478 + if: | + (needs.job_get_metadata.outputs.changed_profiling_node_bindings == 'true') || + (github.event_name != 'pull_request') + uses: actions/download-artifact@v3 + with: + name: profiling-node-binaries-${{ github.sha }} + path: ${{ github.workspace }}/packages/profiling-node/lib/ + + - name: Build Profiling tarball + run: yarn build:tarball --scope @sentry/profiling-node + # End rebuild profiling + - name: Stores tarballs in cache - uses: actions/cache/save@v3 + uses: actions/cache/save@v4 with: path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} + key: ${{ env.BUILD_PROFILING_NODE_CACHE_TARBALL_KEY }} job_e2e_tests: name: E2E ${{ matrix.label || matrix.test-application }} Test # We only run E2E tests for non-fork PRs because the E2E tests require secrets to work and they can't be accessed from forks # Dependabot PRs sadly also don't have access to secrets, so we skip them as well + # We need to add the `always()` check here because the previous step has this as well :( + # See: https://github.com/actions/runner/issues/2205 if: + always() && needs.job_e2e_prepare.result == 'success' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' needs: [job_get_metadata, job_build, job_e2e_prepare] @@ -893,6 +1043,7 @@ jobs: matrix: test-application: [ + 'angular-17', 'cloudflare-astro', 'node-express-app', 'create-react-app', @@ -913,6 +1064,8 @@ jobs: 'node-experimental-fastify-app', 'node-hapi-app', 'node-exports-test-app', + 'node-profiling', + 'vue-3' ] build-command: - false @@ -954,11 +1107,39 @@ jobs: env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + # Rebuild profiling by compiling TS and pull the precompiled binary artifacts + - name: Build Profiling Node + if: | + (needs.job_get_metadata.outputs.changed_profiling_node_bindings == 'true') || + (needs.job_get_metadata.outputs.is_release == 'true') || + (github.event_name != 'pull_request') + run: yarn lerna run build:lib --scope @sentry/profiling-node + + - name: Extract Profiling Node Prebuilt Binaries + # @TODO: v4 breaks convenient merging of same name artifacts + # https://github.com/actions/upload-artifact/issues/478 + if: | + (needs.job_get_metadata.outputs.changed_profiling_node_bindings == 'true') || + (github.event_name != 'pull_request') + uses: actions/download-artifact@v3 + with: + name: profiling-node-binaries-${{ github.sha }} + path: ${{ github.workspace }}/packages/profiling-node/lib/ + + - name: Build Profiling tarball + run: yarn build:tarball --scope @sentry/profiling-node + + - name: Install esbuild + if: ${{ matrix.test-application == 'node-profiling' }} + working-directory: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }} + run: yarn add esbuild@0.19.11 + # End rebuild profiling + - name: Restore tarball cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 with: path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} + key: ${{ env.BUILD_PROFILING_NODE_CACHE_TARBALL_KEY }} - name: Get node version id: versions @@ -1000,11 +1181,13 @@ jobs: needs: [ job_build, + job_compile_bindings_profiling_node, job_browser_build_tests, job_browser_unit_tests, job_bun_unit_tests, job_deno_unit_tests, job_node_unit_tests, + job_profiling_node_unit_tests, job_nextjs_integration_test, job_node_integration_tests, job_browser_playwright_tests, @@ -1014,6 +1197,7 @@ jobs: job_e2e_tests, job_artifacts, job_lint, + job_check_format, job_circular_dep_check, ] # Always run this, even if a dependent job failed @@ -1060,8 +1244,245 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Upload results - uses: actions/upload-artifact@v4.0.0 + uses: actions/upload-artifact@v4 if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: name: ${{ steps.process.outputs.artifactName }} path: ${{ steps.process.outputs.artifactPath }} + + job_compile_bindings_profiling_node: + name: Compile & Test Profiling Bindings (v${{ matrix.node }}) ${{ matrix.target_platform || matrix.os }}, ${{ matrix.node || matrix.container }}, ${{ matrix.arch || matrix.container }}, ${{ contains(matrix.container, 'alpine') && 'musl' || 'glibc' }} + needs: [job_get_metadata, job_install_deps, job_build] + # Compiling bindings can be very slow (especially on windows), so only run precompile + # Skip precompile unless we are on a release branch as precompile slows down CI times. + if: | + (needs.job_get_metadata.outputs.changed_profiling_node_bindings == 'true') || + (needs.job_get_metadata.outputs.is_release == 'true') || + (github.event_name != 'pull_request') + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + # x64 glibc + - os: ubuntu-20.04 + node: 16 + - os: ubuntu-20.04 + node: 18 + - os: ubuntu-20.04 + node: 20 + + # x64 musl + - os: ubuntu-20.04 + container: node:16-alpine3.16 + node: 16 + - os: ubuntu-20.04 + container: node:18-alpine3.17 + node: 18 + - os: ubuntu-20.04 + container: node:20-alpine3.17 + node: 20 + + # arm64 glibc + - os: ubuntu-20.04 + arch: arm64 + node: 16 + - os: ubuntu-20.04 + arch: arm64 + node: 18 + - os: ubuntu-20.04 + arch: arm64 + node: 20 + + # arm64 musl + - os: ubuntu-20.04 + container: node:16-alpine3.16 + arch: arm64 + node: 16 + - os: ubuntu-20.04 + arch: arm64 + container: node:18-alpine3.17 + node: 18 + - os: ubuntu-20.04 + arch: arm64 + container: node:20-alpine3.17 + node: 20 + + # macos x64 + - os: macos-11 + node: 16 + arch: x64 + - os: macos-11 + node: 18 + arch: x64 + - os: macos-11 + node: 20 + arch: x64 + + # macos arm64 + - os: macos-12 + arch: arm64 + node: 16 + target_platform: darwin + + - os: macos-12 + arch: arm64 + node: 18 + target_platform: darwin + + - os: macos-12 + arch: arm64 + node: 20 + target_platform: darwin + + # windows x64 + - os: windows-2022 + node: 16 + arch: x64 + + - os: windows-2022 + node: 18 + arch: x64 + + - os: windows-2022 + node: 20 + arch: x64 + steps: + - name: Setup (alpine) + if: contains(matrix.container, 'alpine') + run: | + apk add --no-cache build-base git g++ make curl python3 + ln -sf python3 /usr/bin/python + + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v4 + with: + ref: ${{ env.HEAD_COMMIT }} + + - name: Restore dependency cache + uses: actions/cache/restore@v4 + id: restore-dependencies + with: + path: ${{ env.CACHED_DEPENDENCY_PATHS }} + key: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + enableCrossOsArchive: true + + - name: Restore build cache + uses: actions/cache/restore@v4 + id: restore-build + with: + path: ${{ env.CACHED_BUILD_PATHS }} + key: ${{ needs.job_build.outputs.dependency_cache_key }} + enableCrossOsArchive: true + + - name: Configure safe directory + run: | + git config --global --add safe.directory "*" + + - name: Install yarn + run: npm i -g yarn@1.22.19 --force + + - name: Increase yarn network timeout on Windows + if: contains(matrix.os, 'windows') + run: yarn config set network-timeout 600000 -g + + - name: Setup python + uses: actions/setup-python@v5 + if: ${{ !contains(matrix.container, 'alpine') }} + id: python-setup + with: + python-version: '3.8.10' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Install Dependencies + if: steps.restore-dependencies.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile --ignore-engines --ignore-scripts + + - name: Setup (arm64| ${{ contains(matrix.container, 'alpine') && 'musl' || 'glibc' }}) + if: matrix.arch == 'arm64' && !contains(matrix.container, 'alpine') && matrix.target_platform != 'darwin' + run: | + sudo apt-get update + sudo apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + + - name: Setup Musl + if: contains(matrix.container, 'alpine') + run: | + cd packages/profiling-node + curl -OL https://musl.cc/aarch64-linux-musl-cross.tgz + tar -xzvf aarch64-linux-musl-cross.tgz + $(pwd)/aarch64-linux-musl-cross/bin/aarch64-linux-musl-gcc --version + + # configure node-gyp + - name: Configure node-gyp + if: matrix.arch != 'arm64' + run: | + cd packages/profiling-node + yarn build:bindings:configure + + - name: Configure node-gyp (arm64, ${{ contains(matrix.container, 'alpine') && 'musl' || 'glibc' }}) + if: matrix.arch == 'arm64' && matrix.target_platform != 'darwin' + run: | + cd packages/profiling-node + yarn build:bindings:configure:arm64 + + - name: Configure node-gyp (arm64, darwin) + if: matrix.arch == 'arm64' && matrix.target_platform == 'darwin' + run: | + cd packages/profiling-node + yarn build:bindings:configure:arm64 + + # build bindings + - name: Build Bindings + if: matrix.arch != 'arm64' + run: | + yarn lerna run build:bindings --scope @sentry/profiling-node + + - name: Build Bindings (arm64, ${{ contains(matrix.container, 'alpine') && 'musl' || 'glibc' }}) + if: matrix.arch == 'arm64' && contains(matrix.container, 'alpine') && matrix.target_platform != 'darwin' + run: | + cd packages/profiling-node + CC=$(pwd)/aarch64-linux-musl-cross/bin/aarch64-linux-musl-gcc \ + CXX=$(pwd)/aarch64-linux-musl-cross/bin/aarch64-linux-musl-g++ \ + BUILD_ARCH=arm64 \ + yarn build:bindings + + - name: Build Bindings (arm64, ${{ contains(matrix.container, 'alpine') && 'musl' || 'glibc' }}) + if: matrix.arch == 'arm64' && !contains(matrix.container, 'alpine') && matrix.target_platform != 'darwin' + run: | + cd packages/profiling-node + CC=aarch64-linux-gnu-gcc \ + CXX=aarch64-linux-gnu-g++ \ + BUILD_ARCH=arm64 \ + yarn build:bindings:arm64 + + - name: Build Bindings (arm64, darwin) + if: matrix.arch == 'arm64' && matrix.target_platform == 'darwin' + run: | + cd packages/profiling-node + BUILD_PLATFORM=darwin \ + BUILD_ARCH=arm64 \ + yarn build:bindings:arm64 + + - name: Build Monorepo + if: steps.restore-build.outputs.cache-hit != 'true' + run: yarn build --scope @sentry/profiling-node + + - name: Test Bindings + if: matrix.arch != 'arm64' + run: | + yarn lerna run test --scope @sentry/profiling-node + + - name: Archive Binary + # @TODO: v4 breaks convenient merging of same name artifacts + # https://github.com/actions/upload-artifact/issues/478 + uses: actions/upload-artifact@v3 + with: + name: profiling-node-binaries-${{ github.sha }} + path: | + ${{ github.workspace }}/packages/profiling-node/lib/*.node diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 72dcd46d238d..9a856a7ce034 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -31,7 +31,7 @@ jobs: with: node-version-file: 'package.json' - name: Check canary cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ${{ github.workspace }}/packages/*/*.tgz @@ -100,7 +100,7 @@ jobs: node-version-file: 'package.json' - name: Restore canary cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 with: path: | ${{ github.workspace }}/packages/*/*.tgz diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 1207c9fbf3fd..d499c12d661b 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -23,7 +23,7 @@ concurrency: jobs: flaky-detector: - runs-on: ubuntu-20.04 + runs-on: ubuntu-20.04-large-js timeout-minutes: 60 name: 'Check tests for flakiness' # Also skip if PR is from master -> develop @@ -40,7 +40,7 @@ jobs: run: yarn install --ignore-engines --frozen-lockfile - name: NX cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 with: path: .nxcache key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} @@ -55,7 +55,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -71,7 +71,7 @@ jobs: run: npx playwright install-deps - name: Determine changed tests - uses: getsentry/paths-filter@v2.11.1 + uses: dorny/paths-filter@v3.0.0 id: changed with: list-files: json diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index ca7f27d1f5f9..612d2802a580 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -47,7 +47,7 @@ jobs: # https://github.com/marketplace/actions/auto-approve - name: Auto approve PR if: steps.open-pr.outputs.pr_number != '' - uses: hmarr/auto-approve-action@v3 + uses: hmarr/auto-approve-action@v4 with: pull-request-number: ${{ steps.open-pr.outputs.pr_number }} review-message: 'Auto approved automated PR' diff --git a/CHANGELOG.md b/CHANGELOG.md index f7548880d5f7..75a70e414df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,73 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.100.0 + +### Important Changes + +#### Deprecations + +This release includes some deprecations. For more details please look at our +[migration guide](https://github.com/getsentry/sentry-javascript/blob/develop/MIGRATION.md). + +The deprecation most likely to affect you is the one of `BrowserTracing`. Instead of `new BrowserTracing()`, you should +now use `browserTracingIntegration()`, which will also handle framework-specific instrumentation out of the box for +you - no need to pass a custom `routingInstrumentation` anymore. For `@sentry/react`, we expose dedicated integrations +for the different react-router versions: + +- `reactRouterV6BrowserTracingIntegration()` +- `reactRouterV5BrowserTracingIntegration()` +- `reactRouterV4BrowserTracingIntegration()` +- `reactRouterV3BrowserTracingIntegration()` + +See the +[migration guide](https://github.com/getsentry/sentry-javascript/blob/develop/MIGRATION.md#depreacted-browsertracing-integration) +for details. + +- feat(angular): Export custom `browserTracingIntegration()` (#10353) +- feat(browser): Deprecate `BrowserTracing` integration (#10493) +- feat(browser): Export `browserProfilingIntegration` (#10438) +- feat(bun): Export `bunServerIntegration()` (#10439) +- feat(nextjs): Add `browserTracingIntegration` (#10397) +- feat(react): Add `reactRouterV3BrowserTracingIntegration` for react router v3 (#10489) +- feat(react): Add `reactRouterV4/V5BrowserTracingIntegration` for react router v4 & v5 (#10488) +- feat(react): Add `reactRouterV6BrowserTracingIntegration` for react router v6 & v6.4 (#10491) +- feat(remix): Add custom `browserTracingIntegration` (#10442) +- feat(node): Expose functional integrations to replace classes (#10356) +- feat(vercel-edge): Replace `WinterCGFetch` with `winterCGFetchIntegration` (#10436) +- feat: Deprecate non-callback based `continueTrace` (#10301) +- feat(vue): Deprecate `new VueIntegration()` (#10440) +- feat(vue): Implement vue `browserTracingIntegration()` (#10477) +- feat(sveltekit): Add custom `browserTracingIntegration()` (#10450) + +#### Profiling Node + +`@sentry/profiling-node` has been ported into the monorepo. Future development for it will happen here! + +- pkg(profiling-node): port profiling-node repo to monorepo (#10151) + +### Other Changes + +- feat: Export `setHttpStatus` from all packages (#10475) +- feat(bundles): Add pluggable integrations on CDN to `Sentry` namespace (#10452) +- feat(core): Pass `name` & `attributes` to `tracesSampler` (#10426) +- feat(feedback): Add `system-ui` to start of font family (#10464) +- feat(node-experimental): Add koa integration (#10451) +- feat(node-experimental): Update opentelemetry packages (#10456) +- feat(node-experimental): Update tracing integrations to functional style (#10443) +- feat(replay): Bump `rrweb` to 2.10.0 (#10445) +- feat(replay): Enforce masking of credit card fields (#10472) +- feat(utils): Add `propagationContextFromHeaders` (#10313) +- fix: Make `startSpan`, `startSpanManual` and `startInactiveSpan` pick up the scopes at time of creation instead of + termination (#10492) +- fix(feedback): Fix logo color when colorScheme is "system" (#10465) +- fix(nextjs): Do not report redirects and notFound calls as errors in server actions (#10474) +- fix(nextjs): Fix navigation tracing on app router (#10502) +- fix(nextjs): Apply server action data to correct isolation scope (#10514) +- fix(node): Use normal `require` call to import Undici (#10388) +- ref(nextjs): Remove internally used deprecated APIs (#10453) +- ref(vue): use startInactiveSpan in tracing mixin (#10406) + ## 7.99.0 ### Important Changes diff --git a/MIGRATION.md b/MIGRATION.md index f92022cc4690..e315bf77ccfd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -10,6 +10,129 @@ npx @sentry/migr8@latest This will let you select which updates to run, and automatically update your code. Make sure to still review all code changes! +## Depreacted `BrowserTracing` integration + +The `BrowserTracing` integration, together with the custom routing instrumentations passed to it, are deprecated in v8. +Instead, you should use `Sentry.browserTracingIntegration()`. + +Package-specific browser tracing integrations are available directly. In most cases, there is a single integration +provided for each package, which will make sure to set up performance tracing correctly for the given SDK. For react, we +provide multiple integrations to cover different router integrations: + +### `@sentry/browser`, `@sentry/svelte`, `@sentry/gatsby` + +```js +import * as Sentry from '@sentry/browser'; + +Sentry.init({ + integrations: [Sentry.browserTracingIntegration()], +}); +``` + +### `@sentry/react` + +```js +import * as Sentry from '@sentry/react'; + +Sentry.init({ + integrations: [ + // No react router + Sentry.browserTracingIntegration(), + // OR, if you are using react router, instead use one of the following: + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + stripBasename, + }), + Sentry.reactRouterV5BrowserTracingIntegration({ + history, + }), + Sentry.reactRouterV4BrowserTracingIntegration({ + history, + }), + Sentry.reactRouterV3BrowserTracingIntegration({ + history, + routes, + match, + }), + ], +}); +``` + +### `@sentry/vue` + +```js +import * as Sentry from '@sentry/vue'; + +Sentry.init({ + integrations: [ + Sentry.browserTracingIntegration({ + // pass router in, if applicable + router, + }), + ], +}); +``` + +### `@sentry/angular` & `@sentry/angular-ivy` + +```js +import * as Sentry from '@sentry/angular'; + +Sentry.init({ + integrations: [Sentry.browserTracingIntegration()], +}); + +// You still need to add the Trace Service like before! +``` + +### `@sentry/remix` + +```js +import * as Sentry from '@sentry/remix'; + +Sentry.init({ + integrations: [ + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, + }), + ], +}); +``` + +### `@sentry/nextjs`, `@sentry/astro`, `@sentry/sveltekit` + +Browser tracing is automatically set up for you in these packages. If you need to customize the options, you can do it +like this: + +```js +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + integrations: [ + Sentry.browserTracingIntegration({ + // add custom options here + }), + ], +}); +``` + +### `@sentry/ember` + +Browser tracing is automatically set up for you. You can configure it as before through configuration. + +## Deprecated `transactionContext` passed to `tracesSampler` + +Instead of an `transactionContext` being passed to the `tracesSampler` callback, the callback will directly receive +`name` and `attributes` going forward. You can use these to make your sampling decisions, while `transactionContext` +will be removed in v8. Note that the `attributes` are only the attributes at span creation time, and some attributes may +only be set later during the span lifecycle (and thus not be available during sampling). + ## Deprecate using `getClient()` to check if the SDK was initialized In v8, `getClient()` will stop returning `undefined` if `Sentry.init()` was not called. For cases where this may be used @@ -34,34 +157,46 @@ integrations from the `Integrations.XXX` hash, is deprecated in favor of using t The following list shows how integrations should be migrated: -| Old | New | Packages | -| ------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `new InboundFilters()` | `inboundFiltersIntegration()` | `@sentry/core`, `@sentry/browser`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | -| `new FunctionToString()` | `functionToStringIntegration()` | `@sentry/core`, `@sentry/browser`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | -| `new LinkedErrors()` | `linkedErrorsIntegration()` | `@sentry/core`, `@sentry/browser`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | -| `new ModuleMetadata()` | `moduleMetadataIntegration()` | `@sentry/core`, `@sentry/browser` | -| `new RequestData()` | `requestDataIntegration()` | `@sentry/core`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | -| `new Wasm() ` | `wasmIntegration()` | `@sentry/wasm` | -| `new Replay()` | `replayIntegration()` | `@sentry/browser` | -| `new ReplayCanvas()` | `replayCanvasIntegration()` | `@sentry/browser` | -| `new Feedback()` | `feedbackIntegration()` | `@sentry/browser` | -| `new CaptureConsole()` | `captureConsoleIntegration()` | `@sentry/integrations` | -| `new Debug()` | `debugIntegration()` | `@sentry/integrations` | -| `new Dedupe()` | `dedupeIntegration()` | `@sentry/browser`, `@sentry/integrations`, `@sentry/deno` | -| `new ExtraErrorData()` | `extraErrorDataIntegration()` | `@sentry/integrations` | -| `new ReportingObserver()` | `reportingObserverIntegration()` | `@sentry/integrations` | -| `new RewriteFrames()` | `rewriteFramesIntegration()` | `@sentry/integrations` | -| `new SessionTiming()` | `sessionTimingIntegration()` | `@sentry/integrations` | -| `new HttpClient()` | `httpClientIntegration()` | `@sentry/integrations` | -| `new ContextLines()` | `contextLinesIntegration()` | `@sentry/browser`, `@sentry/deno` | -| `new Breadcrumbs()` | `breadcrumbsIntegration()` | `@sentry/browser`, `@sentry/deno` | -| `new GlobalHandlers()` | `globalHandlersIntegration()` | `@sentry/browser` , `@sentry/deno` | -| `new HttpContext()` | `httpContextIntegration()` | `@sentry/browser` | -| `new TryCatch()` | `browserApiErrorsIntegration()` | `@sentry/browser`, `@sentry/deno` | -| `new VueIntegration()` | `vueIntegration()` | `@sentry/vue` | -| `new DenoContext()` | `denoContextIntegration()` | `@sentry/deno` | -| `new DenoCron()` | `denoCronIntegration()` | `@sentry/deno` | -| `new NormalizePaths()` | `normalizePathsIntegration()` | `@sentry/deno` | +| Old | New | Packages | +| ---------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `new BrowserTracing()` | `browserTracingIntegration()` | `@sentry/browser` | +| `new InboundFilters()` | `inboundFiltersIntegration()` | `@sentry/core`, `@sentry/browser`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | +| `new FunctionToString()` | `functionToStringIntegration()` | `@sentry/core`, `@sentry/browser`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | +| `new LinkedErrors()` | `linkedErrorsIntegration()` | `@sentry/core`, `@sentry/browser`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | +| `new ModuleMetadata()` | `moduleMetadataIntegration()` | `@sentry/core`, `@sentry/browser` | +| `new RequestData()` | `requestDataIntegration()` | `@sentry/core`, `@sentry/node`, `@sentry/deno`, `@sentry/bun`, `@sentry/vercel-edge` | +| `new Wasm() ` | `wasmIntegration()` | `@sentry/wasm` | +| `new Replay()` | `replayIntegration()` | `@sentry/browser` | +| `new ReplayCanvas()` | `replayCanvasIntegration()` | `@sentry/browser` | +| `new Feedback()` | `feedbackIntegration()` | `@sentry/browser` | +| `new CaptureConsole()` | `captureConsoleIntegration()` | `@sentry/integrations` | +| `new Debug()` | `debugIntegration()` | `@sentry/integrations` | +| `new Dedupe()` | `dedupeIntegration()` | `@sentry/browser`, `@sentry/integrations`, `@sentry/deno` | +| `new ExtraErrorData()` | `extraErrorDataIntegration()` | `@sentry/integrations` | +| `new ReportingObserver()` | `reportingObserverIntegration()` | `@sentry/integrations` | +| `new RewriteFrames()` | `rewriteFramesIntegration()` | `@sentry/integrations` | +| `new SessionTiming()` | `sessionTimingIntegration()` | `@sentry/integrations` | +| `new HttpClient()` | `httpClientIntegration()` | `@sentry/integrations` | +| `new ContextLines()` | `contextLinesIntegration()` | `@sentry/integrations`, `@sentry/node`, `@sentry/deno`, `@sentry/bun` | +| `new Breadcrumbs()` | `breadcrumbsIntegration()` | `@sentry/browser`, `@sentry/deno` | +| `new GlobalHandlers()` | `globalHandlersIntegration()` | `@sentry/browser` , `@sentry/deno` | +| `new HttpContext()` | `httpContextIntegration()` | `@sentry/browser` | +| `new TryCatch()` | `browserApiErrorsIntegration()` | `@sentry/browser`, `@sentry/deno` | +| `new VueIntegration()` | `vueIntegration()` | `@sentry/vue` | +| `new DenoContext()` | `denoContextIntegration()` | `@sentry/deno` | +| `new DenoCron()` | `denoCronIntegration()` | `@sentry/deno` | +| `new NormalizePaths()` | `normalizePathsIntegration()` | `@sentry/deno` | +| `new Console()` | `consoleIntegration()` | `@sentry/node` | +| `new Context()` | `nodeContextIntegration()` | `@sentry/node` | +| `new Modules()` | `modulesIntegration()` | `@sentry/node` | +| `new OnUncaughtException()` | `onUncaughtExceptionIntegration()` | `@sentry/node` | +| `new OnUnhandledRejection()` | `onUnhandledRejectionIntegration()` | `@sentry/node` | +| `new LocalVariables()` | `localVariablesIntegration()` | `@sentry/node` | +| `new Spotlight()` | `spotlightIntegration()` | `@sentry/node` | +| `new Anr()` | `anrIntegration()` | `@sentry/node` | +| `new Hapi()` | `hapiIntegration()` | `@sentry/node` | +| `new Undici()` | `nativeNodeFetchIntegration()` | `@sentry/node` | +| `new Http()` | `httpIntegration()` | `@sentry/node` | ## Deprecate `hub.bindClient()` and `makeMain()` @@ -179,6 +314,32 @@ be removed. Instead, use the new performance APIs: You can [read more about the new performance APIs here](./docs/v8-new-performance-apis.md). +## Deprecate variations of `Sentry.continueTrace()` + +The version of `Sentry.continueTrace()` which does not take a callback argument will be removed in favor of the version +that does. Additionally, the callback argument will not receive an argument with the next major version. + +Use `Sentry.continueTrace()` as follows: + +```ts +app.get('/your-route', req => { + Sentry.withIsolationScope(isolationScope => { + Sentry.continueTrace( + { + sentryTrace: req.headers.get('sentry-trace'), + baggage: req.headers.get('baggage'), + }, + () => { + // All events recorded in this callback will be associated with the incoming trace. For example: + Sentry.startSpan({ name: '/my-route' }, async () => { + await doExpensiveWork(); + }); + }, + ); + }); +}); +``` + ## Deprecate `Sentry.lastEventId()` and `hub.lastEventId()` `Sentry.lastEventId()` sometimes causes race conditions, so we are deprecating it in favour of the `beforeSend` diff --git a/biome.json b/biome.json index ff5a6ac17286..ccb69e4746db 100644 --- a/biome.json +++ b/biome.json @@ -31,7 +31,20 @@ "noDelete": "off" } }, - "ignore": [".vscode/*", "**/*.json", ".next/**/*", ".svelte-kit/**/*"] + "ignore": [ + ".vscode/*", + "**/*.json", + ".next/**/*", + ".svelte-kit/**/*", + "**/fixtures/*/*.json", + "**/*.min.js", + ".next/**", + ".svelte-kit/**", + ".angular/**", + "angular.json", + "ember/instance-initializers/**", + "ember/types.d.ts" + ] }, "files": { "ignoreUnknown": true @@ -51,7 +64,12 @@ "**/fixtures/*/*.json", "**/*.min.js", ".next/**", - ".svelte-kit/**" + ".svelte-kit/**", + ".angular/**", + "angular.json", + "**/profiling-node/lib/**", + "ember/instance-initializers/**", + "ember/types.d.ts" ] }, "javascript": { diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index d625d4fc3ca0..fd5c3b90c040 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -46,7 +46,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.7", "@playwright/test": "^1.40.1", - "@sentry-internal/rrweb": "2.9.0", + "@sentry-internal/rrweb": "2.11.0", "@sentry/browser": "7.99.0", "@sentry/tracing": "7.99.0", "axios": "1.6.0", diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/init.js b/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/init.js new file mode 100644 index 000000000000..07bc4a5b351e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; +import { httpClientIntegration } from '@sentry/integrations'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [httpClientIntegration()], + tracesSampleRate: 1, + sendDefaultPii: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/subject.js b/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/subject.js new file mode 100644 index 000000000000..7a2e3cdd28c0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/subject.js @@ -0,0 +1,8 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://localhost:7654/foo', true); +xhr.withCredentials = true; +xhr.setRequestHeader('Accept', 'application/json'); +xhr.setRequestHeader('Content-Type', 'application/json'); +xhr.setRequestHeader('Cache', 'no-cache'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/test.ts new file mode 100644 index 000000000000..8bf8efa34cc4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/httpClientIntegration/test.ts @@ -0,0 +1,64 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('works with httpClientIntegration', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.route('**/foo', route => { + return route.fulfill({ + status: 500, + body: JSON.stringify({ + error: { + message: 'Internal Server Error', + }, + }), + headers: { + 'Content-Type': 'text/html', + }, + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + + // Not able to get the cookies from the request/response because of Playwright bug + // https://github.com/microsoft/playwright/issues/11035 + expect(eventData).toMatchObject({ + message: 'HTTP Client Error with status code: 500', + exception: { + values: [ + { + type: 'Error', + value: 'HTTP Client Error with status code: 500', + mechanism: { + type: 'http.client', + handled: false, + }, + }, + ], + }, + request: { + url: 'http://localhost:7654/foo', + method: 'GET', + headers: { + accept: 'application/json', + cache: 'no-cache', + 'content-type': 'application/json', + }, + }, + contexts: { + response: { + status_code: 500, + body_size: 45, + headers: { + 'content-type': 'text/html', + 'content-length': '45', + }, + }, + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts index 4f29b0422d2a..bd8050b740aa 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures text request body', async ({ getLocalTestPath, page, browserName }) => { @@ -30,7 +30,9 @@ sentryTest('captures text request body', async ({ getLocalTestPath, page, browse }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -65,9 +67,8 @@ sentryTest('captures text request body', async ({ getLocalTestPath, page, browse }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -109,7 +110,9 @@ sentryTest('captures JSON request body', async ({ getLocalTestPath, page, browse }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -144,9 +147,8 @@ sentryTest('captures JSON request body', async ({ getLocalTestPath, page, browse }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -188,7 +190,9 @@ sentryTest('captures non-text request body', async ({ getLocalTestPath, page, br }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -227,9 +231,8 @@ sentryTest('captures non-text request body', async ({ getLocalTestPath, page, br }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -271,7 +274,9 @@ sentryTest('captures text request body when matching relative URL', async ({ get }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); @@ -306,9 +311,8 @@ sentryTest('captures text request body when matching relative URL', async ({ get }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -348,7 +352,9 @@ sentryTest('does not capture request body when URL does not match', async ({ get }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -383,9 +389,8 @@ sentryTest('does not capture request body when URL does not match', async ({ get }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts index d1cc0a58e118..68296df30cdd 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('handles empty/missing request headers', async ({ getLocalTestPath, page, browserName }) => { @@ -28,7 +28,9 @@ sentryTest('handles empty/missing request headers', async ({ getLocalTestPath, p }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -61,9 +63,8 @@ sentryTest('handles empty/missing request headers', async ({ getLocalTestPath, p }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -100,7 +101,9 @@ sentryTest('captures request headers as POJO', async ({ getLocalTestPath, page, }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -140,9 +143,8 @@ sentryTest('captures request headers as POJO', async ({ getLocalTestPath, page, }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -184,7 +186,9 @@ sentryTest('captures request headers on Request', async ({ getLocalTestPath, pag }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -224,9 +228,8 @@ sentryTest('captures request headers on Request', async ({ getLocalTestPath, pag }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -267,7 +270,9 @@ sentryTest('captures request headers as Headers instance', async ({ getLocalTest }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); @@ -308,9 +313,8 @@ sentryTest('captures request headers as Headers instance', async ({ getLocalTest }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -351,7 +355,9 @@ sentryTest('does not captures request headers if URL does not match', async ({ g }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -391,9 +397,8 @@ sentryTest('does not captures request headers if URL does not match', async ({ g }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts index 3e250bd20df3..bc79df066246 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page }) => { @@ -28,7 +28,9 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -63,9 +65,8 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -112,7 +113,9 @@ sentryTest('captures request size from non-text request body', async ({ getLocal }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -149,9 +152,8 @@ sentryTest('captures request size from non-text request body', async ({ getLocal }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts index b1c0a496476e..c4607fa9cbf7 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures text response body', async ({ getLocalTestPath, page, browserName }) => { @@ -31,7 +31,9 @@ sentryTest('captures text response body', async ({ getLocalTestPath, page, brows }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -65,9 +67,8 @@ sentryTest('captures text response body', async ({ getLocalTestPath, page, brows }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -112,7 +113,9 @@ sentryTest('captures JSON response body', async ({ getLocalTestPath, page, brows }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -146,9 +149,8 @@ sentryTest('captures JSON response body', async ({ getLocalTestPath, page, brows }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -193,7 +195,9 @@ sentryTest('captures non-text response body', async ({ getLocalTestPath, page, b }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -227,9 +231,8 @@ sentryTest('captures non-text response body', async ({ getLocalTestPath, page, b }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', @@ -272,7 +275,9 @@ sentryTest('does not capture response body when URL does not match', async ({ ge }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -306,9 +311,8 @@ sentryTest('does not capture response body when URL does not match', async ({ ge }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts index 93fe566c6bb6..c587db401e4f 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('handles empty headers', async ({ getLocalTestPath, page, browserName }) => { @@ -30,7 +30,9 @@ sentryTest('handles empty headers', async ({ getLocalTestPath, page, browserName }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -61,9 +63,8 @@ sentryTest('handles empty headers', async ({ getLocalTestPath, page, browserName }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'GET', @@ -105,7 +106,9 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page }) => { }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -136,9 +139,8 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page }) => { }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'GET', @@ -186,7 +188,9 @@ sentryTest('does not capture response headers if URL does not match', async ({ g }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -217,9 +221,8 @@ sentryTest('does not capture response headers if URL does not match', async ({ g }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'GET', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts index cba36c1814b9..ad3aafe34562 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures response size from Content-Length header if available', async ({ getLocalTestPath, page }) => { @@ -35,7 +35,10 @@ sentryTest('captures response size from Content-Length header if available', asy }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -67,9 +70,8 @@ sentryTest('captures response size from Content-Length header if available', asy }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'GET', @@ -123,7 +125,10 @@ sentryTest('captures response size without Content-Length header', async ({ getL }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -155,9 +160,8 @@ sentryTest('captures response size without Content-Length header', async ({ getL }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'GET', @@ -208,7 +212,10 @@ sentryTest('captures response size from non-text response body', async ({ getLoc }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -241,9 +248,8 @@ sentryTest('captures response size from non-text response body', async ({ getLoc }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.fetch')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureTimestamps/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureTimestamps/test.ts index 203a89caaaab..cce931062770 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureTimestamps/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureTimestamps/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, browserName }) => { @@ -30,7 +30,9 @@ sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, brows }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -50,10 +52,9 @@ sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, brows const request = await requestPromise; const eventData = envelopeRequestParser(request); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + const { replayRecordingSnapshots } = await replayRequestPromise; - const xhrSpan = performanceSpans1.find(span => span.op === 'resource.fetch')!; + const xhrSpan = getReplayPerformanceSpans(replayRecordingSnapshots).find(span => span.op === 'resource.fetch')!; expect(xhrSpan).toBeDefined(); diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts index b2d4fddaad9e..cd19ba50dd99 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures text request body', async ({ getLocalTestPath, page, browserName }) => { @@ -29,7 +29,9 @@ sentryTest('captures text request body', async ({ getLocalTestPath, page, browse }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -68,9 +70,8 @@ sentryTest('captures text request body', async ({ getLocalTestPath, page, browse }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -110,7 +111,9 @@ sentryTest('captures JSON request body', async ({ getLocalTestPath, page, browse }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -149,9 +152,8 @@ sentryTest('captures JSON request body', async ({ getLocalTestPath, page, browse }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -191,7 +193,9 @@ sentryTest('captures non-text request body', async ({ getLocalTestPath, page, br }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -234,9 +238,8 @@ sentryTest('captures non-text request body', async ({ getLocalTestPath, page, br }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -276,7 +279,9 @@ sentryTest('captures text request body when matching relative URL', async ({ get }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); @@ -315,9 +320,8 @@ sentryTest('captures text request body when matching relative URL', async ({ get }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -357,7 +361,9 @@ sentryTest('does not capture request body when URL does not match', async ({ get }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -396,9 +402,8 @@ sentryTest('does not capture request body when URL does not match', async ({ get }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts index 7158f034a2ef..c9dd8c455b41 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures request headers', async ({ getLocalTestPath, page, browserName }) => { @@ -29,7 +29,9 @@ sentryTest('captures request headers', async ({ getLocalTestPath, page, browserN }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -54,7 +56,7 @@ sentryTest('captures request headers', async ({ getLocalTestPath, page, browserN /* eslint-enable */ }); - const [request, replayReq1] = await Promise.all([requestPromise, replayRequestPromise1]); + const request = await requestPromise; const eventData = envelopeRequestParser(request); expect(eventData.exception?.values).toHaveLength(1); @@ -71,8 +73,8 @@ sentryTest('captures request headers', async ({ getLocalTestPath, page, browserN }, }); - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -116,7 +118,9 @@ sentryTest( }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -141,7 +145,7 @@ sentryTest( /* eslint-enable */ }); - const [request, replayReq1] = await Promise.all([requestPromise, replayRequestPromise1]); + const [request] = await Promise.all([requestPromise]); const eventData = envelopeRequestParser(request); @@ -159,8 +163,8 @@ sentryTest( }, }); - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts index 15e5cc431d35..d33d8a64f1c1 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page, browserName }) => { @@ -29,7 +29,9 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -68,9 +70,8 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -118,7 +119,9 @@ sentryTest('captures request size from non-text request body', async ({ getLocal }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -159,9 +162,8 @@ sentryTest('captures request size from non-text request body', async ({ getLocal }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts index 12ef0b2a6068..97e9bcd749fa 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures text response body', async ({ getLocalTestPath, page, browserName }) => { @@ -33,7 +33,9 @@ sentryTest('captures text response body', async ({ getLocalTestPath, page, brows }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -72,9 +74,8 @@ sentryTest('captures text response body', async ({ getLocalTestPath, page, brows }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -118,7 +119,9 @@ sentryTest('captures JSON response body', async ({ getLocalTestPath, page, brows }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -157,9 +160,8 @@ sentryTest('captures JSON response body', async ({ getLocalTestPath, page, brows }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -203,7 +205,9 @@ sentryTest('captures JSON response body when responseType=json', async ({ getLoc }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -244,9 +248,8 @@ sentryTest('captures JSON response body when responseType=json', async ({ getLoc }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -290,7 +293,9 @@ sentryTest('captures non-text response body', async ({ getLocalTestPath, page, b }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -329,9 +334,8 @@ sentryTest('captures non-text response body', async ({ getLocalTestPath, page, b }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', @@ -377,7 +381,9 @@ sentryTest( }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -416,9 +422,8 @@ sentryTest( }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts index ed2c2f5b2765..754c2adf588f 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures response headers', async ({ getLocalTestPath, page, browserName }) => { @@ -36,7 +36,9 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page, browser }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -74,9 +76,8 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page, browser }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'GET', @@ -127,7 +128,9 @@ sentryTest( }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -165,9 +168,8 @@ sentryTest( }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'GET', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts index ea0d6240c8e9..5024e65741e9 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest( @@ -34,7 +34,9 @@ sentryTest( }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -73,9 +75,8 @@ sentryTest( }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'GET', @@ -130,7 +131,9 @@ sentryTest('captures response size without Content-Length header', async ({ getL }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -169,9 +172,8 @@ sentryTest('captures response size without Content-Length header', async ({ getL }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'GET', @@ -223,7 +225,9 @@ sentryTest('captures response size for non-string bodies', async ({ getLocalTest }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -262,9 +266,8 @@ sentryTest('captures response size for non-string bodies', async ({ getLocalTest }, }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + const { replayRecordingSnapshots } = await replayRequestPromise; + expect(getReplayPerformanceSpans(replayRecordingSnapshots).filter(span => span.op === 'resource.xhr')).toEqual([ { data: { method: 'POST', diff --git a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureTimestamps/test.ts b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureTimestamps/test.ts index 1a60ceea6509..d5d065f83ec5 100644 --- a/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureTimestamps/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureTimestamps/test.ts @@ -3,9 +3,9 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; import { - getCustomRecordingEvents, + collectReplayRequests, + getReplayPerformanceSpans, shouldSkipReplayTest, - waitForReplayRequest, } from '../../../../../utils/replayHelpers'; sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, browserName }) => { @@ -30,7 +30,9 @@ sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, brows }); const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => { + return getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.xhr'); + }); const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -58,10 +60,8 @@ sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, brows const request = await requestPromise; const eventData = envelopeRequestParser(request); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - - const xhrSpan = performanceSpans1.find(span => span.op === 'resource.xhr')!; + const { replayRecordingSnapshots } = await replayRequestPromise; + const xhrSpan = getReplayPerformanceSpans(replayRecordingSnapshots).find(span => span.op === 'resource.xhr')!; expect(xhrSpan).toBeDefined(); diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyInput/template.html b/dev-packages/browser-integration-tests/suites/replay/privacyInput/template.html index fea3e1e29047..a5020bc956c1 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyInput/template.html +++ b/dev-packages/browser-integration-tests/suites/replay/privacyInput/template.html @@ -11,6 +11,7 @@ + diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyInput/test.ts b/dev-packages/browser-integration-tests/suites/replay/privacyInput/test.ts index 3b76e5622225..f2c506f90132 100644 --- a/dev-packages/browser-integration-tests/suites/replay/privacyInput/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/privacyInput/test.ts @@ -126,6 +126,18 @@ sentryTest( // This one should not have any input mutations return inputMutationSegmentIds.length === 2 && inputMutationSegmentIds[1] < event.segment_id; }); + const reqPromise4 = waitForReplayRequest(page, (event, res) => { + const check = + inputMutationSegmentIds.length === 2 && + inputMutationSegmentIds[1] < event.segment_id && + getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -160,5 +172,11 @@ sentryTest( await forceFlushReplay(); const snapshots3 = getIncrementalRecordingSnapshots(await reqPromise3).filter(isInputMutation); expect(snapshots3.length).toBe(0); + + await page.locator('#should-still-be-masked').fill(text); + await forceFlushReplay(); + const snapshots4 = getIncrementalRecordingSnapshots(await reqPromise4).filter(isInputMutation); + const lastSnapshot4 = snapshots4[snapshots4.length - 1]; + expect(lastSnapshot4.data.text).toBe('*'.repeat(text.length)); }, ); diff --git a/dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim /init.js b/dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim /init.js rename to dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim/init.js diff --git a/dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim /template.html b/dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim /template.html rename to dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim/template.html diff --git a/dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim /test.ts b/dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim /test.ts rename to dev-packages/browser-integration-tests/suites/replay/replayIntegrationShim/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts index 131403756251..50c095dbcc57 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts @@ -51,7 +51,7 @@ sentryTest('should capture interaction transaction. @firefox', async ({ browserN expect(interactionSpan.timestamp).toBeDefined(); const interactionSpanDuration = (interactionSpan.timestamp! - interactionSpan.start_timestamp) * 1000; - expect(interactionSpanDuration).toBeGreaterThan(70); + expect(interactionSpanDuration).toBeGreaterThan(65); expect(interactionSpanDuration).toBeLessThan(200); }); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index 1258c684492d..cf2816ab0033 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -22,6 +22,27 @@ const useCompiledModule = bundleKey === 'esm' || bundleKey === 'cjs'; const useBundleOrLoader = bundleKey && !useCompiledModule; const useLoader = bundleKey.startsWith('loader'); +// These are imports that, when using CDN bundles, are not included in the main CDN bundle. +// 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> = { browser: { cjs: 'build/npm/cjs/index.js', @@ -149,8 +170,8 @@ class SentryScenarioGenerationPlugin { '@sentry/browser': 'Sentry', '@sentry/tracing': 'Sentry', '@sentry/replay': 'Sentry', - '@sentry/integrations': 'Sentry.Integrations', - '@sentry/wasm': 'Sentry.Integrations', + '@sentry/integrations': 'Sentry', + '@sentry/wasm': 'Sentry', } : {}; @@ -161,8 +182,11 @@ class SentryScenarioGenerationPlugin { parser.hooks.import.tap( this._name, (statement: { specifiers: [{ imported: { name: string } }] }, source: string) => { - if (source === '@sentry/integrations') { - this.requiredIntegrations.push(statement.specifiers[0].imported.name.toLowerCase()); + const imported = statement.specifiers?.[0]?.imported?.name; + + if (imported && IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS[imported]) { + const bundleName = IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS[imported]; + this.requiredIntegrations.push(bundleName); } else if (source === '@sentry/wasm') { this.requiresWASMIntegration = true; } diff --git a/dev-packages/browser-integration-tests/utils/replayHelpers.ts b/dev-packages/browser-integration-tests/utils/replayHelpers.ts index 87283e2ceb75..f0015d2dfb7f 100644 --- a/dev-packages/browser-integration-tests/utils/replayHelpers.ts +++ b/dev-packages/browser-integration-tests/utils/replayHelpers.ts @@ -104,6 +104,49 @@ export function waitForReplayRequest( ); } +/** + * Collect replay requests until a given callback is satisfied. + * This can be used to ensure we wait correctly, + * when we don't know in which request a certain replay event/snapshot will be. + */ +export function collectReplayRequests( + page: Page, + callback: (replayRecordingEvents: RecordingSnapshot[], replayEvents: ReplayEvent[]) => boolean, +): Promise<{ replayEvents: ReplayEvent[]; replayRecordingSnapshots: RecordingSnapshot[] }> { + const replayEvents: ReplayEvent[] = []; + const replayRecordingSnapshots: RecordingSnapshot[] = []; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + const promise = page.waitForResponse(res => { + const req = res.request(); + + const event = getReplayEventFromRequest(req); + + if (!event) { + return false; + } + + replayEvents.push(event); + replayRecordingSnapshots.push(...getDecompressedRecordingEvents(req)); + + try { + return callback(replayRecordingSnapshots, replayEvents); + } catch { + return false; + } + }); + + const replayRequestPromise = async (): Promise<{ + replayEvents: ReplayEvent[]; + replayRecordingSnapshots: RecordingSnapshot[]; + }> => { + await promise; + return { replayEvents, replayRecordingSnapshots }; + }; + + return replayRequestPromise(); +} + /** * Wait until a callback returns true, collecting all replay responses along the way. * This can be useful when you don't know if stuff will be in one or multiple replay requests. @@ -246,14 +289,14 @@ function getAllCustomRrwebRecordingEvents(recordingEvents: RecordingEvent[]): Cu return recordingEvents.filter(isCustomSnapshot).map(event => event.data); } -function getReplayBreadcrumbs(recordingEvents: RecordingSnapshot[], category?: string): Breadcrumb[] { +export function getReplayBreadcrumbs(recordingEvents: RecordingSnapshot[], category?: string): Breadcrumb[] { return getAllCustomRrwebRecordingEvents(recordingEvents) .filter(data => data.tag === 'breadcrumb') .map(data => data.payload) .filter(payload => !category || payload.category === category); } -function getReplayPerformanceSpans(recordingEvents: RecordingEvent[]): PerformanceSpan[] { +export function getReplayPerformanceSpans(recordingEvents: RecordingSnapshot[]): PerformanceSpan[] { return getAllCustomRrwebRecordingEvents(recordingEvents) .filter(data => data.tag === 'performanceSpan') .map(data => data.payload) as PerformanceSpan[]; diff --git a/dev-packages/e2e-tests/test-applications/angular-17/.editorconfig b/dev-packages/e2e-tests/test-applications/angular-17/.editorconfig new file mode 100644 index 000000000000..59d9a3a3e73f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/dev-packages/e2e-tests/test-applications/angular-17/.gitignore b/dev-packages/e2e-tests/test-applications/angular-17/.gitignore new file mode 100644 index 000000000000..0711527ef9d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/dev-packages/e2e-tests/test-applications/angular-17/.npmrc b/dev-packages/e2e-tests/test-applications/angular-17/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/angular-17/README.md b/dev-packages/e2e-tests/test-applications/angular-17/README.md new file mode 100644 index 000000000000..0b2e08b54d34 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/README.md @@ -0,0 +1,3 @@ +# Angular17 + +E2E test app for Angular 17 and `@sentry/angular-ivy`. diff --git a/dev-packages/e2e-tests/test-applications/angular-17/angular.json b/dev-packages/e2e-tests/test-applications/angular-17/angular.json new file mode 100644 index 000000000000..387a7eefce16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/angular.json @@ -0,0 +1,95 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular-17": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/angular-17", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "angular-17:build:production" + }, + "development": { + "buildTarget": "angular-17:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "angular-17:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-17/event-proxy-server.ts b/dev-packages/e2e-tests/test-applications/angular-17/event-proxy-server.ts new file mode 100644 index 000000000000..4c2df32399f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, SerializedEvent } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/dev-packages/e2e-tests/test-applications/angular-17/package.json b/dev-packages/e2e-tests/test-applications/angular-17/package.json new file mode 100644 index 000000000000..c81a2d19c8c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/package.json @@ -0,0 +1,50 @@ +{ + "name": "angular-17", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "dev": "ng serve", + "preview": "http-server dist/angular-17/browser", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "playwright test", + "clean": "npx rimraf .angular,node_modules,pnpm-lock.yaml,dist" + }, + "private": true, + "dependencies": { + "@angular/animations": "^17.1.0", + "@angular/common": "^17.1.0", + "@angular/compiler": "^17.1.0", + "@angular/core": "^17.1.0", + "@angular/forms": "^17.1.0", + "@angular/platform-browser": "^17.1.0", + "@angular/platform-browser-dynamic": "^17.1.0", + "@angular/router": "^17.1.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.3" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^17.1.1", + "@angular/cli": "^17.1.1", + "@angular/compiler-cli": "^17.1.0", + "@playwright/test": "^1.41.1", + "@sentry/angular-ivy": "latest || *", + "@types/jasmine": "~5.1.0", + "http-server": "^14.1.1", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.3.2", + "ts-node": "10.9.1", + "wait-port": "1.0.4" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts b/dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts new file mode 100644 index 000000000000..967aad98df5e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env['TEST_ENV'] || 'production'; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const angularPort = 8080; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${angularPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}` + : `pnpm wait-port ${eventProxyPort} && pnpm preview -p ${angularPort}`, + port: angularPort, + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.component.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.component.ts new file mode 100644 index 000000000000..989003bef670 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ``, +}) +export class AppComponent { + title = 'angular-17'; +} diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.config.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.config.ts new file mode 100644 index 000000000000..44cf67e5875d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.config.ts @@ -0,0 +1,25 @@ +import { APP_INITIALIZER, ApplicationConfig, ErrorHandler } from '@angular/core'; +import { Router, provideRouter } from '@angular/router'; + +import { TraceService, createErrorHandler } from '@sentry/angular-ivy'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + { + provide: ErrorHandler, + useValue: createErrorHandler(), + }, + { + provide: TraceService, + deps: [Router], + }, + { + provide: APP_INITIALIZER, + useFactory: () => () => {}, + deps: [TraceService], + multi: true, + }, + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts new file mode 100644 index 000000000000..0b44bc341a9b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/app/app.routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; +import { HomeComponent } from './home/home.component'; +import { UserComponent } from './user/user.component'; + +export const routes: Routes = [ + { + path: 'users/:id', + component: UserComponent, + }, + { + path: 'home', + component: HomeComponent, + }, + { + path: '**', + redirectTo: 'home', + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts new file mode 100644 index 000000000000..58a375be1a2d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/app/home/home.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-home', + standalone: true, + imports: [RouterLink], + template: ` +
+

Welcome to Sentry's Angular 17 E2E test app

+ + +
+`, +}) +export class HomeComponent { + throwError() { + throw new Error('Error thrown from Angular 17 E2E test app'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/app/user/user.component.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/app/user/user.component.ts new file mode 100644 index 000000000000..087a33a4a2f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/app/user/user.component.ts @@ -0,0 +1,20 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, map } from 'rxjs'; + +@Component({ + selector: 'app-user', + standalone: true, + imports: [AsyncPipe], + template: ` +

Hello User {{ userId$ | async }}

+ `, +}) +export class UserComponent { + public userId$: Observable; + + constructor(private route: ActivatedRoute) { + this.userId$ = this.route.paramMap.pipe(map(params => params.get('id') || 'UNKNOWN USER')); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/favicon.ico b/dev-packages/e2e-tests/test-applications/angular-17/src/favicon.ico new file mode 100644 index 000000000000..57614f9c9675 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/angular-17/src/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/index.html b/dev-packages/e2e-tests/test-applications/angular-17/src/index.html new file mode 100644 index 000000000000..d7d32515339e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular17 + + + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/main.ts b/dev-packages/e2e-tests/test-applications/angular-17/src/main.ts new file mode 100644 index 000000000000..761a7329a91f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/main.ts @@ -0,0 +1,15 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +import * as Sentry from '@sentry/angular-ivy'; + +Sentry.init({ + dsn: 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + tracesSampleRate: 1.0, + integrations: [Sentry.browserTracingIntegration({})], + tunnel: `http://localhost:3031/`, // proxy server + debug: true, +}); + +bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); diff --git a/dev-packages/e2e-tests/test-applications/angular-17/src/styles.css b/dev-packages/e2e-tests/test-applications/angular-17/src/styles.css new file mode 100644 index 000000000000..90d4ee0072ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/dev-packages/e2e-tests/test-applications/angular-17/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/angular-17/start-event-proxy.ts new file mode 100644 index 000000000000..56fe43416adc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'angular-17', +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-17/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/angular-17/tests/errors.test.ts new file mode 100644 index 000000000000..d34f6a83eb29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '../event-proxy-server'; + +test('sends an error', async ({ page }) => { + const errorPromise = waitForError('angular-17', async errorEvent => { + return !errorEvent?.transaction; + }); + + await page.goto(`/`); + + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Angular 17 E2E test app', + mechanism: { + type: 'angular', + handled: false, + }, + }, + ], + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts new file mode 100644 index 000000000000..8cda20ec3853 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/tests/performance.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('angular-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-17', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-17/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/angular-17/tsconfig.app.json new file mode 100644 index 000000000000..374cc9d294aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/dev-packages/e2e-tests/test-applications/angular-17/tsconfig.json b/dev-packages/e2e-tests/test-applications/angular-17/tsconfig.json new file mode 100644 index 000000000000..e850ebdafb6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-17/tsconfig.json @@ -0,0 +1,38 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js index cb3c8c7a9fb7..92ed1ddc32eb 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js @@ -6,4 +6,5 @@ module.exports = { // serverBuildPath: 'build/index.js', // publicPath: '/build/', serverModuleFormat: 'cjs', + entry, }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/request-instrumentation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/request-instrumentation/page.tsx index 7a226868d1bd..a1092a7fa618 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/app/request-instrumentation/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/request-instrumentation/page.tsx @@ -3,7 +3,7 @@ import http from 'http'; export const dynamic = 'force-dynamic'; export default async function Page() { - await fetch('http://example.com/'); + await fetch('http://example.com/', { cache: 'no-cache' }); await new Promise(resolve => { http.get('http://example.com/', () => { resolve(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts index ab3c40a21471..d855e4918ce5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts @@ -1,3 +1,4 @@ +import os from 'os'; import type { PlaywrightTestConfig } from '@playwright/test'; import { devices } from '@playwright/test'; @@ -31,6 +32,8 @@ const config: PlaywrightTestConfig = { }, /* Run tests in files in parallel */ fullyParallel: true, + /* Defaults to half the number of CPUs. The tests are not really CPU-bound but rather I/O-bound with all the polling we do so we increase the concurrency to the CPU count. */ + workers: os.cpus().length, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* `next dev` is incredibly buggy with the app dir */ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-action/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-action/page.tsx index 4137fafd9c3c..6784970d2aae 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-action/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/server-action/page.tsx @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/nextjs'; import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; export default function ServerComponent() { async function myServerAction(formData: FormData) { @@ -14,11 +15,29 @@ export default function ServerComponent() { ); } + async function notFoundServerAction(formData: FormData) { + 'use server'; + return await Sentry.withServerActionInstrumentation( + 'notFoundServerAction', + { formData, headers: headers(), recordResponse: true }, + () => { + notFound(); + }, + ); + } + return ( - // @ts-ignore -
- - -
+ <> + {/* @ts-ignore */} +
+ + +
+ {/* @ts-ignore */} +
+ + +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts index ab3c40a21471..599afc629b87 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.ts @@ -1,3 +1,4 @@ +import os from 'os'; import type { PlaywrightTestConfig } from '@playwright/test'; import { devices } from '@playwright/test'; @@ -29,6 +30,8 @@ const config: PlaywrightTestConfig = { */ timeout: 10000, }, + /* Defaults to half the number of CPUs. The tests are not really CPU-bound but rather I/O-bound with all the polling we do so we increase the concurrency to the CPU count. */ + workers: os.cpus().length, /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 9c6dd31496a8..d52cd4f18893 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -37,7 +37,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) ); }); - const servercomponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' && (await clientNavigationTransactionPromise).contexts?.trace?.trace_id === @@ -48,5 +48,5 @@ test('Creates a navigation transaction for app router routes', async ({ page }) await page.getByText('/server-component/parameter/foo/bar/baz').click(); expect(await clientNavigationTransactionPromise).toBeDefined(); - expect(await servercomponentTransactionPromise).toBeDefined(); + expect(await serverComponentTransactionPromise).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 3532c5c64746..3e146433defc 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 @@ -130,16 +130,32 @@ test('Should send a transaction for instrumented server actions', async ({ page await page.getByText('Run Action').click(); expect(await serverComponentTransactionPromise).toBeDefined(); - expect( - (await serverComponentTransactionPromise).contexts?.trace?.data?.['server_action_form_data.some-text-value'], - ).toEqual('some-default-value'); - expect((await serverComponentTransactionPromise).contexts?.trace?.data?.['server_action_result']).toEqual({ - city: 'Vienna', + expect((await serverComponentTransactionPromise).extra).toMatchObject({ + 'server_action_form_data.some-text-value': 'some-default-value', + server_action_result: { + city: 'Vienna', + }, }); expect(Object.keys((await serverComponentTransactionPromise).request?.headers || {}).length).toBeGreaterThan(0); }); +test('Should set not_found status for server actions calling notFound()', async ({ page }) => { + const nextjsVersion = packageJson.dependencies.next; + const nextjsMajor = Number(nextjsVersion.split('.')[0]); + test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); + + const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'serverAction/notFoundServerAction'; + }); + + await page.goto('/server-action'); + await page.getByText('Run NotFound Action').click(); + + expect(await serverComponentTransactionPromise).toBeDefined(); + expect(await (await serverComponentTransactionPromise).contexts?.trace?.status).toBe('not_found'); +}); + test('Will not include spans in pageload transaction with faulty timestamps for slow loading pages', async ({ page, }) => { diff --git a/dev-packages/e2e-tests/test-applications/node-profiling/.gitignore b/dev-packages/e2e-tests/test-applications/node-profiling/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-profiling/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-profiling/.npmrc b/dev-packages/e2e-tests/test-applications/node-profiling/.npmrc new file mode 100644 index 000000000000..949fbddc2343 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-profiling/.npmrc @@ -0,0 +1,2 @@ +# @sentry:registry=http://127.0.0.1:4873 +# @sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-profiling/build.mjs b/dev-packages/e2e-tests/test-applications/node-profiling/build.mjs new file mode 100644 index 000000000000..cdf744355fe8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-profiling/build.mjs @@ -0,0 +1,19 @@ +// Because bundlers can now predetermine a static set of binaries we need to ensure those binaries +// actually exists, else we risk a compile time error when bundling the package. This could happen +// if we added a new binary in cpu_profiler.ts, but forgot to prebuild binaries for it. Because CI +// only runs integration and unit tests, this change would be missed and could end up in a release. +// Therefor, once all binaries are precompiled in CI and tests pass, run esbuild with bundle:true +// which will copy all binaries to the outfile folder and throw if any of them are missing. +import esbuild from 'esbuild'; + +console.log('Running build using esbuild version', esbuild.version); + +esbuild.buildSync({ + platform: 'node', + entryPoints: ['./index.js'], + outdir: './dist', + target: 'esnext', + format: 'cjs', + bundle: true, + loader: { '.node': 'copy' }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-profiling/index.js b/dev-packages/e2e-tests/test-applications/node-profiling/index.js new file mode 100644 index 000000000000..bd440f4f17be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-profiling/index.js @@ -0,0 +1,18 @@ +const Sentry = require('@sentry/node'); +const Profiling = require('@sentry/profiling-node'); + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + +Sentry.init({ + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + integrations: [new Profiling.ProfilingIntegration()], + tracesSampleRate: 1.0, + profilesSampleRate: 1.0, +}); + +const txn = Sentry.startTransaction('Precompile test'); + +(async () => { + await wait(500); + txn.finish(); +})(); diff --git a/dev-packages/e2e-tests/test-applications/node-profiling/package.json b/dev-packages/e2e-tests/test-applications/node-profiling/package.json new file mode 100644 index 000000000000..8d2bfff693eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-profiling/package.json @@ -0,0 +1,21 @@ +{ + "name": "node-profiling", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "node build.mjs", + "start": "node index.js", + "test": "node index.js && node build.mjs", + "clean": "npx rimraf node_modules", + "test:build": "npm run build", + "test:assert": "npm run test" + }, + "dependencies": { + "@sentry/node": "latest || *", + "@sentry/profiling-node": "latest || *" + }, + "devDependencies": {}, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx index 35db65cf3160..73c5e024539f 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -18,14 +18,12 @@ Sentry.init({ // environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ - new Sentry.BrowserTracing({ - routingInstrumentation: Sentry.reactRouterV6Instrumentation( - React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - ), + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, }), replay, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx index 2f8587db9859..b8a036fc5340 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx @@ -18,14 +18,12 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ - new Sentry.BrowserTracing({ - routingInstrumentation: Sentry.reactRouterV6Instrumentation( - React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - ), + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, }), replay, ], diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx b/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx index 660c3827f583..8cf0e8462e16 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx @@ -19,14 +19,12 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ - new Sentry.BrowserTracing({ - routingInstrumentation: Sentry.reactRouterV6Instrumentation( - React.useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - ), + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, }), replay, ], diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/event-proxy-server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/event-proxy-server.ts index 66a9e744846e..4c2df32399f0 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/event-proxy-server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/event-proxy-server.ts @@ -6,7 +6,7 @@ import * as os from 'os'; import * as path from 'path'; import * as util from 'util'; import * as zlib from 'zlib'; -import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import type { Envelope, EnvelopeItem, SerializedEvent } from '@sentry/types'; import { parseEnvelope } from '@sentry/utils'; const readFile = util.promisify(fs.readFile); @@ -210,13 +210,13 @@ export function waitForEnvelopeItem( export function waitForError( proxyServerName: string, - callback: (transactionEvent: Event) => Promise | boolean, -): Promise { + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { return new Promise((resolve, reject) => { waitForEnvelopeItem(proxyServerName, async envelopeItem => { const [envelopeItemHeader, envelopeItemBody] = envelopeItem; - if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { - resolve(envelopeItemBody as Event); + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); return true; } return false; @@ -226,13 +226,13 @@ export function waitForError( export function waitForTransaction( proxyServerName: string, - callback: (transactionEvent: Event) => Promise | boolean, -): Promise { + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { return new Promise((resolve, reject) => { waitForEnvelopeItem(proxyServerName, async envelopeItem => { const [envelopeItemHeader, envelopeItemBody] = envelopeItem; - if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { - resolve(envelopeItemBody as Event); + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); return true; } return false; @@ -247,7 +247,7 @@ async function registerCallbackServerPort(serverName: string, port: string): Pro await writeFile(tmpFilePath, port, { encoding: 'utf8' }); } -async function retrieveCallbackServerPort(serverName: string): Promise { +function retrieveCallbackServerPort(serverName: string): Promise { const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); - return await readFile(tmpFilePath, 'utf8'); + return readFile(tmpFilePath, 'utf8'); } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json index b55d9ff74df6..a323c3c415be 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json @@ -10,20 +10,19 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm -v" + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "pnpm test:prod" }, "dependencies": { "@sentry/sveltekit": "latest || *" }, "devDependencies": { - "@playwright/test": "^1.27.1", + "@playwright/test": "^1.36.2", "@sentry/types": "latest || *", "@sentry/utils": "latest || *", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-node": "^2.0.0", - "@sveltejs/kit": "^2.0.0", + "@sveltejs/kit": "^2.5.0", "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.2.8", "svelte-check": "^3.6.0", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/playwright.config.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/playwright.config.ts index bfa29df7d549..5e028dc2e29a 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/playwright.config.ts @@ -1,13 +1,19 @@ import type { PlaywrightTestConfig } from '@playwright/test'; import { devices } from '@playwright/test'; +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + const testEnv = process.env.TEST_ENV; if (!testEnv) { throw new Error('No test env defined'); } -const port = 3030; +const svelteKitPort = 3030; +const eventProxyPort = 3031; /** * See https://playwright.dev/docs/test-configuration. @@ -24,7 +30,8 @@ const config: PlaywrightTestConfig = { timeout: 10000, }, /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, + workers: 1, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* `next dev` is incredibly buggy with the app dir */ @@ -36,7 +43,7 @@ const config: PlaywrightTestConfig = { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ actionTimeout: 0, /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: `http://localhost:${port}`, + baseURL: `http://localhost:${svelteKitPort}`, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', @@ -55,15 +62,17 @@ const config: PlaywrightTestConfig = { /* Run your local dev server before starting the tests */ webServer: [ { - command: 'pnpm ts-node --esm start-event-proxy.ts', - port: 3031, + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + reuseExistingServer: false, }, { command: testEnv === 'development' - ? `pnpm wait-port ${port} && pnpm dev --port ${port}` - : `pnpm wait-port ${port} && pnpm preview --port ${port}`, - port, + ? `pnpm wait-port ${eventProxyPort} && pnpm dev --port ${svelteKitPort}` + : `pnpm wait-port ${eventProxyPort} && PORT=${svelteKitPort} node build`, + port: svelteKitPort, + reuseExistingServer: false, }, ], }; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/app.html b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/app.html index 117bd026151a..435cf39f2268 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/app.html +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/app.html @@ -6,7 +6,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts index ae99e0e0e7b4..2a2abbb870dd 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/hooks.server.ts @@ -9,10 +9,7 @@ Sentry.init({ tracesSampleRate: 1.0, }); -const myErrorHandler = ({ error, event }: any) => { - console.error('An error occurred on the server side:', error, event); -}; - -export const handleError = Sentry.handleErrorWithSentry(myErrorHandler); +// not logging anything to console to avoid noise in the test output +export const handleError = Sentry.handleErrorWithSentry(() => {}); export const handle = Sentry.sentryHandle(); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+layout.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+layout.svelte new file mode 100644 index 000000000000..08c4b6468a93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+layout.svelte @@ -0,0 +1,13 @@ + + +

Sveltekit E2E Test app

+
+ +
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte index 5982b0ae37dd..aeb12d58603f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte @@ -1,2 +1,26 @@

Welcome to SvelteKit

Visit kit.svelte.dev to read the documentation

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/api/users/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/api/users/+server.ts new file mode 100644 index 000000000000..d0e4371c594b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/api/users/+server.ts @@ -0,0 +1,3 @@ +export const GET = () => { + return new Response(JSON.stringify({ users: ['alice', 'bob', 'carol'] })); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.svelte index fde274c60705..b27edb70053d 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/building/+page.svelte @@ -1,3 +1,18 @@ + +

Check Build

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/client-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/client-error/+page.svelte new file mode 100644 index 000000000000..ba6b464e9324 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/client-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Client error

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-error/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-error/+page.server.ts new file mode 100644 index 000000000000..17dd53fb5bbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-error/+page.server.ts @@ -0,0 +1,6 @@ +export const load = async () => { + throw new Error('Server Load Error'); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-error/+page.svelte new file mode 100644 index 000000000000..3a0942971d06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Server load error

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+page.svelte new file mode 100644 index 000000000000..3d682e7e3462 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Server Route error

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+page.ts new file mode 100644 index 000000000000..298240827714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+page.ts @@ -0,0 +1,7 @@ +export const load = async ({ fetch }) => { + const res = await fetch('/server-route-error'); + const data = await res.json(); + return { + msg: data, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+server.ts new file mode 100644 index 000000000000..f1a4b94b7706 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-route-error/+server.ts @@ -0,0 +1,6 @@ +export const GET = async () => { + throw new Error('Server Route Error'); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-error/+page.svelte new file mode 100644 index 000000000000..dc2d311a0ece --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-error/+page.svelte @@ -0,0 +1,17 @@ + + +

Universal load error

+ +

+ To trigger from client: Load on another route, then navigate to this route. +

+ +

+ To trigger from server: Load on this route +

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-error/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-error/+page.ts new file mode 100644 index 000000000000..3d72bf4a890f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-error/+page.ts @@ -0,0 +1,8 @@ +import { browser } from '$app/environment'; + +export const load = async () => { + throw new Error(`Universal Load Error (${browser ? 'browser' : 'server'})`); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-fetch/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-fetch/+page.svelte new file mode 100644 index 000000000000..563c51e8c850 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-fetch/+page.svelte @@ -0,0 +1,14 @@ + + +

Fetching in universal load

+ +

Here's a list of a few users:

+ +
    + {#each data.users as user} +
  • {user}
  • + {/each} +
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-fetch/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-fetch/+page.ts new file mode 100644 index 000000000000..63c1ee68e1cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/universal-load-fetch/+page.ts @@ -0,0 +1,5 @@ +export const load = async ({ fetch }) => { + const usersRes = await fetch('/api/users'); + const data = await usersRes.json(); + return { users: data.users }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/+page.server.ts new file mode 100644 index 000000000000..a34c5450f682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async () => { + return { + msg: 'Hi everyone!', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/+page.svelte new file mode 100644 index 000000000000..aa804a4518fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/+page.svelte @@ -0,0 +1,10 @@ + +

+ All Users: +

+ +

+ message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/[id]/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/[id]/+page.server.ts new file mode 100644 index 000000000000..9388f3927018 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/[id]/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ params }) => { + return { + msg: `This is a special message for user ${params.id}`, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/[id]/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/[id]/+page.svelte new file mode 100644 index 000000000000..d348a8c57dad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/users/[id]/+page.svelte @@ -0,0 +1,14 @@ + + +

Route with dynamic params

+ +

+ User id: {$page.params.id} +

+ +

+ Secret message for user: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.client.test.ts new file mode 100644 index 000000000000..7942b96b41b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.client.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '../event-proxy-server'; +import { waitForInitialPageload } from './utils'; + +test.describe('client-side errors', () => { + test('captures error thrown on click', async ({ page }) => { + await page.goto('/client-error'); + + await expect(page.getByText('Client error')).toBeVisible(); + + const errorEventPromise = waitForError('sveltekit-2', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; + }); + + const clickPromise = page.getByText('Throw error').click(); + + const [errorEvent, _] = await Promise.all([errorEventPromise, clickPromise]); + + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: expect.stringContaining('HTMLButtonElement'), + lineno: 1, + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'browser' }); + }); + + test('captures universal load error', async ({ page }) => { + await waitForInitialPageload(page); + await page.reload(); + + const errorEventPromise = waitForError('sveltekit-2', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (browser)'; + }); + + // navigating triggers the error on the client + await page.getByText('Universal Load error').click(); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + const lastFrame = errorEventFrames?.[errorEventFrames?.length - 1]; + expect(lastFrame).toEqual( + expect.objectContaining({ + lineno: 1, + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'browser' }); + }); +}); 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 new file mode 100644 index 000000000000..b7966325560a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/errors.server.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '../event-proxy-server'; + +test.describe('server-side errors', () => { + test('captures universal load error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit-2', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (server)'; + }); + + await page.goto('/universal-load-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'load$1', + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'node' }); + }); + + test('captures server load error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit-2', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Server Load Error'; + }); + + await page.goto('/server-load-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'load$1', + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'node' }); + }); + + test('captures server route (GET) error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit-2', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Server Route Error'; + }); + + await page.goto('/server-route-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + filename: expect.stringContaining('app:///_server.ts'), + function: 'GET', + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ + runtime: 'node', + transaction: 'GET /server-route-error', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/performance.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/performance.test.ts new file mode 100644 index 000000000000..aed2040392e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/performance.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; +import { waitForInitialPageload } from './utils'; + +test.describe('performance events', () => { + test('capture a distributed pageload trace', async ({ page }) => { + await page.goto('/users/123xyz'); + + const clientTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === '/users/[id]'; + }); + + const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === 'GET /users/[id]'; + }); + + const [clientTxnEvent, serverTxnEvent, _] = await Promise.all([ + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText('User id: 123xyz')).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/users/[id]', + tags: { runtime: 'browser' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.sveltekit', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /users/[id]', + tags: { runtime: 'node' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + expect(clientTxnEvent.spans?.length).toBeGreaterThan(5); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + + // weird but server txn is parent of client txn + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); + + test('capture a distributed navigation trace', async ({ page }) => { + await waitForInitialPageload(page); + + const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; + }); + + const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === 'GET /users'; + }); + + // navigation to page + const clickPromise = page.getByText('Route with Server Load').click(); + + const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ + clientNavigationTxnEventPromise, + serverTxnEventPromise, + clickPromise, + expect(page.getByText('Hi everyone')).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/users', + tags: { runtime: 'browser' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /users', + tags: { runtime: 'node' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + // trace is connected + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + }); + + test('record client-side universal load fetch span and trace', async ({ page }) => { + await waitForInitialPageload(page); + + const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === '/universal-load-fetch' && txnEvent.contexts?.trace?.op === 'navigation'; + }); + + // this transaction should be created because of the fetch call + // it should also be part of the trace + const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === 'GET /api/users'; + }); + + // navigation to page + const clickPromise = page.getByText('Route with fetch in universal load').click(); + + const [clientTxnEvent, serverTxnEvent, _1, _2] = await Promise.all([ + clientNavigationTxnEventPromise, + serverTxnEventPromise, + clickPromise, + expect(page.getByText('alice')).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/universal-load-fetch', + tags: { runtime: 'browser' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /api/users', + tags: { runtime: 'node' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + // trace is connected + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + + const clientFetchSpan = clientTxnEvent.spans?.find(s => s.op === 'http.client'); + + expect(clientFetchSpan).toMatchObject({ + description: expect.stringMatching(/^GET.*\/api\/users/), + op: 'http.client', + origin: 'auto.http.browser', + data: { + url: expect.stringContaining('/api/users'), + type: 'fetch', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'network.protocol.version': '1.1', + 'network.protocol.name': 'http', + 'http.request.redirect_start': expect.any(Number), + 'http.request.fetch_start': expect.any(Number), + 'http.request.domain_lookup_start': expect.any(Number), + 'http.request.domain_lookup_end': expect.any(Number), + 'http.request.connect_start': expect.any(Number), + 'http.request.secure_connection_start': expect.any(Number), + 'http.request.connection_end': expect.any(Number), + 'http.request.request_start': expect.any(Number), + 'http.request.response_start': expect.any(Number), + 'http.request.response_end': expect.any(Number), + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/transaction.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/transaction.test.ts deleted file mode 100644 index 7d621af34dcf..000000000000 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/transaction.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expect, test } from '@playwright/test'; -import axios, { AxiosError } from 'axios'; -// @ts-expect-error ok ok -import { waitForTransaction } from '../event-proxy-server.ts'; - -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 pageload transaction', async ({ page }) => { - const pageloadTransactionEventPromise = waitForTransaction('sveltekit', (transactionEvent: any) => { - return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; - }); - - await page.goto('/'); - - const transactionEvent = await pageloadTransactionEventPromise; - const transactionEventId = transactionEvent.event_id; - - 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); -}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/utils.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/utils.ts new file mode 100644 index 000000000000..b48b949abdd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/utils.ts @@ -0,0 +1,49 @@ +import { Page } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +/** + * Helper function that waits for the initial pageload to complete. + * + * This function + * - loads the given route ("/" by default) + * - waits for SvelteKit's hydration + * - waits for the pageload transaction to be sent (doesn't assert on it though) + * + * Useful for tests that test outcomes of _navigations_ after an initial pageload. + * Waiting on the pageload transaction excludes edge cases where navigations occur + * so quickly that the pageload idle transaction is still active. This might lead + * to cases where the routing span would be attached to the pageload transaction + * and hence eliminates a lot of flakiness. + * + */ +export async function waitForInitialPageload( + page: Page, + opts?: { route?: string; parameterizedRoute?: string; debug: boolean }, +) { + const route = opts?.route ?? '/'; + const txnName = opts?.parameterizedRoute ?? route; + const debug = opts?.debug ?? false; + + const clientPageloadTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + debug && + console.log({ + txn: txnEvent?.transaction, + op: txnEvent.contexts?.trace?.op, + trace: txnEvent.contexts?.trace?.trace_id, + span: txnEvent.contexts?.trace?.span_id, + parent: txnEvent.contexts?.trace?.parent_span_id, + }); + + return txnEvent?.transaction === txnName && txnEvent.contexts?.trace?.op === 'pageload'; + }); + + await Promise.all([ + page.goto(route), + // the test app adds the "hydrated" class to the body when hydrating + page.waitForSelector('body.hydrated'), + // also waiting for the initial pageload txn so that later navigations don't interfere + clientPageloadTxnEventPromise, + ]); + + debug && console.log('hydrated'); +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/event-proxy-server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/event-proxy-server.ts index 66a9e744846e..1bc419bd0b4c 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/event-proxy-server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit/event-proxy-server.ts @@ -6,7 +6,7 @@ import * as os from 'os'; import * as path from 'path'; import * as util from 'util'; import * as zlib from 'zlib'; -import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import type { Envelope, EnvelopeItem, Event, SerializedEvent } from '@sentry/types'; import { parseEnvelope } from '@sentry/utils'; const readFile = util.promisify(fs.readFile); @@ -226,13 +226,13 @@ export function waitForError( export function waitForTransaction( proxyServerName: string, - callback: (transactionEvent: Event) => Promise | boolean, -): Promise { + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { return new Promise((resolve, reject) => { waitForEnvelopeItem(proxyServerName, async envelopeItem => { const [envelopeItemHeader, envelopeItemBody] = envelopeItem; - if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { - resolve(envelopeItemBody as Event); + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); return true; } return false; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/package.json b/dev-packages/e2e-tests/test-applications/sveltekit/package.json index ad6bf6456843..ea21787939c3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit/package.json @@ -10,20 +10,19 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm -v" + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "pnpm test:prod" }, "dependencies": { "@sentry/sveltekit": "latest || *" }, "devDependencies": { - "@playwright/test": "^1.27.1", + "@playwright/test": "^1.41.1", "@sentry/types": "latest || *", "@sentry/utils": "latest || *", "@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/adapter-node": "^1.2.4", - "@sveltejs/kit": "^1.5.0", + "@sveltejs/kit": "^1.30.3", "svelte": "^3.54.0", "svelte-check": "^3.0.1", "ts-node": "10.9.1", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/playwright.config.ts b/dev-packages/e2e-tests/test-applications/sveltekit/playwright.config.ts index bfa29df7d549..779757c8807f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit/playwright.config.ts @@ -8,6 +8,7 @@ if (!testEnv) { } const port = 3030; +const eventProxyPort = 3031; /** * See https://playwright.dev/docs/test-configuration. @@ -23,8 +24,9 @@ const config: PlaywrightTestConfig = { */ timeout: 10000, }, + workers: 1, /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* `next dev` is incredibly buggy with the app dir */ @@ -61,8 +63,8 @@ const config: PlaywrightTestConfig = { { command: testEnv === 'development' - ? `pnpm wait-port ${port} && pnpm dev --port ${port}` - : `pnpm wait-port ${port} && pnpm preview --port ${port}`, + ? `pnpm wait-port ${eventProxyPort} && pnpm dev --port ${port}` + : `pnpm wait-port ${eventProxyPort} && pnpm preview --port ${port}`, port, }, ], diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/app.html b/dev-packages/e2e-tests/test-applications/sveltekit/src/app.html index 117bd026151a..435cf39f2268 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/src/app.html +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/app.html @@ -6,7 +6,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/hooks.server.ts index 2d9cb9b02328..375b8d2c170a 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/hooks.server.ts @@ -9,9 +9,8 @@ Sentry.init({ tracesSampleRate: 1.0, }); -const myErrorHandler = ({ error, event }: any) => { - console.error('An error occurred on the server side:', error, event); -}; +// not logging anything to console to avoid noise in the test output +const myErrorHandler = ({ error, event }: any) => {}; export const handleError = Sentry.handleErrorWithSentry(myErrorHandler); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+layout.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+layout.svelte new file mode 100644 index 000000000000..8b7db6f720bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+layout.svelte @@ -0,0 +1,10 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte index 5982b0ae37dd..aeb12d58603f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte @@ -1,2 +1,26 @@

Welcome to SvelteKit

Visit kit.svelte.dev to read the documentation

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/api/users/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/api/users/+server.ts new file mode 100644 index 000000000000..d0e4371c594b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/api/users/+server.ts @@ -0,0 +1,3 @@ +export const GET = () => { + return new Response(JSON.stringify({ users: ['alice', 'bob', 'carol'] })); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/client-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/client-error/+page.svelte new file mode 100644 index 000000000000..ba6b464e9324 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/client-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Client error

+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-error/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-error/+page.server.ts new file mode 100644 index 000000000000..17dd53fb5bbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-error/+page.server.ts @@ -0,0 +1,6 @@ +export const load = async () => { + throw new Error('Server Load Error'); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-error/+page.svelte new file mode 100644 index 000000000000..3a0942971d06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Server load error

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+page.svelte new file mode 100644 index 000000000000..3d682e7e3462 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+page.svelte @@ -0,0 +1,9 @@ + + +

Server Route error

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+page.ts new file mode 100644 index 000000000000..298240827714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+page.ts @@ -0,0 +1,7 @@ +export const load = async ({ fetch }) => { + const res = await fetch('/server-route-error'); + const data = await res.json(); + return { + msg: data, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+server.ts new file mode 100644 index 000000000000..f1a4b94b7706 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-route-error/+server.ts @@ -0,0 +1,6 @@ +export const GET = async () => { + throw new Error('Server Route Error'); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-error/+page.svelte new file mode 100644 index 000000000000..dc2d311a0ece --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-error/+page.svelte @@ -0,0 +1,17 @@ + + +

Universal load error

+ +

+ To trigger from client: Load on another route, then navigate to this route. +

+ +

+ To trigger from server: Load on this route +

+ +

+ Message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-error/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-error/+page.ts new file mode 100644 index 000000000000..3d72bf4a890f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-error/+page.ts @@ -0,0 +1,8 @@ +import { browser } from '$app/environment'; + +export const load = async () => { + throw new Error(`Universal Load Error (${browser ? 'browser' : 'server'})`); + return { + msg: 'Hello World', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-fetch/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-fetch/+page.svelte new file mode 100644 index 000000000000..563c51e8c850 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-fetch/+page.svelte @@ -0,0 +1,14 @@ + + +

Fetching in universal load

+ +

Here's a list of a few users:

+ +
    + {#each data.users as user} +
  • {user}
  • + {/each} +
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-fetch/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-fetch/+page.ts new file mode 100644 index 000000000000..63c1ee68e1cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/universal-load-fetch/+page.ts @@ -0,0 +1,5 @@ +export const load = async ({ fetch }) => { + const usersRes = await fetch('/api/users'); + const data = await usersRes.json(); + return { users: data.users }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/+page.server.ts new file mode 100644 index 000000000000..a34c5450f682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async () => { + return { + msg: 'Hi everyone!', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/+page.svelte new file mode 100644 index 000000000000..aa804a4518fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/+page.svelte @@ -0,0 +1,10 @@ + +

+ All Users: +

+ +

+ message: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/[id]/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/[id]/+page.server.ts new file mode 100644 index 000000000000..9388f3927018 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/[id]/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ params }) => { + return { + msg: `This is a special message for user ${params.id}`, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/[id]/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/[id]/+page.svelte new file mode 100644 index 000000000000..d348a8c57dad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/users/[id]/+page.svelte @@ -0,0 +1,14 @@ + + +

Route with dynamic params

+ +

+ User id: {$page.params.id} +

+ +

+ Secret message for user: {data.msg} +

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.client.test.ts new file mode 100644 index 000000000000..7cd1a545165b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.client.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '../event-proxy-server'; +import { waitForInitialPageload } from '../utils'; + +test.describe('client-side errors', () => { + test('captures error thrown on click', async ({ page }) => { + await page.goto('/client-error'); + + await expect(page.getByText('Client error')).toBeVisible(); + + const errorEventPromise = waitForError('sveltekit', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; + }); + + const clickPromise = page.getByText('Throw error').click(); + + const [errorEvent, _] = await Promise.all([errorEventPromise, clickPromise]); + + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: expect.stringContaining('HTMLButtonElement'), + lineno: 1, + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'browser' }); + }); + + test('captures universal load error', async ({ page }) => { + await waitForInitialPageload(page); + await page.reload(); + + const errorEventPromise = waitForError('sveltekit', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (browser)'; + }); + + // navigating triggers the error on the client + await page.getByText('Universal Load error').click(); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + lineno: 1, + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'browser' }); + }); +}); 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 new file mode 100644 index 000000000000..5493659b19db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/test/errors.server.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '../event-proxy-server'; + +test.describe('server-side errors', () => { + test('captures universal load error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (server)'; + }); + + await page.goto('/universal-load-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'load$1', + lineno: 3, + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'node' }); + }); + + test('captures server load error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Server Load Error'; + }); + + await page.goto('/server-load-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'load$1', + lineno: 3, + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ runtime: 'node' }); + }); + + test('captures server route (GET) error', async ({ page }) => { + const errorEventPromise = waitForError('sveltekit', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Server Route Error'; + }); + + await page.goto('/server-route-error'); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + filename: 'app:///_server.ts.js', + function: 'GET', + lineno: 2, + in_app: true, + }), + ); + + expect(errorEvent.tags).toMatchObject({ + runtime: 'node', + transaction: 'GET /server-route-error', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/test/performance.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit/test/performance.test.ts new file mode 100644 index 000000000000..e0d3d16df1ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/test/performance.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server.js'; +import { waitForInitialPageload } from '../utils.js'; + +test('sends a pageload transaction', async ({ page }) => { + const pageloadTransactionEventPromise = waitForTransaction('sveltekit', (transactionEvent: any) => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent).toMatchObject({ + transaction: '/', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.sveltekit', + }, + }, + tags: { + 'routing.instrumentation': '@sentry/sveltekit', + }, + }); +}); + +test('captures a distributed pageload trace', async ({ page }) => { + await page.goto('/users/123xyz'); + + const clientTxnEventPromise = waitForTransaction('sveltekit', txnEvent => { + return txnEvent?.transaction === '/users/[id]'; + }); + + const serverTxnEventPromise = waitForTransaction('sveltekit', txnEvent => { + return txnEvent?.transaction === 'GET /users/[id]'; + }); + + const [clientTxnEvent, serverTxnEvent] = await Promise.all([clientTxnEventPromise, serverTxnEventPromise]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/users/[id]', + tags: { runtime: 'browser' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.sveltekit', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /users/[id]', + tags: { runtime: 'node' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + + // weird but server txn is parent of client txn + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); +}); + +test('captures a distributed navigation trace', async ({ page }) => { + await waitForInitialPageload(page); + + const clientNavigationTxnEventPromise = waitForTransaction('sveltekit', txnEvent => { + return txnEvent?.transaction === '/users/[id]'; + }); + + const serverTxnEventPromise = waitForTransaction('sveltekit', txnEvent => { + return txnEvent?.transaction === 'GET /users/[id]'; + }); + + // navigation to page + const clickPromise = page.getByText('Route with Params').click(); + + const [clientTxnEvent, serverTxnEvent, _1] = await Promise.all([ + clientNavigationTxnEventPromise, + serverTxnEventPromise, + clickPromise, + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/users/[id]', + tags: { runtime: 'browser' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.sveltekit', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /users/[id]', + tags: { runtime: 'node' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + // trace is connected + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/test/transaction.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit/test/transaction.test.ts deleted file mode 100644 index 7d621af34dcf..000000000000 --- a/dev-packages/e2e-tests/test-applications/sveltekit/test/transaction.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expect, test } from '@playwright/test'; -import axios, { AxiosError } from 'axios'; -// @ts-expect-error ok ok -import { waitForTransaction } from '../event-proxy-server.ts'; - -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 pageload transaction', async ({ page }) => { - const pageloadTransactionEventPromise = waitForTransaction('sveltekit', (transactionEvent: any) => { - return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; - }); - - await page.goto('/'); - - const transactionEvent = await pageloadTransactionEventPromise; - const transactionEventId = transactionEvent.event_id; - - 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); -}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/utils.ts b/dev-packages/e2e-tests/test-applications/sveltekit/utils.ts new file mode 100644 index 000000000000..2886873bb8fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/utils.ts @@ -0,0 +1,53 @@ +import { Page } from '@playwright/test'; +import { waitForTransaction } from './event-proxy-server'; + +/** + * Helper function that waits for the initial pageload to complete. + * + * This function + * - loads the given route ("/" by default) + * - waits for SvelteKit's hydration + * - waits for the pageload transaction to be sent (doesn't assert on it though) + * + * Useful for tests that test outcomes of _navigations_ after an initial pageload. + * Waiting on the pageload transaction excludes edge cases where navigations occur + * so quickly that the pageload idle transaction is still active. This might lead + * to cases where the routing span would be attached to the pageload transaction + * and hence eliminates a lot of flakiness. + * + */ +export async function waitForInitialPageload( + page: Page, + opts?: { route?: string; parameterizedRoute?: string; debug: boolean }, +) { + const route = opts?.route ?? '/'; + const txnName = opts?.parameterizedRoute ?? route; + const debug = opts?.debug ?? false; + + const clientPageloadTxnEventPromise = waitForTransaction('sveltekit', txnEvent => { + debug && + console.log({ + txn: txnEvent?.transaction, + op: txnEvent.contexts?.trace?.op, + trace: txnEvent.contexts?.trace?.trace_id, + span: txnEvent.contexts?.trace?.span_id, + parent: txnEvent.contexts?.trace?.parent_span_id, + }); + + return txnEvent?.transaction === txnName && txnEvent.contexts?.trace?.op === 'pageload'; + }); + + await Promise.all([ + page.goto(route), + // the test app adds the "hydrated" class to the body when hydrating + page.waitForSelector('body.hydrated'), + // also waiting for the initial pageload txn so that later navigations don't interfere + clientPageloadTxnEventPromise, + ]); + + // let's add a buffer because it seems like the hydrated flag isn't enough :( + // guess: The layout finishes hydration/mounting before the components within finish + // await page.waitForTimeout(10_000); + + debug && console.log('hydrated'); +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/.gitignore b/dev-packages/e2e-tests/test-applications/vue-3/.gitignore new file mode 100644 index 000000000000..8ee54e8d343e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/dev-packages/e2e-tests/test-applications/vue-3/.npmrc b/dev-packages/e2e-tests/test-applications/vue-3/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/vue-3/README.md b/dev-packages/e2e-tests/test-applications/vue-3/README.md new file mode 100644 index 000000000000..6af7bb60b866 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/README.md @@ -0,0 +1,3 @@ +# Vue 3 E2E Test App + +E2E test app for Vue 3 and `@sentry/vue`. diff --git a/dev-packages/e2e-tests/test-applications/vue-3/env.d.ts b/dev-packages/e2e-tests/test-applications/vue-3/env.d.ts new file mode 100644 index 000000000000..11f02fe2a006 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/vue-3/event-proxy-server.ts b/dev-packages/e2e-tests/test-applications/vue-3/event-proxy-server.ts new file mode 100644 index 000000000000..4c2df32399f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, SerializedEvent } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/index.html b/dev-packages/e2e-tests/test-applications/vue-3/index.html new file mode 100644 index 000000000000..a888544898a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json new file mode 100644 index 000000000000..1fa4cbcf3882 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -0,0 +1,42 @@ +{ + "name": "vue-3-tmp", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "npx rimraf node_modules,pnpm-lock.yaml,dist", + "dev": "vite", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build --force", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "playwright test" + }, + "dependencies": { + "@sentry/vue": "latest || *", + "vue": "^3.4.15", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@playwright/test": "^1.41.1", + "@sentry/types": "^7.99.0", + "@sentry/utils": "^7.99.0", + "@tsconfig/node20": "^20.1.2", + "@types/node": "^20.11.10", + "@vitejs/plugin-vue": "^5.0.3", + "@vitejs/plugin-vue-jsx": "^3.1.0", + "@vue/tsconfig": "^0.5.1", + "http-server": "^14.1.1", + "npm-run-all2": "^6.1.1", + "ts-node": "10.9.1", + "typescript": "~5.3.0", + "vite": "^5.0.11", + "vue-tsc": "^1.8.27", + "wait-port": "1.0.4" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/playwright.config.ts b/dev-packages/e2e-tests/test-applications/vue-3/playwright.config.ts new file mode 100644 index 000000000000..16dd640e58ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env['TEST_ENV'] || 'production'; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const vuePort = 4173; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + fullyParallel: false, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${vuePort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script --project tsconfig.proxy.json start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm preview --port ${vuePort}` + : `pnpm wait-port ${eventProxyPort} && pnpm preview --port ${vuePort}`, + port: vuePort, + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/vue-3/public/favicon.ico b/dev-packages/e2e-tests/test-applications/vue-3/public/favicon.ico new file mode 100644 index 000000000000..df36fcfb7258 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/vue-3/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/App.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/App.vue new file mode 100644 index 000000000000..08c38cecfda9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/App.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/assets/base.css b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/base.css new file mode 100644 index 000000000000..8816868a41b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/base.css @@ -0,0 +1,86 @@ +/* color palette from */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/assets/logo.svg b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/logo.svg new file mode 100644 index 000000000000..7565660356e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/assets/main.css b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/main.css new file mode 100644 index 000000000000..36fb845b5232 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/main.css @@ -0,0 +1,35 @@ +@import './base.css'; + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + font-weight: normal; +} + +a, +.green { + text-decoration: none; + color: hsla(160, 100%, 37%, 1); + transition: 0.4s; + padding: 3px; +} + +@media (hover: hover) { + a:hover { + background-color: hsla(160, 100%, 37%, 0.2); + } +} + +@media (min-width: 1024px) { + body { + display: flex; + place-items: center; + } + + #app { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 0 2rem; + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts new file mode 100644 index 000000000000..997c74fa0740 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts @@ -0,0 +1,26 @@ +import './assets/main.css'; + +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; + +import * as Sentry from '@sentry/vue'; +import { browserTracingIntegration } from '@sentry/vue'; + +const app = createApp(App); + +Sentry.init({ + app, + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + tracesSampleRate: 1.0, + integrations: [ + browserTracingIntegration({ + router, + }), + ], + tunnel: `http://localhost:3031/`, // proxy server + debug: true, +}); + +app.use(router); +app.mount('#app'); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts new file mode 100644 index 000000000000..a17208711eff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import HomeView from '../views/HomeView.vue'; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + component: HomeView, + }, + { + path: '/about', + name: 'AboutView', + component: () => import('../views/AboutView.vue'), + }, + { + path: '/users/:id', + component: () => import('../views/UserIdView.vue'), + }, + ], +}); + +export default router; diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/AboutView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/AboutView.vue new file mode 100644 index 000000000000..8c706352120a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/AboutView.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/HomeView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/HomeView.vue new file mode 100644 index 000000000000..92b38c308a6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/HomeView.vue @@ -0,0 +1,14 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/UserIdView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/UserIdView.vue new file mode 100644 index 000000000000..a6c973ef6e35 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/UserIdView.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/vue-3/start-event-proxy.ts new file mode 100644 index 000000000000..6435984ad069 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'vue-3', +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/errors.test.ts new file mode 100644 index 000000000000..508fe738bbc5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '../event-proxy-server'; + +test('sends an error', async ({ page }) => { + const errorPromise = waitForError('vue-3', async errorEvent => { + return !errorEvent?.transaction; + }); + + await page.goto(`/`); + + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'This is a Vue test error', + mechanism: { + type: 'generic', + handled: false, + }, + }, + ], + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts new file mode 100644 index 000000000000..dc5bd500eee3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/users/456`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.id': '456', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/users/:id', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.vue', + 'sentry.op': 'navigation', + 'params.id': '123', + }, + op: 'navigation', + origin: 'auto.navigation.vue', + }, + }, + transaction: '/users/:id', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a pageload transaction with a route name as transaction name if available', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/about`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: 'AboutView', + transaction_info: { + source: 'custom', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.app.json new file mode 100644 index 000000000000..e14c754d3ae5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.json new file mode 100644 index 000000000000..78f134a16dca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ], +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.node.json new file mode 100644 index 000000000000..2c669eeb8e8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.proxy.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.proxy.json new file mode 100644 index 000000000000..7ccdde196a3b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.proxy.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "moduleResolution": "Node", + "target": "ES2022", + "module": "ES2022", + }, + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node", + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts new file mode 100644 index 000000000000..72a15caeae52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts @@ -0,0 +1,16 @@ +import { URL, fileURLToPath } from 'node:url'; + +import vue from '@vitejs/plugin-vue'; +import vueJsx from '@vitejs/plugin-vue-jsx'; +import { defineConfig } from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue(), vueJsx()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + envPrefix: 'PUBLIC_', +}); diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index 0f1fdee05669..2ed138f1cdcc 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -128,6 +128,12 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/profiling-node': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/react': access: $all publish: $all diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 6bad3d7f7a71..4fc394a91a79 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -14,8 +14,8 @@ "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.mjs", "build:types": "tsc -p tsconfig.types.json", - "clean": "rimraf -g **/node_modules && run-p clean:docker:*", - "clean:docker:mysql2": "cd suites/tracing-experimental/mysql2 && docker-compose down --volumes", + "clean": "rimraf -g **/node_modules && run-p clean:docker", + "clean:docker": "node scripts/clean.js", "prisma:init": "(cd suites/tracing/prisma-orm && ts-node ./setup.ts)", "prisma:init:new": "(cd suites/tracing-new/prisma-orm && ts-node ./setup.ts)", "lint": "eslint . --format stylish", @@ -52,6 +52,9 @@ "proxy": "^2.1.1", "yargs": "^16.2.0" }, + "devDependencies": { + "globby": "11" + }, "config": { "mongodbMemoryServer": { "preferGlobalPath": true, diff --git a/dev-packages/node-integration-tests/scripts/clean.js b/dev-packages/node-integration-tests/scripts/clean.js new file mode 100644 index 000000000000..0610e39f92d4 --- /dev/null +++ b/dev-packages/node-integration-tests/scripts/clean.js @@ -0,0 +1,19 @@ +const { execSync } = require('child_process'); +const globby = require('globby'); +const { dirname, join } = require('path'); + +const cwd = join(__dirname, '..'); +const paths = globby.sync(['suites/**/docker-compose.yml'], { cwd }).map(path => join(cwd, dirname(path))); + +// eslint-disable-next-line no-console +console.log('Cleaning up docker containers and volumes...'); + +for (const path of paths) { + try { + // eslint-disable-next-line no-console + console.log(`docker compose down @ ${path}`); + execSync('docker compose down --volumes', { stdio: 'inherit', cwd: path }); + } catch (_) { + // + } +} diff --git a/dev-packages/node-integration-tests/suites/tracing-experimental/apollo-graphql/test.ts b/dev-packages/node-integration-tests/suites/tracing-experimental/apollo-graphql/test.ts index 96018c12ebeb..230c4cd4dac3 100644 --- a/dev-packages/node-integration-tests/suites/tracing-experimental/apollo-graphql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing-experimental/apollo-graphql/test.ts @@ -26,7 +26,7 @@ conditionalTest({ min: 14 })('GraphQL/Apollo Tests', () => { 'otel.kind': 'INTERNAL', 'sentry.origin': 'manual', }, - description: 'graphql.resolve', + description: 'graphql.resolve hello', status: 'ok', origin: 'manual', }), @@ -44,9 +44,7 @@ conditionalTest({ min: 14 })('GraphQL/Apollo Tests', () => { data: { 'graphql.operation.name': 'Mutation', 'graphql.operation.type': 'mutation', - 'graphql.source': `mutation Mutation($email: String) { - login(email: $email) -}`, + 'graphql.source': 'mutation Mutation($email: String) {\n login(email: $email)\n}', 'otel.kind': 'INTERNAL', 'sentry.origin': 'auto.graphql.otel.graphql', }, diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spans/scenario.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spans/scenario.ts new file mode 100644 index 000000000000..9b1abf466db1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spans/scenario.ts @@ -0,0 +1,21 @@ +import '@sentry/tracing'; + +import * as http from 'http'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.httpIntegration({})], + debug: true, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + http.get('http://match-this-url.com/api/v0'); + http.get('http://match-this-url.com/api/v1'); + + // Give it a tick to resolve... + await new Promise(resolve => setTimeout(resolve, 100)); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spans/test.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spans/test.ts new file mode 100644 index 000000000000..bd95db22de6e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spans/test.ts @@ -0,0 +1,40 @@ +import nock from 'nock'; + +import { TestEnv, assertSentryTransaction } from '../../../../utils'; + +test('should capture spans for outgoing http requests', async () => { + const match1 = nock('http://match-this-url.com').get('/api/v0').reply(200); + const match2 = nock('http://match-this-url.com').get('/api/v1').reply(200); + + const env = await TestEnv.init(__dirname); + const envelope = await env.getEnvelopeRequest({ envelopeType: 'transaction' }); + + expect(match1.isDone()).toBe(true); + expect(match2.isDone()).toBe(true); + + expect(envelope).toHaveLength(3); + + assertSentryTransaction(envelope[2], { + transaction: 'test_transaction', + spans: [ + { + description: 'GET http://match-this-url.com/api/v0', + op: 'http.client', + origin: 'auto.http.node.http', + status: 'ok', + tags: { + 'http.status_code': '200', + }, + }, + { + description: 'GET http://match-this-url.com/api/v1', + op: 'http.client', + origin: 'auto.http.node.http', + status: 'ok', + tags: { + 'http.status_code': '200', + }, + }, + ], + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spansDisabled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spansDisabled/scenario.ts new file mode 100644 index 000000000000..61711e974f7d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spansDisabled/scenario.ts @@ -0,0 +1,20 @@ +import '@sentry/tracing'; + +import * as http from 'http'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.httpIntegration({ tracing: false })], +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + http.get('http://match-this-url.com/api/v0'); + http.get('http://match-this-url.com/api/v1'); + + // Give it a tick to resolve... + await new Promise(resolve => setTimeout(resolve, 100)); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spansDisabled/test.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spansDisabled/test.ts new file mode 100644 index 000000000000..bacf5eaf1882 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/spansDisabled/test.ts @@ -0,0 +1,21 @@ +import nock from 'nock'; + +import { TestEnv, assertSentryTransaction } from '../../../../utils'; + +test('should not capture spans for outgoing http requests if tracing is disabled', async () => { + const match1 = nock('http://match-this-url.com').get('/api/v0').reply(200); + const match2 = nock('http://match-this-url.com').get('/api/v1').reply(200); + + const env = await TestEnv.init(__dirname); + const envelope = await env.getEnvelopeRequest({ envelopeType: 'transaction' }); + + expect(match1.isDone()).toBe(true); + expect(match2.isDone()).toBe(true); + + expect(envelope).toHaveLength(3); + + assertSentryTransaction(envelope[2], { + transaction: 'test_transaction', + spans: [], + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargets/scenario.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargets/scenario.ts new file mode 100644 index 000000000000..7794b20911f9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargets/scenario.ts @@ -0,0 +1,20 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import '@sentry/tracing'; + +import * as http from 'http'; +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: [Sentry.httpIntegration({})], +}); + +Sentry.startSpan({ name: 'test_transaction' }, () => { + http.get('http://match-this-url.com/api/v0'); + http.get('http://match-this-url.com/api/v1'); + http.get('http://dont-match-this-url.com/api/v2'); + http.get('http://dont-match-this-url.com/api/v3'); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargets/test.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargets/test.ts new file mode 100644 index 000000000000..59e4eff9e105 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargets/test.ts @@ -0,0 +1,42 @@ +import nock from 'nock'; + +import { TestEnv, runScenario } from '../../../../utils'; + +test('httpIntegration should instrument correct requests when tracePropagationTargets option is provided & tracing is enabled', async () => { + const match1 = nock('http://match-this-url.com') + .get('/api/v0') + .matchHeader('baggage', val => typeof val === 'string') + .matchHeader('sentry-trace', val => typeof val === 'string') + .reply(200); + + const match2 = nock('http://match-this-url.com') + .get('/api/v1') + .matchHeader('baggage', val => typeof val === 'string') + .matchHeader('sentry-trace', val => typeof val === 'string') + .reply(200); + + const match3 = nock('http://dont-match-this-url.com') + .get('/api/v2') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const match4 = nock('http://dont-match-this-url.com') + .get('/api/v3') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const env = await TestEnv.init(__dirname); + await runScenario(env.url); + + env.server.close(); + nock.cleanAll(); + + await new Promise(resolve => env.server.close(resolve)); + + expect(match1.isDone()).toBe(true); + expect(match2.isDone()).toBe(true); + expect(match3.isDone()).toBe(true); + expect(match4.isDone()).toBe(true); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargetsDisabled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargetsDisabled/scenario.ts new file mode 100644 index 000000000000..c04616f7db89 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargetsDisabled/scenario.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import '@sentry/tracing'; + +import * as http from 'http'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [Sentry.httpIntegration({})], +}); + +Sentry.startSpan({ name: 'test_transaction' }, () => { + http.get('http://match-this-url.com/api/v0'); + http.get('http://match-this-url.com/api/v1'); + http.get('http://dont-match-this-url.com/api/v2'); + http.get('http://dont-match-this-url.com/api/v3'); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargetsDisabled/test.ts b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargetsDisabled/test.ts new file mode 100644 index 000000000000..abc1ff025b78 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing-new/httpIntegration/tracePropagationTargetsDisabled/test.ts @@ -0,0 +1,42 @@ +import nock from 'nock'; + +import { TestEnv, runScenario } from '../../../../utils'; + +test('httpIntegration should not instrument when tracing is enabled', async () => { + const match1 = nock('http://match-this-url.com') + .get('/api/v0') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const match2 = nock('http://match-this-url.com') + .get('/api/v1') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const match3 = nock('http://dont-match-this-url.com') + .get('/api/v2') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const match4 = nock('http://dont-match-this-url.com') + .get('/api/v3') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const env = await TestEnv.init(__dirname); + await runScenario(env.url); + + env.server.close(); + nock.cleanAll(); + + await new Promise(resolve => env.server.close(resolve)); + + expect(match1.isDone()).toBe(true); + expect(match2.isDone()).toBe(true); + expect(match3.isDone()).toBe(true); + expect(match4.isDone()).toBe(true); +}); diff --git a/dev-packages/rollup-utils/bundleHelpers.mjs b/dev-packages/rollup-utils/bundleHelpers.mjs index b6ca7c8fcbc7..66bded3b62de 100644 --- a/dev-packages/rollup-utils/bundleHelpers.mjs +++ b/dev-packages/rollup-utils/bundleHelpers.mjs @@ -82,6 +82,7 @@ export function makeBaseBundleConfig(options) { ' for (var key in exports) {', ' if (Object.prototype.hasOwnProperty.call(exports, key)) {', ' __window.Sentry.Integrations[key] = exports[key];', + ' __window.Sentry[key] = exports[key];', ' }', ' }', ].join('\n'), diff --git a/dev-packages/rollup-utils/plugins/extractPolyfillsPlugin.mjs b/dev-packages/rollup-utils/plugins/extractPolyfillsPlugin.mjs index 0acbb175ebf8..ca5ff99438fd 100644 --- a/dev-packages/rollup-utils/plugins/extractPolyfillsPlugin.mjs +++ b/dev-packages/rollup-utils/plugins/extractPolyfillsPlugin.mjs @@ -42,7 +42,7 @@ export function makeExtractPolyfillsPlugin() { // The index.js file of the tuils package will include identifiers named after polyfills so we would inject the // polyfills, however that would override the exports so we should just skip that file. - const isUtilsPackage = process.cwd().endsWith('packages/utils'); + const isUtilsPackage = process.cwd().endsWith(`packages${path.sep}utils`); if (isUtilsPackage && sourceFile === 'index.js') { return null; } @@ -194,7 +194,9 @@ function createImportOrRequireNode(polyfillNodes, currentSourceFile, moduleForma // relative const isUtilsPackage = process.cwd().endsWith(path.join('packages', 'utils')); const importSource = literal( - isUtilsPackage ? `./${path.relative(path.dirname(currentSourceFile), 'buildPolyfills')}` : '@sentry/utils', + isUtilsPackage + ? `.${path.sep}${path.relative(path.dirname(currentSourceFile), 'buildPolyfills')}` + : '@sentry/utils', ); // This is the `x, y, z` of inside of `import { x, y, z }` or `var { x, y, z }` diff --git a/package.json b/package.json index da7149544f46..9ecacd34c252 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "clean:build": "lerna run clean", "clean:caches": "yarn rimraf eslintcache .nxcache && yarn jest --clearCache", "clean:deps": "lerna clean --yes && rm -rf node_modules && yarn", - "clean:all": "run-s clean:build clean:caches clean:deps", + "clean:tarballs": "rimraf **/*.tgz", + "clean:all": "run-s clean:build clean:tarballs clean:caches clean:deps", "codecov": "codecov", "fix": "run-s fix:biome fix:prettier fix:lerna", "fix:lerna": "lerna run fix", @@ -25,6 +26,7 @@ "changelog": "ts-node ./scripts/get-commit-list.ts", "link:yarn": "lerna exec yarn link", "lint": "run-s lint:lerna lint:biome lint:prettier", + "lint:clang": "lerna run lint:clang", "lint:lerna": "lerna run lint", "lint:biome": "biome check .", "lint:prettier": "prettier **/*.md *.md **/*.css --check", @@ -32,7 +34,7 @@ "postpublish": "lerna run --stream --concurrency 1 postpublish", "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\" test", "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\" test:unit", - "test-ci-browser": "lerna run test --ignore \"@sentry/{bun,deno,node,node-experimental,opentelemetry-node,serverless,nextjs,remix,gatsby,sveltekit,vercel-edge}\" --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\"", + "test-ci-browser": "lerna run test --ignore \"@sentry/{bun,deno,node,node-experimental,opentelemetry-node,profiling-node,serverless,nextjs,remix,gatsby,sveltekit,vercel-edge}\" --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\"", "test-ci-node": "ts-node ./scripts/node-unit-tests.ts", "test-ci-bun": "lerna run test --scope @sentry/bun", "test:update-snapshots": "lerna run test:update-snapshots", @@ -63,6 +65,7 @@ "packages/node-experimental", "packages/opentelemetry-node", "packages/opentelemetry", + "packages/profiling-node", "packages/react", "packages/remix", "packages/replay", diff --git a/packages/angular-ivy/README.md b/packages/angular-ivy/README.md index 6967e7570a82..438bb0fb5ff5 100644 --- a/packages/angular-ivy/README.md +++ b/packages/angular-ivy/README.md @@ -93,16 +93,14 @@ Registering a Trace Service is a 3-step process. instrumentation: ```javascript -import { init, instrumentAngularRouting, BrowserTracing } from '@sentry/angular-ivy'; +import { init, browserTracingIntegration } from '@sentry/angular-ivy'; init({ dsn: '__DSN__', - integrations: [ - new BrowserTracing({ - tracingOrigins: ['localhost', 'https://yourserver.io/api'], - routingInstrumentation: instrumentAngularRouting, - }), + integrations: [ + browserTracingIntegration(), ], + tracePropagationTargets: ['localhost', 'https://yourserver.io/api'], tracesSampleRate: 1, }); ``` diff --git a/packages/angular/README.md b/packages/angular/README.md index 302b060bdb39..a3e15a426196 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -93,16 +93,14 @@ Registering a Trace Service is a 3-step process. instrumentation: ```javascript -import { init, instrumentAngularRouting, BrowserTracing } from '@sentry/angular'; +import { init, browserTracingIntegration } from '@sentry/angular'; init({ dsn: '__DSN__', integrations: [ - new BrowserTracing({ - tracingOrigins: ['localhost', 'https://yourserver.io/api'], - routingInstrumentation: instrumentAngularRouting, - }), + browserTracingIntegration(), ], + tracePropagationTargets: ['localhost', 'https://yourserver.io/api'], tracesSampleRate: 1, }); ``` diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index f7f0536463a2..a2b1195c4e3c 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -7,9 +7,11 @@ export { createErrorHandler, SentryErrorHandler } from './errorhandler'; export { // eslint-disable-next-line deprecation/deprecation getActiveTransaction, - // TODO `instrumentAngularRouting` is just an alias for `routingInstrumentation`; deprecate the latter at some point + // eslint-disable-next-line deprecation/deprecation instrumentAngularRouting, // new name + // eslint-disable-next-line deprecation/deprecation routingInstrumentation, // legacy name + browserTracingIntegration, TraceClassDecorator, TraceMethodDecorator, TraceDirective, diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index efd2c840420b..5b2f74615c45 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -7,9 +7,21 @@ import type { ActivatedRouteSnapshot, Event, RouterState } from '@angular/router // eslint-disable-next-line @typescript-eslint/consistent-type-imports import { NavigationCancel, NavigationError, Router } from '@angular/router'; import { NavigationEnd, NavigationStart, ResolveEnd } from '@angular/router'; -import { WINDOW, getCurrentScope } from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; -import type { Span, Transaction, TransactionContext } from '@sentry/types'; +import { + WINDOW, + browserTracingIntegration as originalBrowserTracingIntegration, + getCurrentScope, + startBrowserTracingNavigationSpan, +} from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + getActiveSpan, + getClient, + spanToJSON, + startInactiveSpan, +} from '@sentry/core'; +import type { Integration, Span, Transaction, TransactionContext } from '@sentry/types'; import { logger, stripUrlQueryAndFragment, timestampInSeconds } from '@sentry/utils'; import type { Observable } from 'rxjs'; import { Subscription } from 'rxjs'; @@ -23,8 +35,12 @@ let instrumentationInitialized: boolean; let stashedStartTransaction: (context: TransactionContext) => Transaction | undefined; let stashedStartTransactionOnLocationChange: boolean; +let hooksBasedInstrumentation = false; + /** * Creates routing instrumentation for Angular Router. + * + * @deprecated Use `browserTracingIntegration()` instead, which includes Angular-specific instrumentation out of the box. */ export function routingInstrumentation( customStartTransaction: (context: TransactionContext) => Transaction | undefined, @@ -47,8 +63,35 @@ export function routingInstrumentation( } } +/** + * Creates routing instrumentation for Angular Router. + * + * @deprecated Use `browserTracingIntegration()` instead, which includes Angular-specific instrumentation out of the box. + */ +// eslint-disable-next-line deprecation/deprecation export const instrumentAngularRouting = routingInstrumentation; +/** + * A custom BrowserTracing integration for Angular. + * + * Use this integration in combination with `TraceService` + */ +export function browserTracingIntegration( + options: Parameters[0] = {}, +): Integration { + // If the user opts out to set this up, we just don't initialize this. + // That way, the TraceService will not actually do anything, functionally disabling this. + if (options.instrumentNavigation !== false) { + instrumentationInitialized = true; + hooksBasedInstrumentation = true; + } + + return originalBrowserTracingIntegration({ + ...options, + instrumentNavigation: false, + }); +} + /** * Grabs active transaction off scope. * @@ -74,7 +117,44 @@ export class TraceService implements OnDestroy { return; } + if (this._routingSpan) { + this._routingSpan.end(); + this._routingSpan = null; + } + + const client = getClient(); const strippedUrl = stripUrlQueryAndFragment(navigationEvent.url); + + if (client && hooksBasedInstrumentation) { + if (!getActiveSpan()) { + startBrowserTracingNavigationSpan(client, { + name: strippedUrl, + op: 'navigation', + origin: 'auto.navigation.angular', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + } + + // eslint-disable-next-line deprecation/deprecation + this._routingSpan = + startInactiveSpan({ + name: `${navigationEvent.url}`, + op: ANGULAR_ROUTING_OP, + origin: 'auto.ui.angular', + tags: { + 'routing.instrumentation': '@sentry/angular', + url: strippedUrl, + ...(navigationEvent.navigationTrigger && { + navigationTrigger: navigationEvent.navigationTrigger, + }), + }, + }) || null; + + return; + } + // eslint-disable-next-line deprecation/deprecation let activeTransaction = getActiveTransaction(); @@ -90,9 +170,6 @@ export class TraceService implements OnDestroy { } if (activeTransaction) { - if (this._routingSpan) { - this._routingSpan.end(); - } // eslint-disable-next-line deprecation/deprecation this._routingSpan = activeTransaction.startChild({ description: `${navigationEvent.url}`, @@ -132,6 +209,7 @@ export class TraceService implements OnDestroy { if (transaction && attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'url') { transaction.updateName(route); transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, `auto.${spanToJSON(transaction).op}.angular`); } }), ); diff --git a/packages/angular/test/tracing.test.ts b/packages/angular/test/tracing.test.ts index c2406f628128..31bd13473253 100644 --- a/packages/angular/test/tracing.test.ts +++ b/packages/angular/test/tracing.test.ts @@ -44,6 +44,7 @@ describe('Angular Tracing', () => { transaction = undefined; }); + /* eslint-disable deprecation/deprecation */ describe('instrumentAngularRouting', () => { it('should attach the transaction source on the pageload transaction', () => { const startTransaction = jest.fn(); @@ -57,6 +58,7 @@ describe('Angular Tracing', () => { }); }); }); + /* eslint-enable deprecation/deprecation */ describe('getParameterizedRouteFromSnapshot', () => { it.each([ diff --git a/packages/angular/test/utils/index.ts b/packages/angular/test/utils/index.ts index 83a416ca2a03..390d7fbe14ac 100644 --- a/packages/angular/test/utils/index.ts +++ b/packages/angular/test/utils/index.ts @@ -50,6 +50,7 @@ export class TestEnv { useTraceService?: boolean; additionalProviders?: Provider[]; }): Promise { + // eslint-disable-next-line deprecation/deprecation instrumentAngularRouting( conf.customStartTransaction || jest.fn(), conf.startTransactionOnPageLoad !== undefined ? conf.startTransactionOnPageLoad : true, diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 8d2b70ee6751..a289296c2ab2 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -1,6 +1,6 @@ import type { BrowserOptions } from '@sentry/browser'; import { - BrowserTracing, + browserTracingIntegration, getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk, setTag, @@ -34,7 +34,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] | undefi // in which case everything inside will get treeshaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { if (hasTracingEnabled(options)) { - return [...getBrowserDefaultIntegrations(options), new BrowserTracing()]; + return [...getBrowserDefaultIntegrations(options), browserTracingIntegration()]; } } diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 8d4bb2a7e371..98e5486894db 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -49,6 +49,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, withScope, @@ -70,6 +71,17 @@ export { // eslint-disable-next-line deprecation/deprecation deepReadDirSync, Integrations, + consoleIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + modulesIntegration, + contextLinesIntegration, + nodeContextIntegration, + localVariablesIntegration, + requestDataIntegration, + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, Handlers, setMeasurement, getActiveSpan, diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 8a7cc3d90384..cd59d2f73344 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -94,97 +94,95 @@ async function instrumentRequest( const { method, headers } = ctx.request; - const traceCtx = continueTrace({ - sentryTrace: headers.get('sentry-trace') || undefined, - baggage: headers.get('baggage'), - }); + return continueTrace( + { + sentryTrace: headers.get('sentry-trace') || undefined, + baggage: headers.get('baggage'), + }, + async () => { + const allHeaders: Record = {}; - const allHeaders: Record = {}; + if (options.trackHeaders) { + headers.forEach((value, key) => { + allHeaders[key] = value; + }); + } - if (options.trackHeaders) { - headers.forEach((value, key) => { - allHeaders[key] = value; - }); - } + if (options.trackClientIp) { + getCurrentScope().setUser({ ip_address: ctx.clientAddress }); + } - if (options.trackClientIp) { - getCurrentScope().setUser({ ip_address: ctx.clientAddress }); - } + try { + const interpolatedRoute = interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params); + const source = interpolatedRoute ? 'route' : 'url'; + // storing res in a variable instead of directly returning is necessary to + // invoke the catch block if next() throws + const res = await startSpan( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro', + }, + name: `${method} ${interpolatedRoute || ctx.url.pathname}`, + op: 'http.server', + status: 'ok', + data: { + method, + url: stripUrlQueryAndFragment(ctx.url.href), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + ...(ctx.url.search && { 'http.query': ctx.url.search }), + ...(ctx.url.hash && { 'http.fragment': ctx.url.hash }), + ...(options.trackHeaders && { headers: allHeaders }), + }, + }, + async span => { + const originalResponse = await next(); - try { - const interpolatedRoute = interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params); - const source = interpolatedRoute ? 'route' : 'url'; - // storing res in a variable instead of directly returning is necessary to - // invoke the catch block if next() throws - const res = await startSpan( - { - ...traceCtx, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro', - }, - name: `${method} ${interpolatedRoute || ctx.url.pathname}`, - op: 'http.server', - status: 'ok', - metadata: { - // eslint-disable-next-line deprecation/deprecation - ...traceCtx?.metadata, - }, - data: { - method, - url: stripUrlQueryAndFragment(ctx.url.href), - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - ...(ctx.url.search && { 'http.query': ctx.url.search }), - ...(ctx.url.hash && { 'http.fragment': ctx.url.hash }), - ...(options.trackHeaders && { headers: allHeaders }), - }, - }, - async span => { - const originalResponse = await next(); - - if (span && originalResponse.status) { - setHttpStatus(span, originalResponse.status); - } - - const scope = getCurrentScope(); - const client = getClient(); - const contentType = originalResponse.headers.get('content-type'); - - const isPageloadRequest = contentType && contentType.startsWith('text/html'); - if (!isPageloadRequest || !client) { - return originalResponse; - } - - // Type case necessary b/c the body's ReadableStream type doesn't include - // the async iterator that is actually available in Node - // We later on use the async iterator to read the body chunks - // see https://github.com/microsoft/TypeScript/issues/39051 - const originalBody = originalResponse.body as NodeJS.ReadableStream | null; - if (!originalBody) { - return originalResponse; - } - - const decoder = new TextDecoder(); - - const newResponseStream = new ReadableStream({ - start: async controller => { - for await (const chunk of originalBody) { - const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); - const modifiedHtml = addMetaTagToHead(html, scope, client, span); - controller.enqueue(new TextEncoder().encode(modifiedHtml)); + if (span && originalResponse.status) { + setHttpStatus(span, originalResponse.status); } - controller.close(); - }, - }); - return new Response(newResponseStream, originalResponse); - }, - ); - return res; - } catch (e) { - sendErrorToSentry(e); - throw e; - } - // TODO: flush if serverless (first extract function) + const scope = getCurrentScope(); + const client = getClient(); + const contentType = originalResponse.headers.get('content-type'); + + const isPageloadRequest = contentType && contentType.startsWith('text/html'); + if (!isPageloadRequest || !client) { + return originalResponse; + } + + // Type case necessary b/c the body's ReadableStream type doesn't include + // the async iterator that is actually available in Node + // We later on use the async iterator to read the body chunks + // see https://github.com/microsoft/TypeScript/issues/39051 + const originalBody = originalResponse.body as NodeJS.ReadableStream | null; + if (!originalBody) { + return originalResponse; + } + + const decoder = new TextDecoder(); + + const newResponseStream = new ReadableStream({ + start: async controller => { + for await (const chunk of originalBody) { + const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + const modifiedHtml = addMetaTagToHead(html, scope, client, span); + controller.enqueue(new TextEncoder().encode(modifiedHtml)); + } + controller.close(); + }, + }); + + return new Response(newResponseStream, originalResponse); + }, + ); + return res; + } catch (e) { + sendErrorToSentry(e); + throw e; + } + // TODO: flush if serverless (first extract function) + }, + ); } /** diff --git a/packages/astro/test/client/sdk.test.ts b/packages/astro/test/client/sdk.test.ts index 3960c25eccd3..d6f22dc9ed7a 100644 --- a/packages/astro/test/client/sdk.test.ts +++ b/packages/astro/test/client/sdk.test.ts @@ -104,12 +104,14 @@ describe('Sentry client SDK', () => { it('Overrides the automatically default BrowserTracing instance with a a user-provided BrowserTracing instance', () => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', + // eslint-disable-next-line deprecation/deprecation integrations: [new BrowserTracing({ finalTimeout: 10, startTransactionOnLocationChange: false })], enableTracing: true, }); const integrationsToInit = browserInit.mock.calls[0][0]?.defaultIntegrations; + // eslint-disable-next-line deprecation/deprecation const browserTracing = getClient()?.getIntegrationByName('BrowserTracing') as BrowserTracing; const options = browserTracing.options; @@ -120,7 +122,7 @@ describe('Sentry client SDK', () => { expect(options.finalTimeout).toEqual(10); }); - it('Overrides the automatically default BrowserTracing instance with a a user-provided browserTracingIntergation instance', () => { + it('Overrides the automatically default BrowserTracing instance with a a user-provided browserTracingIntegration instance', () => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index c641f5ac6177..a83de3cb0eb2 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -66,7 +66,6 @@ describe('sentryMiddleware', () => { url: 'https://mydomain.io/users/123/details', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', }, - metadata: {}, name: 'GET /users/[id]/details', op: 'http.server', status: 'ok', @@ -104,7 +103,6 @@ describe('sentryMiddleware', () => { url: 'http://localhost:1234/a%xx', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }, - metadata: {}, name: 'GET a%xx', op: 'http.server', status: 'ok', @@ -144,43 +142,6 @@ describe('sentryMiddleware', () => { }); }); - it('attaches tracing headers', async () => { - const middleware = handleRequest(); - const ctx = { - request: { - method: 'GET', - url: '/users', - headers: new Headers({ - 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', - baggage: 'sentry-release=1.0.0', - }), - }, - params: {}, - url: new URL('https://myDomain.io/users/'), - }; - const next = vi.fn(() => nextResult); - - // @ts-expect-error, a partial ctx object is fine here - await middleware(ctx, next); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }), - metadata: { - dynamicSamplingContext: { - release: '1.0.0', - }, - }, - parentSampled: true, - parentSpanId: '1234567890123456', - traceId: '12345678901234567890123456789012', - }), - expect.any(Function), // the `next` function - ); - }); - it('attaches client IP and request headers if options are set', async () => { const middleware = handleRequest({ trackClientIp: true, trackHeaders: true }); const ctx = { diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 5fff014eaa8d..35930167672d 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -201,6 +201,7 @@ export function bundleBrowserTracingIntegration( options: Parameters[0] = {}, ): Integration { // Migrate some options from the old integration to the new one + // eslint-disable-next-line deprecation/deprecation const opts: ConstructorParameters[0] = options; if (typeof options.markBackgroundSpan === 'boolean') { @@ -215,5 +216,6 @@ export function bundleBrowserTracingIntegration( opts.startTransactionOnLocationChange = options.instrumentNavigation; } + // eslint-disable-next-line deprecation/deprecation return new BrowserTracing(opts); } diff --git a/packages/browser/src/index.bundle.feedback.ts b/packages/browser/src/index.bundle.feedback.ts index af4de5ea063d..8e653c2d4757 100644 --- a/packages/browser/src/index.bundle.feedback.ts +++ b/packages/browser/src/index.bundle.feedback.ts @@ -14,10 +14,12 @@ import * as Sentry from './index.bundle.base'; // eslint-disable-next-line deprecation/deprecation Sentry.Integrations.Replay = Replay; +// eslint-disable-next-line deprecation/deprecation Sentry.Integrations.BrowserTracing = BrowserTracing; export * from './index.bundle.base'; export { + // eslint-disable-next-line deprecation/deprecation BrowserTracing, browserTracingIntegration, addTracingExtensions, diff --git a/packages/browser/src/index.bundle.replay.ts b/packages/browser/src/index.bundle.replay.ts index 175a435fadcf..2e4619ab49ea 100644 --- a/packages/browser/src/index.bundle.replay.ts +++ b/packages/browser/src/index.bundle.replay.ts @@ -14,10 +14,12 @@ import * as Sentry from './index.bundle.base'; // eslint-disable-next-line deprecation/deprecation Sentry.Integrations.Replay = Replay; +// eslint-disable-next-line deprecation/deprecation Sentry.Integrations.BrowserTracing = BrowserTracing; export * from './index.bundle.base'; export { + // eslint-disable-next-line deprecation/deprecation BrowserTracing, browserTracingIntegration, addTracingExtensions, diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index df151bba0a8f..4a015f0dd9fe 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -11,6 +11,7 @@ import * as Sentry from './index.bundle.base'; // eslint-disable-next-line deprecation/deprecation Sentry.Integrations.Replay = Replay; +// eslint-disable-next-line deprecation/deprecation Sentry.Integrations.BrowserTracing = BrowserTracing; // We are patching the global object with our hub extension methods @@ -23,6 +24,7 @@ export { Replay, feedbackIntegration, replayIntegration, + // eslint-disable-next-line deprecation/deprecation BrowserTracing, browserTracingIntegration, Span, diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index 2437a8546d5c..c880f97bd84f 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -11,6 +11,7 @@ import * as Sentry from './index.bundle.base'; // eslint-disable-next-line deprecation/deprecation Sentry.Integrations.Replay = Replay; +// eslint-disable-next-line deprecation/deprecation Sentry.Integrations.BrowserTracing = BrowserTracing; // We are patching the global object with our hub extension methods @@ -23,6 +24,7 @@ export { Replay, replayIntegration, feedbackIntegration, + // eslint-disable-next-line deprecation/deprecation BrowserTracing, browserTracingIntegration, Span, diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index 2ca0613146f0..e645de683dd5 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -11,6 +11,7 @@ import * as Sentry from './index.bundle.base'; // eslint-disable-next-line deprecation/deprecation Sentry.Integrations.Replay = Replay; +// eslint-disable-next-line deprecation/deprecation Sentry.Integrations.BrowserTracing = BrowserTracing; // We are patching the global object with our hub extension methods @@ -23,6 +24,7 @@ export { Replay, feedbackIntegration, replayIntegration, + // eslint-disable-next-line deprecation/deprecation BrowserTracing, browserTracingIntegration, Span, diff --git a/packages/browser/src/index.bundle.ts b/packages/browser/src/index.bundle.ts index 93a0b0cb498a..3087d7d317ca 100644 --- a/packages/browser/src/index.bundle.ts +++ b/packages/browser/src/index.bundle.ts @@ -15,10 +15,12 @@ import * as Sentry from './index.bundle.base'; // eslint-disable-next-line deprecation/deprecation Sentry.Integrations.Replay = Replay; +// eslint-disable-next-line deprecation/deprecation Sentry.Integrations.BrowserTracing = BrowserTracing; export * from './index.bundle.base'; export { + // eslint-disable-next-line deprecation/deprecation BrowserTracing, addTracingExtensions, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 408a64081a02..2be5c71c4518 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -54,6 +54,7 @@ export { } from '@sentry-internal/feedback'; export { + // eslint-disable-next-line deprecation/deprecation BrowserTracing, defaultRequestInstrumentationOptions, instrumentOutgoingRequests, @@ -72,6 +73,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, makeMultiplexedTransport, @@ -83,4 +85,8 @@ export type { SpanStatusType } from '@sentry/core'; export type { Span } from '@sentry/types'; export { makeBrowserOfflineTransport } from './transports/offline'; export { onProfilingStartRouteTransaction } from './profiling/hubextensions'; -export { BrowserProfilingIntegration } from './profiling/integration'; +export { + // eslint-disable-next-line deprecation/deprecation + BrowserProfilingIntegration, + browserProfilingIntegration, +} from './profiling/integration'; diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index a3af7744c4e4..bf8e56a626b5 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,4 +1,4 @@ -import { convertIntegrationFnToClass, getCurrentScope } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration, getCurrentScope } from '@sentry/core'; import type { Client, EventEnvelope, Integration, IntegrationClass, IntegrationFn, Transaction } from '@sentry/types'; import type { Profile } from '@sentry/types/src/profiling'; import { logger } from '@sentry/utils'; @@ -18,7 +18,7 @@ import { const INTEGRATION_NAME = 'BrowserProfiling'; -const browserProfilingIntegration = (() => { +const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, // TODO v8: Remove this @@ -102,6 +102,8 @@ const browserProfilingIntegration = (() => { }; }) satisfies IntegrationFn; +export const browserProfilingIntegration = defineIntegration(_browserProfilingIntegration); + /** * Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"] * This exists because we do not want to await async profiler.stop calls as transaction.finish is called @@ -110,9 +112,13 @@ const browserProfilingIntegration = (() => { * integration less reliable as we might be dropping profiles when the cache is full. * * @experimental + * @deprecated Use `browserProfilingIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const BrowserProfilingIntegration = convertIntegrationFnToClass( INTEGRATION_NAME, browserProfilingIntegration, ) as IntegrationClass void }>; + +// eslint-disable-next-line deprecation/deprecation +export type BrowserProfilingIntegration = typeof BrowserProfilingIntegration; diff --git a/packages/browser/test/unit/profiling/integration.test.ts b/packages/browser/test/unit/profiling/integration.test.ts index b69d3a52d655..9394221b0e4b 100644 --- a/packages/browser/test/unit/profiling/integration.test.ts +++ b/packages/browser/test/unit/profiling/integration.test.ts @@ -1,7 +1,6 @@ import type { BrowserClient } from '@sentry/browser'; import * as Sentry from '@sentry/browser'; -import { BrowserProfilingIntegration } from '../../../src/profiling/integration'; import type { JSSelfProfile } from '../../../src/profiling/jsSelfProfiling'; describe('BrowserProfilingIntegration', () => { @@ -44,7 +43,7 @@ describe('BrowserProfilingIntegration', () => { send, }; }, - integrations: [new Sentry.BrowserTracing(), new BrowserProfilingIntegration()], + integrations: [Sentry.browserTracingIntegration(), Sentry.browserProfilingIntegration()], }); const client = Sentry.getClient(); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 5742597485e0..b51083052c8b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -69,6 +69,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, withScope, @@ -104,6 +105,18 @@ export { extractRequestData, getSentryRelease, addRequestDataToEvent, + anrIntegration, + consoleIntegration, + contextLinesIntegration, + hapiIntegration, + httpIntegration, + localVariablesIntegration, + modulesIntegration, + nativeNodeFetchintegration, + nodeContextIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + spotlightIntegration, } from '@sentry/node'; export { BunClient } from './client'; @@ -116,14 +129,15 @@ export { import { Integrations as CoreIntegrations } from '@sentry/core'; import { Integrations as NodeIntegrations } from '@sentry/node'; - -import * as BunIntegrations from './integrations'; +import { BunServer } from './integrations/bunserver'; +export { bunServerIntegration } from './integrations/bunserver'; const INTEGRATIONS = { // eslint-disable-next-line deprecation/deprecation ...CoreIntegrations, + // eslint-disable-next-line deprecation/deprecation ...NodeIntegrations, - ...BunIntegrations, + BunServer, }; export { INTEGRATIONS as Integrations }; diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index b1dc4c6892e0..e262cd4e70a4 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -5,6 +5,7 @@ import { captureException, continueTrace, convertIntegrationFnToClass, + defineIntegration, getCurrentScope, runWithAsyncContext, setHttpStatus, @@ -15,7 +16,7 @@ import { getSanitizedUrlString, parseUrl } from '@sentry/utils'; const INTEGRATION_NAME = 'BunServer'; -const bunServerIntegration = (() => { +const _bunServerIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { @@ -24,8 +25,12 @@ const bunServerIntegration = (() => { }; }) satisfies IntegrationFn; +export const bunServerIntegration = defineIntegration(_bunServerIntegration); + /** * Instruments `Bun.serve` to automatically create transactions and capture errors. + * + * @deprecated Use `bunServerIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const BunServer = convertIntegrationFnToClass(INTEGRATION_NAME, bunServerIntegration); diff --git a/packages/bun/src/integrations/index.ts b/packages/bun/src/integrations/index.ts deleted file mode 100644 index 95d17cf80e66..000000000000 --- a/packages/bun/src/integrations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BunServer } from './bunserver'; diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index ab637bc7a59e..f8dbcb99c0df 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -1,36 +1,47 @@ /* eslint-disable max-lines */ -import { FunctionToString, InboundFilters, LinkedErrors } from '@sentry/core'; -import { Integrations as NodeIntegrations, init as initNode } from '@sentry/node'; +import { + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, + requestDataIntegration, +} from '@sentry/core'; +import { + consoleIntegration, + contextLinesIntegration, + httpIntegration, + init as initNode, + modulesIntegration, + nativeNodeFetchintegration, + nodeContextIntegration, +} from '@sentry/node'; import type { Integration, Options } from '@sentry/types'; import { BunClient } from './client'; -import { BunServer } from './integrations'; +import { bunServerIntegration } from './integrations/bunserver'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; /** @deprecated Use `getDefaultIntegrations(options)` instead. */ export const defaultIntegrations = [ - /* eslint-disable deprecation/deprecation */ // Common - new InboundFilters(), - new FunctionToString(), - new LinkedErrors(), - /* eslint-enable deprecation/deprecation */ + inboundFiltersIntegration(), + functionToStringIntegration(), + linkedErrorsIntegration(), + requestDataIntegration(), // Native Wrappers - new NodeIntegrations.Console(), - new NodeIntegrations.Http(), - new NodeIntegrations.Undici(), + consoleIntegration(), + httpIntegration(), + nativeNodeFetchintegration(), // Global Handlers # TODO (waiting for https://github.com/oven-sh/bun/issues/5091) // new NodeIntegrations.OnUncaughtException(), // new NodeIntegrations.OnUnhandledRejection(), // Event Info - new NodeIntegrations.ContextLines(), + contextLinesIntegration(), // new NodeIntegrations.LocalVariables(), # does't work with Bun - new NodeIntegrations.Context(), - new NodeIntegrations.Modules(), - new NodeIntegrations.RequestData(), + nodeContextIntegration(), + modulesIntegration(), // Bun Specific - new BunServer(), + bunServerIntegration(), ]; /** Get the default integrations for the Bun SDK. */ diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index b3eb10f686d7..b7f00e14baef 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -222,8 +222,11 @@ export abstract class BaseClient implements Client { let eventId: string | undefined = hint && hint.event_id; + const sdkProcessingMetadata = event.sdkProcessingMetadata || {}; + const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope; + this._process( - this._captureEvent(event, hint, scope).then(result => { + this._captureEvent(event, hint, capturedSpanScope || scope).then(result => { eventId = result; }), ); @@ -753,7 +756,10 @@ export abstract class BaseClient implements Client { const dataCategory: DataCategory = eventType === 'replay_event' ? 'replay' : eventType; - return this._prepareEvent(event, hint, scope) + const sdkProcessingMetadata = event.sdkProcessingMetadata || {}; + const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope; + + return this._prepareEvent(event, hint, scope, capturedSpanIsolationScope) .then(prepared => { if (prepared === null) { this.recordDroppedEvent('event_processor', dataCategory, event); diff --git a/packages/core/src/tracing/hubextensions.ts b/packages/core/src/tracing/hubextensions.ts index e35543f16631..51748ea72351 100644 --- a/packages/core/src/tracing/hubextensions.ts +++ b/packages/core/src/tracing/hubextensions.ts @@ -65,8 +65,14 @@ The transaction will not be sampled. Please use the ${configInstrumenter} instru // eslint-disable-next-line deprecation/deprecation let transaction = new Transaction(transactionContext, this); transaction = sampleTransaction(transaction, options, { + name: transactionContext.name, parentSampled: transactionContext.parentSampled, transactionContext, + attributes: { + // eslint-disable-next-line deprecation/deprecation + ...transactionContext.data, + ...transactionContext.attributes, + }, ...customSamplingContext, }); if (transaction.isRecording()) { @@ -106,8 +112,14 @@ export function startIdleTransaction( delayAutoFinishUntilSignal, ); transaction = sampleTransaction(transaction, options, { + name: transactionContext.name, parentSampled: transactionContext.parentSampled, transactionContext, + attributes: { + // eslint-disable-next-line deprecation/deprecation + ...transactionContext.data, + ...transactionContext.attributes, + }, ...customSamplingContext, }); if (transaction.isRecording()) { diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index dc822a2bab7d..832180ef3c72 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -1,6 +1,6 @@ -import type { Span, SpanTimeInput, StartSpanOptions, TransactionContext } from '@sentry/types'; +import type { Scope, Span, SpanTimeInput, StartSpanOptions, TransactionContext } from '@sentry/types'; -import { dropUndefinedKeys, logger, tracingContextFromHeaders } from '@sentry/utils'; +import { addNonEnumerableProperty, dropUndefinedKeys, logger, tracingContextFromHeaders } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; import { getCurrentScope, withScope } from '../exports'; @@ -189,20 +189,22 @@ export function startInactiveSpan(context: StartSpanOptions): Span | undefined { return undefined; } + const isolationScope = getIsolationScope(); + const scope = getCurrentScope(); + + let span: Span | undefined; + if (parentSpan) { // eslint-disable-next-line deprecation/deprecation - return parentSpan.startChild(ctx); + span = parentSpan.startChild(ctx); } else { - const isolationScope = getIsolationScope(); - const scope = getCurrentScope(); - const { traceId, dsc, parentSpanId, sampled } = { ...isolationScope.getPropagationContext(), ...scope.getPropagationContext(), }; // eslint-disable-next-line deprecation/deprecation - return hub.startTransaction({ + span = hub.startTransaction({ traceId, parentSpanId, parentSampled: sampled, @@ -214,6 +216,10 @@ export function startInactiveSpan(context: StartSpanOptions): Span | undefined { }, }); } + + setCapturedScopesOnSpan(span, scope, isolationScope); + + return span; } /** @@ -224,42 +230,81 @@ export function getActiveSpan(): Span | undefined { return getCurrentScope().getSpan(); } -export function continueTrace({ - sentryTrace, - baggage, -}: { - sentryTrace: Parameters[0]; - baggage: Parameters[1]; -}): Partial; -export function continueTrace( - { +interface ContinueTrace { + /** + * Continue a trace from `sentry-trace` and `baggage` values. + * These values can be obtained from incoming request headers, + * or in the browser from `` and `` HTML tags. + * + * @deprecated Use the version of this function taking a callback as second parameter instead: + * + * ``` + * Sentry.continueTrace(sentryTrace: '...', baggage: '...' }, () => { + * // ... + * }) + * ``` + * + */ + ({ sentryTrace, baggage, }: { + // eslint-disable-next-line deprecation/deprecation sentryTrace: Parameters[0]; + // eslint-disable-next-line deprecation/deprecation baggage: Parameters[1]; - }, - callback: (transactionContext: Partial) => V, -): V; -/** - * Continue a trace from `sentry-trace` and `baggage` values. - * These values can be obtained from incoming request headers, - * or in the browser from `` and `` HTML tags. - * - * The callback receives a transactionContext that may be used for `startTransaction` or `startSpan`. - */ -export function continueTrace( + }): Partial; + + /** + * Continue a trace from `sentry-trace` and `baggage` values. + * These values can be obtained from incoming request headers, or in the browser from `` + * and `` HTML tags. + * + * Spans started with `startSpan`, `startSpanManual` and `startInactiveSpan`, within the callback will automatically + * be attached to the incoming trace. + * + * Deprecation notice: In the next major version of the SDK the provided callback will not receive a transaction + * context argument. + */ + ( + { + sentryTrace, + baggage, + }: { + // eslint-disable-next-line deprecation/deprecation + sentryTrace: Parameters[0]; + // eslint-disable-next-line deprecation/deprecation + baggage: Parameters[1]; + }, + // TODO(v8): Remove parameter from this callback. + callback: (transactionContext: Partial) => V, + ): V; +} + +export const continueTrace: ContinueTrace = ( { sentryTrace, baggage, }: { + // eslint-disable-next-line deprecation/deprecation sentryTrace: Parameters[0]; + // eslint-disable-next-line deprecation/deprecation baggage: Parameters[1]; }, callback?: (transactionContext: Partial) => V, -): V | Partial { +): V | Partial => { + // TODO(v8): Change this function so it doesn't do anything besides setting the propagation context on the current scope: + /* + return withScope((scope) => { + const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); + scope.setPropagationContext(propagationContext); + return callback(); + }) + */ + const currentScope = getCurrentScope(); + // eslint-disable-next-line deprecation/deprecation const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( sentryTrace, baggage, @@ -282,8 +327,10 @@ export function continueTrace( return transactionContext; } - return callback(transactionContext); -} + return runWithAsyncContext(() => { + return callback(transactionContext); + }); +}; function createChildSpanOrTransaction( hub: Hub, @@ -294,20 +341,21 @@ function createChildSpanOrTransaction( return undefined; } + const isolationScope = getIsolationScope(); + const scope = getCurrentScope(); + + let span: Span | undefined; if (parentSpan) { // eslint-disable-next-line deprecation/deprecation - return parentSpan.startChild(ctx); + span = parentSpan.startChild(ctx); } else { - const isolationScope = getIsolationScope(); - const scope = getCurrentScope(); - const { traceId, dsc, parentSpanId, sampled } = { ...isolationScope.getPropagationContext(), ...scope.getPropagationContext(), }; // eslint-disable-next-line deprecation/deprecation - return hub.startTransaction({ + span = hub.startTransaction({ traceId, parentSpanId, parentSampled: sampled, @@ -319,6 +367,10 @@ function createChildSpanOrTransaction( }, }); } + + setCapturedScopesOnSpan(span, scope, isolationScope); + + return span; } /** @@ -338,3 +390,28 @@ function normalizeContext(context: StartSpanOptions): TransactionContext { return context; } + +const SCOPE_ON_START_SPAN_FIELD = '_sentryScope'; +const ISOLATION_SCOPE_ON_START_SPAN_FIELD = '_sentryIsolationScope'; + +type SpanWithScopes = Span & { + [SCOPE_ON_START_SPAN_FIELD]?: Scope; + [ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: Scope; +}; + +function setCapturedScopesOnSpan(span: Span | undefined, scope: Scope, isolationScope: Scope): void { + if (span) { + addNonEnumerableProperty(span, ISOLATION_SCOPE_ON_START_SPAN_FIELD, isolationScope); + addNonEnumerableProperty(span, SCOPE_ON_START_SPAN_FIELD, scope); + } +} + +/** + * Grabs the scope and isolation scope off a span that were active when the span was started. + */ +export function getCapturedScopesOnSpan(span: Span): { scope?: Scope; isolationScope?: Scope } { + return { + scope: (span as SpanWithScopes)[SCOPE_ON_START_SPAN_FIELD], + isolationScope: (span as SpanWithScopes)[ISOLATION_SCOPE_ON_START_SPAN_FIELD], + }; +} diff --git a/packages/core/src/tracing/transaction.ts b/packages/core/src/tracing/transaction.ts index 490714636fe9..026723929471 100644 --- a/packages/core/src/tracing/transaction.ts +++ b/packages/core/src/tracing/transaction.ts @@ -19,6 +19,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE import { spanTimeInputToSeconds, spanToJSON, spanToTraceContext } from '../utils/spanUtils'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { Span as SpanClass, SpanRecorder } from './span'; +import { getCapturedScopesOnSpan } from './trace'; /** JSDoc */ export class Transaction extends SpanClass implements TransactionInterface { @@ -303,6 +304,8 @@ export class Transaction extends SpanClass implements TransactionInterface { }); } + const { scope: capturedSpanScope, isolationScope: capturedSpanIsolationScope } = getCapturedScopesOnSpan(this); + // eslint-disable-next-line deprecation/deprecation const { metadata } = this; // eslint-disable-next-line deprecation/deprecation @@ -324,6 +327,8 @@ export class Transaction extends SpanClass implements TransactionInterface { type: 'transaction', sdkProcessingMetadata: { ...metadata, + capturedSpanScope, + capturedSpanIsolationScope, dynamicSamplingContext: getDynamicSamplingContextFromSpan(this), }, ...(source && { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index fca086f10c94..4c9190e56b6a 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -1,9 +1,11 @@ +import type { Span as SpanType } from '@sentry/types'; import { Hub, SEMANTIC_ATTRIBUTE_SENTRY_OP, addTracingExtensions, getCurrentScope, makeMain, + setCurrentClient, spanToJSON, withScope, } from '../../../src'; @@ -357,9 +359,79 @@ describe('startSpan', () => { expect(span).toBeDefined(); }); }); + + it('samples with a tracesSampler', () => { + const tracesSampler = jest.fn(() => { + return true; + }); + + const options = getDefaultTestClientOptions({ tracesSampler }); + client = new TestClient(options); + setCurrentClient(client); + + startSpan( + { name: 'outer', attributes: { test1: 'aa', test2: 'aa' }, data: { test1: 'bb', test3: 'bb' } }, + outerSpan => { + expect(outerSpan).toBeDefined(); + }, + ); + + expect(tracesSampler).toBeCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer', + attributes: { + test1: 'aa', + test2: 'aa', + test3: 'bb', + }, + transactionContext: expect.objectContaining({ name: 'outer', parentSampled: undefined }), + }); + }); + + it('includes the scope at the time the span was started when finished', async () => { + const transactionEventPromise = new Promise(resolve => { + setCurrentClient( + new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction(event) { + resolve(event); + return event; + }, + }), + ), + ); + }); + + withScope(scope1 => { + scope1.setTag('scope', 1); + startSpanManual({ name: 'my-span' }, span => { + withScope(scope2 => { + scope2.setTag('scope', 2); + span?.end(); + }); + }); + }); + + expect(await transactionEventPromise).toMatchObject({ + tags: { + scope: 1, + }, + }); + }); }); describe('startSpanManual', () => { + beforeEach(() => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); + client = new TestClient(options); + hub = new Hub(client); + // eslint-disable-next-line deprecation/deprecation + makeMain(hub); + }); + it('creates & finishes span', async () => { startSpanManual({ name: 'GET users/[id]' }, (span, finish) => { expect(span).toBeDefined(); @@ -462,6 +534,14 @@ describe('startSpanManual', () => { }); describe('startInactiveSpan', () => { + beforeEach(() => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); + client = new TestClient(options); + hub = new Hub(client); + // eslint-disable-next-line deprecation/deprecation + makeMain(hub); + }); + it('creates & finishes span', async () => { const span = startInactiveSpan({ name: 'GET users/[id]' }); @@ -541,6 +621,41 @@ describe('startInactiveSpan', () => { expect(span).toBeDefined(); }); }); + + it('includes the scope at the time the span was started when finished', async () => { + const transactionEventPromise = new Promise(resolve => { + setCurrentClient( + new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction(event) { + resolve(event); + return event; + }, + }), + ), + ); + }); + + let span: SpanType | undefined; + + withScope(scope => { + scope.setTag('scope', 1); + span = startInactiveSpan({ name: 'my-span' }); + }); + + withScope(scope => { + scope.setTag('scope', 2); + span?.end(); + }); + + expect(await transactionEventPromise).toMatchObject({ + tags: { + scope: 1, + }, + }); + }); }); describe('continueTrace', () => { @@ -728,6 +843,7 @@ describe('continueTrace', () => { traceId: '12312012123120121231201212312012', }; + // eslint-disable-next-line deprecation/deprecation const ctx = continueTrace({ sentryTrace: '12312012123120121231201212312012-1121201211212012-0', baggage: undefined, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f5ed9651bf94..d42ad97fedb8 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -68,6 +68,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, withScope, diff --git a/packages/deno/src/integrations/globalhandlers.ts b/packages/deno/src/integrations/globalhandlers.ts index 0c830c40da25..2c562a7aa0f9 100644 --- a/packages/deno/src/integrations/globalhandlers.ts +++ b/packages/deno/src/integrations/globalhandlers.ts @@ -48,7 +48,7 @@ export const globalHandlersIntegration = defineIntegration(_globalHandlersIntegr /** * Global handlers. - * @deprecated Use `globalHandlersIntergation()` instead. + * @deprecated Use `globalHandlersIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const GlobalHandlers = convertIntegrationFnToClass( diff --git a/packages/ember/addon/instance-initializers/sentry-performance.ts b/packages/ember/addon/instance-initializers/sentry-performance.ts index acabe5334cad..f4c47998ea90 100644 --- a/packages/ember/addon/instance-initializers/sentry-performance.ts +++ b/packages/ember/addon/instance-initializers/sentry-performance.ts @@ -8,18 +8,13 @@ import type { EmberRunQueues } from '@ember/runloop/-private/types'; import { getOwnConfig, isTesting, macroCondition } from '@embroider/macros'; import * as Sentry from '@sentry/browser'; import type { ExtendedBackburner } from '@sentry/ember/runloop'; -import type { Span, Transaction } from '@sentry/types'; +import type { Span } from '@sentry/types'; import { GLOBAL_OBJ, browserPerformanceTimeOrigin, timestampInSeconds } from '@sentry/utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { BrowserClient } from '..'; import { getActiveSpan, startInactiveSpan } from '..'; -import type { EmberRouterMain, EmberSentryConfig, GlobalConfig, OwnConfig, StartTransactionFunction } from '../types'; - -type SentryTestRouterService = RouterService & { - _startTransaction?: StartTransactionFunction; - _sentryInstrumented?: boolean; -}; +import type { EmberRouterMain, EmberSentryConfig, GlobalConfig, OwnConfig } from '../types'; function getSentryConfig(): EmberSentryConfig { const _global = GLOBAL_OBJ as typeof GLOBAL_OBJ & GlobalConfig; @@ -98,26 +93,29 @@ export function _instrumentEmberRouter( routerService: RouterService, routerMain: EmberRouterMain, config: EmberSentryConfig, - startTransaction: StartTransactionFunction, - startTransactionOnPageLoad?: boolean, -): { - startTransaction: StartTransactionFunction; -} { +): void { const { disableRunloopPerformance } = config; const location = routerMain.location; - let activeTransaction: Transaction | undefined; + let activeRootSpan: Span | undefined; let transitionSpan: Span | undefined; + // Maintaining backwards compatibility with config.browserTracingOptions, but passing it with Sentry options is preferred. + const browserTracingOptions = config.browserTracingOptions || config.sentry.browserTracingOptions || {}; const url = getLocationURL(location); - if (macroCondition(isTesting())) { - (routerService as SentryTestRouterService)._sentryInstrumented = true; - (routerService as SentryTestRouterService)._startTransaction = startTransaction; + const client = Sentry.getClient(); + + if (!client) { + return; } - if (startTransactionOnPageLoad && url) { + if ( + url && + browserTracingOptions.startTransactionOnPageLoad !== false && + browserTracingOptions.instrumentPageLoad !== false + ) { const routeInfo = routerService.recognize(url); - activeTransaction = startTransaction({ + Sentry.startBrowserTracingPageLoadSpan(client, { name: `route:${routeInfo.name}`, op: 'pageload', origin: 'auto.pageload.ember', @@ -127,20 +125,29 @@ export function _instrumentEmberRouter( 'routing.instrumentation': '@sentry/ember', }, }); + activeRootSpan = getActiveSpan(); } const finishActiveTransaction = (_: unknown, nextInstance: unknown): void => { if (nextInstance) { return; } - activeTransaction?.end(); + activeRootSpan?.end(); getBackburner().off('end', finishActiveTransaction); }; + if ( + browserTracingOptions.startTransactionOnLocationChange === false && + browserTracingOptions.instrumentNavigation === false + ) { + return; + } + routerService.on('routeWillChange', (transition: Transition) => { const { fromRoute, toRoute } = getTransitionInformation(transition, routerService); - activeTransaction?.end(); - activeTransaction = startTransaction({ + activeRootSpan?.end(); + + Sentry.startBrowserTracingNavigationSpan(client, { name: `route:${toRoute}`, op: 'navigation', origin: 'auto.navigation.ember', @@ -150,6 +157,9 @@ export function _instrumentEmberRouter( 'routing.instrumentation': '@sentry/ember', }, }); + + activeRootSpan = getActiveSpan(); + transitionSpan = startInactiveSpan({ attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', @@ -160,22 +170,18 @@ export function _instrumentEmberRouter( }); routerService.on('routeDidChange', () => { - if (!transitionSpan || !activeTransaction) { + if (!transitionSpan || !activeRootSpan) { return; } transitionSpan.end(); if (disableRunloopPerformance) { - activeTransaction.end(); + activeRootSpan.end(); return; } getBackburner().on('end', finishActiveTransaction); }); - - return { - startTransaction, - }; } function _instrumentEmberRunloop(config: EmberSentryConfig): void { @@ -411,61 +417,63 @@ export async function instrumentForPerformance(appInstance: ApplicationInstance) // Maintaining backwards compatibility with config.browserTracingOptions, but passing it with Sentry options is preferred. const browserTracingOptions = config.browserTracingOptions || config.sentry.browserTracingOptions || {}; - const { BrowserTracing } = await import('@sentry/browser'); + const { browserTracingIntegration } = await import('@sentry/browser'); const idleTimeout = config.transitionTimeout || 5000; - const browserTracing = new BrowserTracing({ - routingInstrumentation: (customStartTransaction, startTransactionOnPageLoad) => { - // eslint-disable-next-line ember/no-private-routing-service - const routerMain = appInstance.lookup('router:main') as EmberRouterMain; - let routerService = appInstance.lookup('service:router') as RouterService & { - externalRouter?: RouterService; - _hasMountedSentryPerformanceRouting?: boolean; - }; - - if (routerService.externalRouter) { - // Using ember-engines-router-service in an engine. - routerService = routerService.externalRouter; - } - if (routerService._hasMountedSentryPerformanceRouting) { - // Routing listens to route changes on the main router, and should not be initialized multiple times per page. - return; - } - if (!routerService.recognize) { - // Router is missing critical functionality to limit cardinality of the transaction names. - return; - } - routerService._hasMountedSentryPerformanceRouting = true; - _instrumentEmberRouter(routerService, routerMain, config, customStartTransaction, startTransactionOnPageLoad); - }, + const browserTracing = browserTracingIntegration({ idleTimeout, ...browserTracingOptions, + instrumentNavigation: false, + instrumentPageLoad: false, }); - if (macroCondition(isTesting())) { - const client = Sentry.getClient(); - - if ( - client && - (client as BrowserClient).getIntegrationByName && - (client as BrowserClient).getIntegrationByName('BrowserTracing') - ) { - // Initializers are called more than once in tests, causing the integrations to not be setup correctly. - return; - } - } + const client = Sentry.getClient(); + + const isAlreadyInitialized = macroCondition(isTesting()) ? !!client?.getIntegrationByName('BrowserTracing') : false; - const client = Sentry.getClient(); if (client && client.addIntegration) { client.addIntegration(browserTracing); } + // We _always_ call this, as it triggers the page load & navigation spans + _instrumentNavigation(appInstance, config); + + // Skip instrumenting the stuff below again in tests, as these are not reset between tests + if (isAlreadyInitialized) { + return; + } + _instrumentEmberRunloop(config); _instrumentComponents(config); _instrumentInitialLoad(config); } +function _instrumentNavigation(appInstance: ApplicationInstance, config: EmberSentryConfig): void { + // eslint-disable-next-line ember/no-private-routing-service + const routerMain = appInstance.lookup('router:main') as EmberRouterMain; + let routerService = appInstance.lookup('service:router') as RouterService & { + externalRouter?: RouterService; + _hasMountedSentryPerformanceRouting?: boolean; + }; + + if (routerService.externalRouter) { + // Using ember-engines-router-service in an engine. + routerService = routerService.externalRouter; + } + if (routerService._hasMountedSentryPerformanceRouting) { + // Routing listens to route changes on the main router, and should not be initialized multiple times per page. + return; + } + if (!routerService.recognize) { + // Router is missing critical functionality to limit cardinality of the transaction names. + return; + } + + routerService._hasMountedSentryPerformanceRouting = true; + _instrumentEmberRouter(routerService, routerMain, config); +} + export default { initialize, }; diff --git a/packages/ember/addon/types.ts b/packages/ember/addon/types.ts index 787eecc7e4cf..bf333740ee5b 100644 --- a/packages/ember/addon/types.ts +++ b/packages/ember/addon/types.ts @@ -1,7 +1,9 @@ -import type { BrowserOptions, BrowserTracing } from '@sentry/browser'; +import type { BrowserOptions, BrowserTracing, browserTracingIntegration } from '@sentry/browser'; import type { Transaction, TransactionContext } from '@sentry/types'; -type BrowserTracingOptions = ConstructorParameters[0]; +type BrowserTracingOptions = Parameters[0] & + // eslint-disable-next-line deprecation/deprecation + ConstructorParameters[0]; export type EmberSentryConfig = { sentry: BrowserOptions & { browserTracingOptions?: BrowserTracingOptions }; @@ -31,7 +33,7 @@ export interface EmberRouterMain { rootURL: string; }; } - +/** @deprecated This will be removed in v8. */ export type StartTransactionFunction = (context: TransactionContext) => Transaction | undefined; export type GlobalConfig = { diff --git a/packages/ember/tests/helpers/setup-sentry.ts b/packages/ember/tests/helpers/setup-sentry.ts index d8bb513dcd00..db439d226dc2 100644 --- a/packages/ember/tests/helpers/setup-sentry.ts +++ b/packages/ember/tests/helpers/setup-sentry.ts @@ -1,14 +1,7 @@ -import type RouterService from '@ember/routing/router-service'; import type { TestContext } from '@ember/test-helpers'; import { resetOnerror, setupOnerror } from '@ember/test-helpers'; -import { _instrumentEmberRouter } from '@sentry/ember/instance-initializers/sentry-performance'; -import type { EmberRouterMain, EmberSentryConfig, StartTransactionFunction } from '@sentry/ember/types'; import sinon from 'sinon'; -// Keep a reference to the original startTransaction as the application gets re-initialized and setup for -// the integration doesn't occur again after the first time. -let _routerStartTransaction: StartTransactionFunction | undefined; - export type SentryTestContext = TestContext & { errorMessages: string[]; fetchStub: sinon.SinonStub; @@ -16,11 +9,6 @@ export type SentryTestContext = TestContext & { _windowOnError: OnErrorEventHandler; }; -type SentryRouterService = RouterService & { - _startTransaction: StartTransactionFunction; - _sentryInstrumented?: boolean; -}; - export function setupSentryTest(hooks: NestedHooks): void { hooks.beforeEach(async function (this: SentryTestContext) { await window._sentryPerformanceLoad; @@ -28,16 +16,6 @@ export function setupSentryTest(hooks: NestedHooks): void { const errorMessages: string[] = []; this.errorMessages = errorMessages; - // eslint-disable-next-line ember/no-private-routing-service - const routerMain = this.owner.lookup('router:main') as EmberRouterMain; - const routerService = this.owner.lookup('service:router') as SentryRouterService; - - if (routerService._sentryInstrumented) { - _routerStartTransaction = routerService._startTransaction; - } else if (_routerStartTransaction) { - _instrumentEmberRouter(routerService, routerMain, {} as EmberSentryConfig, _routerStartTransaction); - } - /** * Stub out fetch function to assert on Sentry calls. */ diff --git a/packages/ember/tests/helpers/utils.ts b/packages/ember/tests/helpers/utils.ts index a14088a2329e..0be2c3d2f422 100644 --- a/packages/ember/tests/helpers/utils.ts +++ b/packages/ember/tests/helpers/utils.ts @@ -58,16 +58,19 @@ export function assertSentryTransactions( const sentryTestEvents = getTestSentryTransactions(); const event = sentryTestEvents[callNumber]; - assert.ok(event); - assert.ok(event.spans); + assert.ok(event, 'event exists'); + assert.ok(event.spans, 'event has spans'); const spans = event.spans || []; // instead of checking the specific order of runloop spans (which is brittle), // we check (below) that _any_ runloop spans are added + // Also we ignore ui.long-task spans, as they are brittle and may or may not appear const filteredSpans = spans - // eslint-disable-next-line deprecation/deprecation - .filter(span => !span.op?.startsWith('ui.ember.runloop.')) + .filter(span => { + const op = spanToJSON(span).op; + return !op?.startsWith('ui.ember.runloop.') && !op?.startsWith('ui.long-task'); + }) .map(s => { // eslint-disable-next-line deprecation/deprecation return `${s.op} | ${spanToJSON(s).description}`; diff --git a/packages/feedback/src/constants.ts b/packages/feedback/src/constants.ts index 6e3e9055b511..07782968375f 100644 --- a/packages/feedback/src/constants.ts +++ b/packages/feedback/src/constants.ts @@ -9,7 +9,7 @@ const LIGHT_BACKGROUND = '#ffffff'; const INHERIT = 'inherit'; const SUBMIT_COLOR = 'rgba(108, 95, 199, 1)'; const LIGHT_THEME = { - fontFamily: "'Helvetica Neue', Arial, sans-serif", + fontFamily: "system-ui, 'Helvetica Neue', Arial, sans-serif", fontSize: '14px', background: LIGHT_BACKGROUND, diff --git a/packages/feedback/src/widget/Logo.ts b/packages/feedback/src/widget/Logo.ts index 17333bda87ed..9e286a970961 100644 --- a/packages/feedback/src/widget/Logo.ts +++ b/packages/feedback/src/widget/Logo.ts @@ -33,8 +33,13 @@ export function Logo({ colorScheme }: Props): IconReturn { const defs = createElementNS('defs'); const style = createElementNS('style'); + style.textContent = ` + path { + fill: ${colorScheme === 'dark' ? '#fff' : '#362d59'}; + }`; + if (colorScheme === 'system') { - style.textContent = ` + style.textContent += ` @media (prefers-color-scheme: dark) { path: { fill: '#fff'; @@ -43,11 +48,6 @@ export function Logo({ colorScheme }: Props): IconReturn { `; } - style.textContent = ` - path { - fill: ${colorScheme === 'dark' ? '#fff' : '#362d59'}; - }`; - defs.append(style); svg.append(defs); diff --git a/packages/gatsby/src/utils/integrations.ts b/packages/gatsby/src/utils/integrations.ts index 94ef28f21272..7c61adee1d50 100644 --- a/packages/gatsby/src/utils/integrations.ts +++ b/packages/gatsby/src/utils/integrations.ts @@ -1,5 +1,5 @@ import { hasTracingEnabled } from '@sentry/core'; -import { BrowserTracing } from '@sentry/react'; +import { browserTracingIntegration } from '@sentry/react'; import type { Integration } from '@sentry/types'; import type { GatsbyOptions } from './types'; @@ -31,8 +31,8 @@ export function getIntegrationsFromOptions(options: GatsbyOptions): UserIntegrat * @param isTracingEnabled Whether the user has enabled tracing. */ function getIntegrationsFromArray(userIntegrations: Integration[], isTracingEnabled: boolean): Integration[] { - if (isTracingEnabled && !userIntegrations.some(integration => integration.name === BrowserTracing.name)) { - userIntegrations.push(new BrowserTracing()); + if (isTracingEnabled && !userIntegrations.some(integration => integration.name === 'BrowserTracing')) { + userIntegrations.push(browserTracingIntegration()); } return userIntegrations; } diff --git a/packages/gatsby/test/gatsby-browser.test.ts b/packages/gatsby/test/gatsby-browser.test.ts index b67305042c71..cf456a9d3e9d 100644 --- a/packages/gatsby/test/gatsby-browser.test.ts +++ b/packages/gatsby/test/gatsby-browser.test.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { onClientEntry } from '../gatsby-browser'; -import { BrowserTracing } from '../src/index'; +import { browserTracingIntegration } from '../src/index'; (global as any).__SENTRY_RELEASE__ = '683f3a6ab819d47d23abfca9a914c81f0524d35b'; (global as any).__SENTRY_DSN__ = 'https://examplePublicKey@o0.ingest.sentry.io/0'; @@ -141,7 +141,7 @@ describe('onClientEntry', () => { }); it('only defines a single `BrowserTracing` integration', () => { - const integrations = [new BrowserTracing()]; + const integrations = [browserTracingIntegration()]; onClientEntry(undefined, { tracesSampleRate: 0.5, integrations }); expect(sentryInit).toHaveBeenLastCalledWith( diff --git a/packages/gatsby/test/sdk.test.ts b/packages/gatsby/test/sdk.test.ts index c3c95cdabc1f..28206d1ef6c5 100644 --- a/packages/gatsby/test/sdk.test.ts +++ b/packages/gatsby/test/sdk.test.ts @@ -1,4 +1,4 @@ -import { BrowserTracing, SDK_VERSION, init } from '@sentry/react'; +import { SDK_VERSION, browserTracingIntegration, init } from '@sentry/react'; import type { Integration } from '@sentry/types'; import { init as gatsbyInit } from '../src/sdk'; @@ -68,27 +68,27 @@ describe('Integrations from options', () => { [ 'tracing disabled, with BrowserTracing as an array', [], - { integrations: [new BrowserTracing()] }, + { integrations: [browserTracingIntegration()] }, ['BrowserTracing'], ], [ 'tracing disabled, with BrowserTracing as a function', [], { - integrations: () => [new BrowserTracing()], + integrations: () => [browserTracingIntegration()], }, ['BrowserTracing'], ], [ 'tracing enabled, with BrowserTracing as an array', [], - { tracesSampleRate: 1, integrations: [new BrowserTracing()] }, + { tracesSampleRate: 1, integrations: [browserTracingIntegration()] }, ['BrowserTracing'], ], [ 'tracing enabled, with BrowserTracing as a function', [], - { tracesSampleRate: 1, integrations: () => [new BrowserTracing()] }, + { tracesSampleRate: 1, integrations: () => [browserTracingIntegration()] }, ['BrowserTracing'], ], ] as TestArgs[])( diff --git a/packages/integration-shims/src/BrowserTracing.ts b/packages/integration-shims/src/BrowserTracing.ts index 8e3d61bae58f..1c68faf30469 100644 --- a/packages/integration-shims/src/BrowserTracing.ts +++ b/packages/integration-shims/src/BrowserTracing.ts @@ -5,6 +5,8 @@ import { consoleSandbox } from '@sentry/utils'; * This is a shim for the BrowserTracing integration. * It is needed in order for the CDN bundles to continue working when users add/remove tracing * from it, without changing their config. This is necessary for the loader mechanism. + * + * @deprecated Use `browserTracingIntegration()` instead. */ class BrowserTracingShim implements Integration { /** @@ -19,6 +21,7 @@ class BrowserTracingShim implements Integration { // eslint-disable-next-line @typescript-eslint/no-explicit-any public constructor(_options: any) { + // eslint-disable-next-line deprecation/deprecation this.name = BrowserTracingShim.id; consoleSandbox(() => { @@ -39,10 +42,15 @@ class BrowserTracingShim implements Integration { * from it, without changing their config. This is necessary for the loader mechanism. */ function browserTracingIntegrationShim(_options: unknown): Integration { + // eslint-disable-next-line deprecation/deprecation return new BrowserTracingShim({}); } -export { BrowserTracingShim as BrowserTracing, browserTracingIntegrationShim as browserTracingIntegration }; +export { + // eslint-disable-next-line deprecation/deprecation + BrowserTracingShim as BrowserTracing, + browserTracingIntegrationShim as browserTracingIntegration, +}; /** Shim function */ export function addTracingExtensions(): void { diff --git a/packages/integration-shims/src/Feedback.ts b/packages/integration-shims/src/Feedback.ts index 232e2be0830b..7b717e3a4e3b 100644 --- a/packages/integration-shims/src/Feedback.ts +++ b/packages/integration-shims/src/Feedback.ts @@ -6,7 +6,7 @@ import { consoleSandbox } from '@sentry/utils'; * It is needed in order for the CDN bundles to continue working when users add/remove feedback * from it, without changing their config. This is necessary for the loader mechanism. * - * @deprecated Use `feedbackIntergation()` instead. + * @deprecated Use `feedbackIntegration()` instead. */ class FeedbackShim implements Integration { /** diff --git a/packages/integrations/scripts/buildBundles.ts b/packages/integrations/scripts/buildBundles.ts index b5eb77730d40..97730f10afe2 100644 --- a/packages/integrations/scripts/buildBundles.ts +++ b/packages/integrations/scripts/buildBundles.ts @@ -17,6 +17,7 @@ function getIntegrations(): string[] { async function buildBundle(integration: string, jsVersion: string): Promise { return new Promise((resolve, reject) => { const child = spawn('yarn', ['--silent', 'rollup', '--config', 'rollup.bundle.config.mjs'], { + shell: true, // required to run on Windows env: { ...process.env, INTEGRATION_FILE: integration, JS_VERSION: jsVersion }, }); diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index c3eb18887301..af8f59f53b6f 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,10 +1,21 @@ -import { BrowserTracing as OriginalBrowserTracing, defaultRequestInstrumentationOptions } from '@sentry/react'; +import { + BrowserTracing as OriginalBrowserTracing, + browserTracingIntegration as originalBrowserTracingIntegration, + defaultRequestInstrumentationOptions, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/react'; +import type { Integration, StartSpanOptions } from '@sentry/types'; import { nextRouterInstrumentation } from '../index.client'; /** * A custom BrowserTracing integration for Next.js. + * + * @deprecated Use `browserTracingIntegration` instead. */ +// eslint-disable-next-line deprecation/deprecation export class BrowserTracing extends OriginalBrowserTracing { + // eslint-disable-next-line deprecation/deprecation public constructor(options?: ConstructorParameters[0]) { super({ // eslint-disable-next-line deprecation/deprecation @@ -19,8 +30,71 @@ export class BrowserTracing extends OriginalBrowserTracing { ] : // eslint-disable-next-line deprecation/deprecation [...defaultRequestInstrumentationOptions.tracingOrigins, /^(api\/)/], + // eslint-disable-next-line deprecation/deprecation routingInstrumentation: nextRouterInstrumentation, ...options, }); } } + +/** + * A custom BrowserTracing integration for Next.js. + */ +export function browserTracingIntegration( + options?: Parameters[0], +): Integration { + const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ + // eslint-disable-next-line deprecation/deprecation + tracingOrigins: + process.env.NODE_ENV === 'development' + ? [ + // Will match any URL that contains "localhost" but not "webpack.hot-update.json" - The webpack dev-server + // has cors and it doesn't like extra headers when it's accessed from a different URL. + // TODO(v8): Ideally we rework our tracePropagationTargets logic so this hack won't be necessary anymore (see issue #9764) + /^(?=.*localhost)(?!.*webpack\.hot-update\.json).*/, + /^\/(?!\/)/, + ] + : // eslint-disable-next-line deprecation/deprecation + [...defaultRequestInstrumentationOptions.tracingOrigins, /^(api\/)/], + ...options, + instrumentNavigation: false, + instrumentPageLoad: false, + }); + + return { + ...browserTracingIntegrationInstance, + afterAllSetup(client) { + const startPageloadCallback = (startSpanOptions: StartSpanOptions): void => { + startBrowserTracingPageLoadSpan(client, startSpanOptions); + }; + + const startNavigationCallback = (startSpanOptions: StartSpanOptions): void => { + startBrowserTracingNavigationSpan(client, startSpanOptions); + }; + + // We need to run the navigation span instrumentation before the `afterAllSetup` hook on the normal browser + // tracing integration because we need to ensure the order of execution is as follows: + // Instrumentation to start span on RSC fetch request runs -> Instrumentation to put tracing headers from active span on fetch runs + // If it were the other way around, the RSC fetch request would not receive the tracing headers from the navigation transaction. + // eslint-disable-next-line deprecation/deprecation + nextRouterInstrumentation( + () => undefined, + false, + options?.instrumentNavigation, + startPageloadCallback, + startNavigationCallback, + ); + + browserTracingIntegrationInstance.afterAllSetup(client); + + // eslint-disable-next-line deprecation/deprecation + nextRouterInstrumentation( + () => undefined, + options?.instrumentPageLoad, + false, + startPageloadCallback, + startNavigationCallback, + ); + }, + }; +} diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index a1c20937f578..e0d22445a3a1 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -1,5 +1,5 @@ import { applySdkMetadata, hasTracingEnabled } from '@sentry/core'; -import type { BrowserOptions, browserTracingIntegration } from '@sentry/react'; +import type { BrowserOptions } from '@sentry/react'; import { Integrations as OriginalIntegrations, getCurrentScope, @@ -10,11 +10,13 @@ import type { EventProcessor, Integration } from '@sentry/types'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; +import { browserTracingIntegration } from './browserTracingIntegration'; import { BrowserTracing } from './browserTracingIntegration'; import { rewriteFramesIntegration } from './rewriteFramesIntegration'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; +// eslint-disable-next-line deprecation/deprecation export { nextRouterInstrumentation } from './routing/nextRoutingInstrumentation'; export { captureUnderscoreErrorException } from '../common/_error'; @@ -35,6 +37,7 @@ export const Integrations = { // // import { BrowserTracing } from '@sentry/nextjs'; // const instance = new BrowserTracing(); +// eslint-disable-next-line deprecation/deprecation export { BrowserTracing, rewriteFramesIntegration }; // Treeshakable guard to remove all code related to tracing @@ -68,7 +71,7 @@ export function init(options: BrowserOptions): void { } // TODO v8: Remove this again -// We need to handle BrowserTracing passed to `integrations` that comes from `@sentry/tracing`, not `@sentry/sveltekit` :( +// We need to handle BrowserTracing passed to `integrations` that comes from `@sentry/tracing`, not `@sentry/nextjs` :( function fixBrowserTracingIntegration(options: BrowserOptions): void { const { integrations } = options; if (!integrations) { @@ -89,6 +92,7 @@ function fixBrowserTracingIntegration(options: BrowserOptions): void { function isNewBrowserTracingIntegration( integration: Integration, ): integration is Integration & { options?: Parameters[0] } { + // eslint-disable-next-line deprecation/deprecation return !!integration.afterAllSetup && !!(integration as BrowserTracing).options; } @@ -102,17 +106,21 @@ function maybeUpdateBrowserTracingIntegration(integrations: Integration[]): Inte // If `browserTracingIntegration()` was added, we need to force-convert it to our custom one if (isNewBrowserTracingIntegration(browserTracing)) { const { options } = browserTracing; + // eslint-disable-next-line deprecation/deprecation integrations[integrations.indexOf(browserTracing)] = new BrowserTracing(options); } // If BrowserTracing was added, but it is not our forked version, // replace it with our forked version with the same options + // eslint-disable-next-line deprecation/deprecation if (!(browserTracing instanceof BrowserTracing)) { + // eslint-disable-next-line deprecation/deprecation const options: ConstructorParameters[0] = (browserTracing as BrowserTracing).options; // This option is overwritten by the custom integration delete options.routingInstrumentation; // eslint-disable-next-line deprecation/deprecation delete options.tracingOrigins; + // eslint-disable-next-line deprecation/deprecation integrations[integrations.indexOf(browserTracing)] = new BrowserTracing(options); } @@ -126,7 +134,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] { // will get treeshaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { if (hasTracingEnabled(options)) { - customDefaultIntegrations.push(new BrowserTracing()); + customDefaultIntegrations.push(browserTracingIntegration()); } } @@ -140,4 +148,6 @@ export function withSentryConfig(exportedUserNextConfig: T): T { return exportedUserNextConfig; } +export { browserTracingIntegration } from './browserTracingIntegration'; + export * from '../common'; diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 3083013e084a..25ec697a2161 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,30 +1,34 @@ import { WINDOW } from '@sentry/react'; -import type { Primitive, Transaction, TransactionContext } from '@sentry/types'; +import type { Primitive, Span, StartSpanOptions, Transaction, TransactionContext } from '@sentry/types'; import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils'; type StartTransactionCb = (context: TransactionContext) => Transaction | undefined; +type StartSpanCb = (context: StartSpanOptions) => void; const DEFAULT_TAGS = { 'routing.instrumentation': 'next-app-router', } as const; /** - * Instruments the Next.js Clientside App Router. + * Instruments the Next.js Client App Router. */ +// TODO(v8): Clean this function up by splitting into pageload and navigation instrumentation respectively. Also remove startTransactionCb in the process. export function appRouterInstrumentation( startTransactionCb: StartTransactionCb, startTransactionOnPageLoad: boolean = true, startTransactionOnLocationChange: boolean = true, + startPageloadSpanCallback: StartSpanCb, + startNavigationSpanCallback: StartSpanCb, ): void { // We keep track of the active transaction so we can finish it when we start a navigation transaction. - let activeTransaction: Transaction | undefined = undefined; + let activeTransaction: Span | undefined = undefined; // We keep track of the previous location name so we can set the `from` field on navigation transactions. // This is either a route or a pathname. let prevLocationName = WINDOW.location.pathname; if (startTransactionOnPageLoad) { - activeTransaction = startTransactionCb({ + const transactionContext = { name: prevLocationName, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', @@ -32,7 +36,9 @@ export function appRouterInstrumentation( // pageload should always start at timeOrigin (and needs to be in s, not ms) startTimestamp: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined, metadata: { source: 'url' }, - }); + } as const; + activeTransaction = startTransactionCb(transactionContext); + startPageloadSpanCallback(transactionContext); } if (startTransactionOnLocationChange) { @@ -66,13 +72,16 @@ export function appRouterInstrumentation( activeTransaction.end(); } - startTransactionCb({ + const transactionContext = { name: transactionName, op: 'navigation', origin: 'auto.navigation.nextjs.app_router_instrumentation', tags, metadata: { source: 'url' }, - }); + } as const; + + startTransactionCb(transactionContext); + startNavigationSpanCallback(transactionContext); }); } } diff --git a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts index 3010faad4183..4706fb8a32f2 100644 --- a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts @@ -1,23 +1,40 @@ import { WINDOW } from '@sentry/react'; -import type { Transaction, TransactionContext } from '@sentry/types'; +import type { StartSpanOptions, Transaction, TransactionContext } from '@sentry/types'; import { appRouterInstrumentation } from './appRouterRoutingInstrumentation'; import { pagesRouterInstrumentation } from './pagesRouterRoutingInstrumentation'; type StartTransactionCb = (context: TransactionContext) => Transaction | undefined; +type StartSpanCb = (context: StartSpanOptions) => void; /** - * Instruments the Next.js Clientside Router. + * Instruments the Next.js Client Router. + * + * @deprecated Use `browserTracingIntegration()` as exported from `@sentry/nextjs` instead. */ export function nextRouterInstrumentation( startTransactionCb: StartTransactionCb, startTransactionOnPageLoad: boolean = true, startTransactionOnLocationChange: boolean = true, + startPageloadSpanCallback?: StartSpanCb, + startNavigationSpanCallback?: StartSpanCb, ): void { const isAppRouter = !WINDOW.document.getElementById('__NEXT_DATA__'); if (isAppRouter) { - appRouterInstrumentation(startTransactionCb, startTransactionOnPageLoad, startTransactionOnLocationChange); + appRouterInstrumentation( + startTransactionCb, + startTransactionOnPageLoad, + startTransactionOnLocationChange, + startPageloadSpanCallback || (() => undefined), + startNavigationSpanCallback || (() => undefined), + ); } else { - pagesRouterInstrumentation(startTransactionCb, startTransactionOnPageLoad, startTransactionOnLocationChange); + pagesRouterInstrumentation( + startTransactionCb, + startTransactionOnPageLoad, + startTransactionOnLocationChange, + startPageloadSpanCallback || (() => undefined), + startNavigationSpanCallback || (() => undefined), + ); } } diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 5f2064c690e4..c3f466a566ea 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -1,7 +1,7 @@ import type { ParsedUrlQuery } from 'querystring'; import { getClient, getCurrentScope } from '@sentry/core'; import { WINDOW } from '@sentry/react'; -import type { Primitive, Transaction, TransactionContext, TransactionSource } from '@sentry/types'; +import type { Primitive, StartSpanOptions, Transaction, TransactionContext, TransactionSource } from '@sentry/types'; import { browserPerformanceTimeOrigin, logger, @@ -20,6 +20,7 @@ const globalObject = WINDOW as typeof WINDOW & { }; type StartTransactionCb = (context: TransactionContext) => Transaction | undefined; +type StartSpanCb = (context: StartSpanOptions) => void; /** * Describes data located in the __NEXT_DATA__ script tag. This tag is present on every page of a Next.js app. @@ -117,8 +118,11 @@ export function pagesRouterInstrumentation( startTransactionCb: StartTransactionCb, startTransactionOnPageLoad: boolean = true, startTransactionOnLocationChange: boolean = true, + startPageloadSpanCallback: StartSpanCb, + startNavigationSpanCallback: StartSpanCb, ): void { const { route, params, sentryTrace, baggage } = extractNextDataTagInformation(); + // eslint-disable-next-line deprecation/deprecation const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( sentryTrace, baggage, @@ -129,7 +133,7 @@ export function pagesRouterInstrumentation( if (startTransactionOnPageLoad) { const source = route ? 'route' : 'url'; - activeTransaction = startTransactionCb({ + const transactionContext = { name: prevLocationName, op: 'pageload', origin: 'auto.pageload.nextjs.pages_router_instrumentation', @@ -142,7 +146,9 @@ export function pagesRouterInstrumentation( source, dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, }, - }); + } as const; + activeTransaction = startTransactionCb(transactionContext); + startPageloadSpanCallback(transactionContext); } if (startTransactionOnLocationChange) { @@ -172,13 +178,15 @@ export function pagesRouterInstrumentation( activeTransaction.end(); } - const navigationTransaction = startTransactionCb({ + const transactionContext = { name: transactionName, op: 'navigation', origin: 'auto.navigation.nextjs.pages_router_instrumentation', tags, metadata: { source: transactionSource }, - }); + } as const; + const navigationTransaction = startTransactionCb(transactionContext); + startNavigationSpanCallback(transactionContext); if (navigationTransaction) { // In addition to the navigation transaction we're also starting a span to mark Next.js's `routeChangeStart` diff --git a/packages/nextjs/src/common/utils/commonObjectTracing.ts b/packages/nextjs/src/common/utils/commonObjectTracing.ts index bb5cf130bab1..988dee391dc4 100644 --- a/packages/nextjs/src/common/utils/commonObjectTracing.ts +++ b/packages/nextjs/src/common/utils/commonObjectTracing.ts @@ -4,6 +4,10 @@ const commonMap = new WeakMap(); /** * Takes a shared (garbage collectable) object between resources, e.g. a headers object shared between Next.js server components and returns a common propagation context. + * + * @param commonObject The shared object. + * @param propagationContext The propagation context that should be shared between all the resources if no propagation context was registered yet. + * @returns the shared propagation context. */ export function commonObjectToPropagationContext( commonObject: unknown, diff --git a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts index fd98fe2328ee..8597228f6e83 100644 --- a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts +++ b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts @@ -32,52 +32,52 @@ export function withEdgeWrapping( baggage = req.headers.get('baggage'); } - const transactionContext = continueTrace({ - sentryTrace, - baggage, - }); - - return startSpan( + return continueTrace( { - ...transactionContext, - name: options.spanDescription, - op: options.spanOp, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - metadata: { - // eslint-disable-next-line deprecation/deprecation - ...transactionContext.metadata, - request: req instanceof Request ? winterCGRequestToRequestData(req) : undefined, - }, + sentryTrace, + baggage, }, - async span => { - const handlerResult = await handleCallbackErrors( - () => handler.apply(this, args), - error => { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: options.mechanismFunctionName, - }, - }, - }); + () => { + return startSpan( + { + name: options.spanDescription, + op: options.spanOp, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', + }, + metadata: { + request: req instanceof Request ? winterCGRequestToRequestData(req) : undefined, + }, }, - ); + async span => { + const handlerResult = await handleCallbackErrors( + () => handler.apply(this, args), + error => { + captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: options.mechanismFunctionName, + }, + }, + }); + }, + ); - if (span) { - if (handlerResult instanceof Response) { - setHttpStatus(span, handlerResult.status); - } else { - span.setStatus('ok'); - } - } + if (span) { + if (handlerResult instanceof Response) { + setHttpStatus(span, handlerResult.status); + } else { + span.setStatus('ok'); + } + } - return handlerResult; + return handlerResult; + }, + ).finally(() => flushQueue()); }, - ).finally(() => flushQueue()); + ); }; } diff --git a/packages/nextjs/src/common/utils/responseEnd.ts b/packages/nextjs/src/common/utils/responseEnd.ts index c12d19f1c6fa..501208f81e84 100644 --- a/packages/nextjs/src/common/utils/responseEnd.ts +++ b/packages/nextjs/src/common/utils/responseEnd.ts @@ -1,13 +1,13 @@ import type { ServerResponse } from 'http'; import { flush, setHttpStatus } from '@sentry/core'; -import type { Transaction } from '@sentry/types'; +import type { Span } from '@sentry/types'; import { fill, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; import type { ResponseEndMethod, WrappedResponseEndMethod } from '../types'; /** - * Wrap `res.end()` so that it closes the transaction and flushes events before letting the request finish. + * Wrap `res.end()` so that it ends the span and flushes events before letting the request finish. * * Note: This wraps a sync method with an async method. While in general that's not a great idea in terms of keeping * things in the right order, in this case it's safe, because the native `.end()` actually *is* (effectively) async, and @@ -20,13 +20,13 @@ import type { ResponseEndMethod, WrappedResponseEndMethod } from '../types'; * `end` doesn't delay data getting to the end user. See * https://nodejs.org/api/http.html#responseenddata-encoding-callback. * - * @param transaction The transaction tracing request handling + * @param span The span tracking the request * @param res: The request's corresponding response */ -export function autoEndTransactionOnResponseEnd(transaction: Transaction, res: ServerResponse): void { +export function autoEndSpanOnResponseEnd(span: Span, res: ServerResponse): void { const wrapEndMethod = (origEnd: ResponseEndMethod): WrappedResponseEndMethod => { return function sentryWrappedEnd(this: ServerResponse, ...args: unknown[]) { - finishTransaction(transaction, this); + finishSpan(span, this); return origEnd.call(this, ...args); }; }; @@ -38,11 +38,11 @@ export function autoEndTransactionOnResponseEnd(transaction: Transaction, res: S } } -/** Finish the given response's transaction and set HTTP status data */ -export function finishTransaction(transaction: Transaction | undefined, res: ServerResponse): void { - if (transaction) { - setHttpStatus(transaction, res.statusCode); - transaction.end(); +/** Finish the given response's span and set HTTP status data */ +export function finishSpan(span: Span | undefined, res: ServerResponse): void { + if (span) { + setHttpStatus(span, res.statusCode); + span.end(); } } diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index f7e0917f2c39..20f458bf6013 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -1,38 +1,40 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, - getActiveSpan, - getActiveTransaction, - getCurrentScope, - runWithAsyncContext, - startTransaction, + continueTrace, + startInactiveSpan, + startSpan, + startSpanManual, + withActiveSpan, + withIsolationScope, } from '@sentry/core'; -import type { Span, Transaction } from '@sentry/types'; -import { isString, tracingContextFromHeaders } from '@sentry/utils'; +import type { Span } from '@sentry/types'; +import { isString } from '@sentry/utils'; import { platformSupportsStreaming } from './platformSupportsStreaming'; -import { autoEndTransactionOnResponseEnd, flushQueue } from './responseEnd'; +import { autoEndSpanOnResponseEnd, flushQueue } from './responseEnd'; declare module 'http' { interface IncomingMessage { - _sentryTransaction?: Transaction; + _sentrySpan?: Span; } } /** - * Grabs a transaction off a Next.js datafetcher request object, if it was previously put there via - * `setTransactionOnRequest`. + * Grabs a span off a Next.js datafetcher request object, if it was previously put there via + * `setSpanOnRequest`. * * @param req The Next.js datafetcher request object - * @returns the Transaction on the request object if there is one, or `undefined` if the request object didn't have one. + * @returns the span on the request object if there is one, or `undefined` if the request object didn't have one. */ -export function getTransactionFromRequest(req: IncomingMessage): Transaction | undefined { - return req._sentryTransaction; +export function getSpanFromRequest(req: IncomingMessage): Span | undefined { + return req._sentrySpan; } -function setTransactionOnRequest(transaction: Transaction, req: IncomingMessage): void { - req._sentryTransaction = transaction; +function setSpanOnRequest(transaction: Span, req: IncomingMessage): void { + req._sentrySpan = transaction; } /** @@ -85,98 +87,68 @@ export function withTracedServerSideDataFetcher Pr }, ): (...params: Parameters) => Promise> { return async function (this: unknown, ...args: Parameters): Promise> { - return runWithAsyncContext(async () => { - const scope = getCurrentScope(); - const previousSpan: Span | undefined = getTransactionFromRequest(req) ?? getActiveSpan(); - let dataFetcherSpan; + return withIsolationScope(async isolationScope => { + isolationScope.setSDKProcessingMetadata({ + request: req, + }); const sentryTrace = req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined; const baggage = req.headers?.baggage; - const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( - sentryTrace, - baggage, - ); - scope.setPropagationContext(propagationContext); - if (platformSupportsStreaming()) { - let spanToContinue: Span; - if (previousSpan === undefined) { - // TODO: Refactor this to use `startSpan()` - // eslint-disable-next-line deprecation/deprecation - const newTransaction = startTransaction( + return continueTrace({ sentryTrace, baggage }, () => { + let requestSpan: Span | undefined = getSpanFromRequest(req); + if (!requestSpan) { + // TODO(v8): Simplify these checks when startInactiveSpan always returns a span + requestSpan = startInactiveSpan({ + name: options.requestedRouteName, + op: 'http.server', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }, + }); + if (requestSpan) { + requestSpan.setStatus('ok'); + setSpanOnRequest(requestSpan, req); + autoEndSpanOnResponseEnd(requestSpan, res); + } + } + + const withActiveSpanCallback = (): Promise> => { + return startSpanManual( { - op: 'http.server', - name: options.requestedRouteName, - origin: 'auto.function.nextjs', - ...traceparentData, - status: 'ok', - metadata: { - request: req, - source: 'route', - dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, + op: 'function.nextjs', + name: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', }, }, - { request: req }, + async dataFetcherSpan => { + dataFetcherSpan?.setStatus('ok'); + try { + return await origDataFetcher.apply(this, args); + } catch (e) { + dataFetcherSpan?.setStatus('internal_error'); + requestSpan?.setStatus('internal_error'); + throw e; + } finally { + dataFetcherSpan?.end(); + if (!platformSupportsStreaming()) { + await flushQueue(); + } + } + }, ); + }; - if (platformSupportsStreaming()) { - // On platforms that don't support streaming, doing things after res.end() is unreliable. - autoEndTransactionOnResponseEnd(newTransaction, res); - } - - // Link the transaction and the request together, so that when we would normally only have access to one, it's - // still possible to grab the other. - setTransactionOnRequest(newTransaction, req); - spanToContinue = newTransaction; + if (requestSpan) { + return withActiveSpan(requestSpan, withActiveSpanCallback); } else { - spanToContinue = previousSpan; - } - - // eslint-disable-next-line deprecation/deprecation - dataFetcherSpan = spanToContinue.startChild({ - op: 'function.nextjs', - description: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`, - origin: 'auto.function.nextjs', - status: 'ok', - }); - } else { - // TODO: Refactor this to use `startSpan()` - // eslint-disable-next-line deprecation/deprecation - dataFetcherSpan = startTransaction({ - op: 'function.nextjs', - name: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`, - origin: 'auto.function.nextjs', - ...traceparentData, - status: 'ok', - metadata: { - request: req, - source: 'route', - dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, - }, - }); - } - - // eslint-disable-next-line deprecation/deprecation - scope.setSpan(dataFetcherSpan); - scope.setSDKProcessingMetadata({ request: req }); - - try { - return await origDataFetcher.apply(this, args); - } catch (e) { - // Since we finish the span before the error can bubble up and trigger the handlers in `registerErrorInstrumentation` - // that set the transaction status, we need to manually set the status of the span & transaction - dataFetcherSpan.setStatus('internal_error'); - previousSpan?.setStatus('internal_error'); - throw e; - } finally { - dataFetcherSpan.end(); - // eslint-disable-next-line deprecation/deprecation - scope.setSpan(previousSpan); - if (!platformSupportsStreaming()) { - await flushQueue(); + return withActiveSpanCallback(); } - } + }); }); }; } @@ -198,43 +170,30 @@ export async function callDataFetcherTraced Promis ): Promise> { const { parameterizedRoute, dataFetchingMethodName } = options; - // eslint-disable-next-line deprecation/deprecation - const transaction = getActiveTransaction(); - - if (!transaction) { - return origFunction(...origFunctionArgs); - } - - // TODO: Make sure that the given route matches the name of the active transaction (to prevent background data - // fetching from switching the name to a completely other route) -- We'll probably switch to creating a transaction - // right here so making that check will probabably not even be necessary. - // Logic will be: If there is no active transaction, start one with correct name and source. If there is an active - // transaction, create a child span with correct name and source. - transaction.updateName(parameterizedRoute); - transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + return startSpan( + { + op: 'function.nextjs', + name: `${dataFetchingMethodName} (${parameterizedRoute})`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }, + }, + async dataFetcherSpan => { + dataFetcherSpan?.setStatus('ok'); - // Capture the route, since pre-loading, revalidation, etc might mean that this span may happen during another - // route's transaction - // eslint-disable-next-line deprecation/deprecation - const span = transaction.startChild({ - op: 'function.nextjs', - origin: 'auto.function.nextjs', - description: `${dataFetchingMethodName} (${parameterizedRoute})`, - status: 'ok', - }); - - try { - return await origFunction(...origFunctionArgs); - } catch (err) { - // Since we finish the span before the error can bubble up and trigger the handlers in `registerErrorInstrumentation` - // that set the transaction status, we need to manually set the status of the span & transaction - transaction.setStatus('internal_error'); - span.setStatus('internal_error'); - span.end(); - - // TODO Copy more robust error handling over from `withSentry` - captureException(err, { mechanism: { handled: false } }); - - throw err; - } + try { + return await origFunction(...origFunctionArgs); + } catch (e) { + dataFetcherSpan?.setStatus('internal_error'); + captureException(e, { mechanism: { handled: false } }); + throw e; + } finally { + dataFetcherSpan?.end(); + if (!platformSupportsStreaming()) { + await flushQueue(); + } + } + }, + ); } diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index 01e1c75d6f3f..1f4cdaf992c9 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -1,15 +1,17 @@ +import { getIsolationScope } from '@sentry/core'; import { addTracingExtensions, captureException, + continueTrace, getClient, - getCurrentScope, handleCallbackErrors, - runWithAsyncContext, startSpan, + withIsolationScope, } from '@sentry/core'; -import { logger, tracingContextFromHeaders } from '@sentry/utils'; +import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; +import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; import { flushQueue } from './utils/responseEnd'; @@ -56,7 +58,7 @@ async function withServerActionInstrumentationImplementation> { addTracingExtensions(); - return runWithAsyncContext(async () => { + return withIsolationScope(isolationScope => { const sendDefaultPii = getClient()?.getOptions().sendDefaultPii; let sentryTraceHeader; @@ -75,63 +77,73 @@ async function withServerActionInstrumentationImplementation { + try { + return await startSpan( + { + op: 'function.server_action', + name: `serverAction/${serverActionName}`, + metadata: { + source: 'route', + }, }, - }, - }, - async span => { - const result = await handleCallbackErrors(callback, error => { - captureException(error, { mechanism: { handled: false } }); - }); + async span => { + const result = await handleCallbackErrors(callback, error => { + if (isNotFoundNavigationError(error)) { + // We don't want to report "not-found"s + span?.setStatus('not_found'); + } else if (isRedirectNavigationError(error)) { + // Don't do anything for redirects + } else { + span?.setStatus('internal_error'); + captureException(error, { + mechanism: { + handled: false, + }, + }); + } + }); - if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) { - span?.setAttribute('server_action_result', result); - } - - if (options.formData) { - options.formData.forEach((value, key) => { - span?.setAttribute( - `server_action_form_data.${key}`, - typeof value === 'string' ? value : '[non-string value]', - ); - }); - } + if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) { + getIsolationScope().setExtra('server_action_result', result); + } - return result; - }, - ); - } finally { - if (!platformSupportsStreaming()) { - // Lambdas require manual flushing to prevent execution freeze before the event is sent - await flushQueue(); - } + if (options.formData) { + options.formData.forEach((value, key) => { + getIsolationScope().setExtra( + `server_action_form_data.${key}`, + typeof value === 'string' ? value : '[non-string value]', + ); + }); + } - if (process.env.NEXT_RUNTIME === 'edge') { - // flushQueue should not throw - // eslint-disable-next-line @typescript-eslint/no-floating-promises - flushQueue(); - } - } + return result; + }, + ); + } finally { + if (!platformSupportsStreaming()) { + // Lambdas require manual flushing to prevent execution freeze before the event is sent + await flushQueue(); + } - return res; + if (process.env.NEXT_RUNTIME === 'edge') { + // flushQueue should not throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + flushQueue(); + } + } + }, + ); }); } diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts index 61a08ba18891..62124e46912e 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts @@ -78,130 +78,130 @@ export function withSentry(apiHandler: NextApiHandler, parameterizedRoute?: stri addTracingExtensions(); - return runWithAsyncContext(async () => { - const transactionContext = continueTrace({ - sentryTrace: req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined, - baggage: req.headers?.baggage, - }); - - // prefer the parameterized route, if we have it (which we will if we've auto-wrapped the route handler) - let reqPath = parameterizedRoute; - - // If not, fake it by just replacing parameter values with their names, hoping that none of them match either - // each other or any hard-coded parts of the path - if (!reqPath) { - const url = `${req.url}`; - // pull off query string, if any - reqPath = stripUrlQueryAndFragment(url); - // Replace with placeholder - if (req.query) { - for (const [key, value] of Object.entries(req.query)) { - reqPath = reqPath.replace(`${value}`, `[${key}]`); - } - } - } - - const reqMethod = `${(req.method || 'GET').toUpperCase()} `; - - getCurrentScope().setSDKProcessingMetadata({ request: req }); - - return startSpanManual( + return runWithAsyncContext(() => { + return continueTrace( { - ...transactionContext, - name: `${reqMethod}${reqPath}`, - op: 'http.server', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', - }, - metadata: { - // eslint-disable-next-line deprecation/deprecation - ...transactionContext.metadata, - request: req, - }, + sentryTrace: req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined, + baggage: req.headers?.baggage, }, - async span => { - // eslint-disable-next-line @typescript-eslint/unbound-method - res.end = new Proxy(res.end, { - apply(target, thisArg, argArray) { - if (span) { - setHttpStatus(span, res.statusCode); - span.end(); - } - if (platformSupportsStreaming() && !wrappingTarget.__sentry_test_doesnt_support_streaming__) { - target.apply(thisArg, argArray); - } else { - // flushQueue will not reject - // eslint-disable-next-line @typescript-eslint/no-floating-promises - flushQueue().then(() => { - target.apply(thisArg, argArray); - }); + () => { + // prefer the parameterized route, if we have it (which we will if we've auto-wrapped the route handler) + let reqPath = parameterizedRoute; + + // If not, fake it by just replacing parameter values with their names, hoping that none of them match either + // each other or any hard-coded parts of the path + if (!reqPath) { + const url = `${req.url}`; + // pull off query string, if any + reqPath = stripUrlQueryAndFragment(url); + // Replace with placeholder + if (req.query) { + for (const [key, value] of Object.entries(req.query)) { + reqPath = reqPath.replace(`${value}`, `[${key}]`); } - }, - }); - - try { - const handlerResult = await wrappingTarget.apply(thisArg, args); - if ( - process.env.NODE_ENV === 'development' && - !process.env.SENTRY_IGNORE_API_RESOLUTION_ERROR && - !res.finished - // TODO(v8): Remove this warning? - // This can only happen (not always) when the user is using `withSentry` manually, which we're deprecating. - // Warning suppression on Next.JS is only necessary in that case. - ) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - '[sentry] If Next.js logs a warning "API resolved without sending a response", it\'s a false positive, which may happen when you use `withSentry` manually to wrap your routes. To suppress this warning, set `SENTRY_IGNORE_API_RESOLUTION_ERROR` to 1 in your env. To suppress the nextjs warning, use the `externalResolver` API route option (see https://nextjs.org/docs/api-routes/api-middlewares#custom-config for details).', - ); - }); } + } - return handlerResult; - } catch (e) { - // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can - // store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced - // to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a - // way to prevent it from actually being reported twice.) - const objectifiedErr = objectify(e); - - captureException(objectifiedErr, { - mechanism: { - type: 'instrument', - handled: false, - data: { - wrapped_handler: wrappingTarget.name, - function: 'withSentry', - }, - }, - }); + const reqMethod = `${(req.method || 'GET').toUpperCase()} `; - // Because we're going to finish and send the transaction before passing the error onto nextjs, it won't yet - // have had a chance to set the status to 500, so unless we do it ourselves now, we'll incorrectly report that - // the transaction was error-free - res.statusCode = 500; - res.statusMessage = 'Internal Server Error'; + getCurrentScope().setSDKProcessingMetadata({ request: req }); - if (span) { - setHttpStatus(span, res.statusCode); - span.end(); - } + return startSpanManual( + { + name: `${reqMethod}${reqPath}`, + op: 'http.server', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', + }, + metadata: { + request: req, + }, + }, + async span => { + // eslint-disable-next-line @typescript-eslint/unbound-method + res.end = new Proxy(res.end, { + apply(target, thisArg, argArray) { + if (span) { + setHttpStatus(span, res.statusCode); + span.end(); + } + if (platformSupportsStreaming() && !wrappingTarget.__sentry_test_doesnt_support_streaming__) { + target.apply(thisArg, argArray); + } else { + // flushQueue will not reject + // eslint-disable-next-line @typescript-eslint/no-floating-promises + flushQueue().then(() => { + target.apply(thisArg, argArray); + }); + } + }, + }); - // Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors - // out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the - // moment they detect an error, so it's important to get this done before rethrowing the error. Apps not - // deployed serverlessly will run into this cleanup code again in `res.end(), but the transaction will already - // be finished and the queue will already be empty, so effectively it'll just no-op.) - if (platformSupportsStreaming() && !wrappingTarget.__sentry_test_doesnt_support_streaming__) { - await flushQueue(); - } + try { + const handlerResult = await wrappingTarget.apply(thisArg, args); + if ( + process.env.NODE_ENV === 'development' && + !process.env.SENTRY_IGNORE_API_RESOLUTION_ERROR && + !res.finished + // TODO(v8): Remove this warning? + // This can only happen (not always) when the user is using `withSentry` manually, which we're deprecating. + // Warning suppression on Next.JS is only necessary in that case. + ) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[sentry] If Next.js logs a warning "API resolved without sending a response", it\'s a false positive, which may happen when you use `withSentry` manually to wrap your routes. To suppress this warning, set `SENTRY_IGNORE_API_RESOLUTION_ERROR` to 1 in your env. To suppress the nextjs warning, use the `externalResolver` API route option (see https://nextjs.org/docs/api-routes/api-middlewares#custom-config for details).', + ); + }); + } + + return handlerResult; + } catch (e) { + // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can + // store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced + // to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a + // way to prevent it from actually being reported twice.) + const objectifiedErr = objectify(e); + + captureException(objectifiedErr, { + mechanism: { + type: 'instrument', + handled: false, + data: { + wrapped_handler: wrappingTarget.name, + function: 'withSentry', + }, + }, + }); - // We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it - // would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark - // the error as already having been captured.) - throw objectifiedErr; - } + // Because we're going to finish and send the transaction before passing the error onto nextjs, it won't yet + // have had a chance to set the status to 500, so unless we do it ourselves now, we'll incorrectly report that + // the transaction was error-free + res.statusCode = 500; + res.statusMessage = 'Internal Server Error'; + + if (span) { + setHttpStatus(span, res.statusCode); + span.end(); + } + + // Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors + // out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the + // moment they detect an error, so it's important to get this done before rethrowing the error. Apps not + // deployed serverlessly will run into this cleanup code again in `res.end(), but the transaction will already + // be finished and the queue will already be empty, so effectively it'll just no-op.) + if (platformSupportsStreaming() && !wrappingTarget.__sentry_test_doesnt_support_streaming__) { + await flushQueue(); + } + + // We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it + // would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark + // the error as already having been captured.) + throw objectifiedErr; + } + }, + ); }, ); }); diff --git a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts index df18b2ad952d..218ed18b5f26 100644 --- a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts @@ -1,19 +1,16 @@ import { addTracingExtensions, + getActiveSpan, getClient, - getCurrentScope, getDynamicSamplingContextFromSpan, + getRootSpan, spanToTraceHeader, } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type App from 'next/app'; import { isBuild } from './utils/isBuild'; -import { - getTransactionFromRequest, - withErrorInstrumentation, - withTracedServerSideDataFetcher, -} from './utils/wrapperUtils'; +import { getSpanFromRequest, withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; type AppGetInitialProps = (typeof App)['getInitialProps']; @@ -58,8 +55,8 @@ export function wrapAppGetInitialPropsWithSentry(origAppGetInitialProps: AppGetI }; } = await tracedGetInitialProps.apply(thisArg, args); - // eslint-disable-next-line deprecation/deprecation - const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); + const activeSpan = getActiveSpan(); + const requestSpan = getSpanFromRequest(req) ?? (activeSpan ? getRootSpan(activeSpan) : undefined); // Per definition, `pageProps` is not optional, however an increased amount of users doesn't seem to call // `App.getInitialProps(appContext)` in their custom `_app` pages which is required as per @@ -69,10 +66,9 @@ export function wrapAppGetInitialPropsWithSentry(origAppGetInitialProps: AppGetI appGetInitialProps.pageProps = {}; } - if (requestTransaction) { - appGetInitialProps.pageProps._sentryTraceData = spanToTraceHeader(requestTransaction); - - const dynamicSamplingContext = getDynamicSamplingContextFromSpan(requestTransaction); + if (requestSpan) { + appGetInitialProps.pageProps._sentryTraceData = spanToTraceHeader(requestSpan); + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(requestSpan); appGetInitialProps.pageProps._sentryBaggage = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); } diff --git a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts index 44a171d8e6d5..2b2ad24fd18e 100644 --- a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts @@ -1,8 +1,9 @@ import { addTracingExtensions, + getActiveSpan, getClient, - getCurrentScope, getDynamicSamplingContextFromSpan, + getRootSpan, spanToTraceHeader, } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; @@ -10,11 +11,7 @@ import type { NextPageContext } from 'next'; import type { ErrorProps } from 'next/error'; import { isBuild } from './utils/isBuild'; -import { - getTransactionFromRequest, - withErrorInstrumentation, - withTracedServerSideDataFetcher, -} from './utils/wrapperUtils'; +import { getSpanFromRequest, withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; type ErrorGetInitialProps = (context: NextPageContext) => Promise; @@ -59,12 +56,13 @@ export function wrapErrorGetInitialPropsWithSentry( _sentryBaggage?: string; } = await tracedGetInitialProps.apply(thisArg, args); - // eslint-disable-next-line deprecation/deprecation - const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); - if (requestTransaction) { - errorGetInitialProps._sentryTraceData = spanToTraceHeader(requestTransaction); + const activeSpan = getActiveSpan(); + const requestSpan = getSpanFromRequest(req) ?? (activeSpan ? getRootSpan(activeSpan) : undefined); + + if (requestSpan) { + errorGetInitialProps._sentryTraceData = spanToTraceHeader(requestSpan); - const dynamicSamplingContext = getDynamicSamplingContextFromSpan(requestTransaction); + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(requestSpan); errorGetInitialProps._sentryBaggage = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); } diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 5e6a051ffcfb..d1dbecbaec63 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -2,15 +2,14 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, addTracingExtensions, captureException, - continueTrace, getClient, getCurrentScope, handleCallbackErrors, - runWithAsyncContext, startSpanManual, + withIsolationScope, } from '@sentry/core'; import type { WebFetchHeaders } from '@sentry/types'; -import { winterCGHeadersToDict } from '@sentry/utils'; +import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; @@ -46,41 +45,32 @@ export function wrapGenerationFunctionWithSentry a data = { params, searchParams }; } - return runWithAsyncContext(() => { - const transactionContext = continueTrace({ - baggage: headers?.get('baggage'), - sentryTrace: headers?.get('sentry-trace') ?? undefined, + return withIsolationScope(isolationScope => { + isolationScope.setSDKProcessingMetadata({ + request: { + headers: headers ? winterCGHeadersToDict(headers) : undefined, + }, }); + isolationScope.setExtra('route_data', data); + + const incomingPropagationContext = propagationContextFromHeaders( + headers?.get('sentry-trace') ?? undefined, + headers?.get('baggage'), + ); - // If there is no incoming trace, we are setting the transaction context to one that is shared between all other - // transactions for this request. We do this based on the `headers` object, which is the same for all components. - const propagationContext = getCurrentScope().getPropagationContext(); - if (!transactionContext.traceId && !transactionContext.parentSpanId) { - const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext( - headers, - propagationContext, - ); - transactionContext.traceId = commonTraceId; - transactionContext.parentSpanId = commonSpanId; - } + const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext); + isolationScope.setPropagationContext(propagationContext); + getCurrentScope().setPropagationContext(propagationContext); return startSpanManual( { op: 'function.nextjs', name: `${componentType}.${generationFunctionIdentifier} (${componentRoute})`, - ...transactionContext, data, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', }, - metadata: { - // eslint-disable-next-line deprecation/deprecation - ...transactionContext.metadata, - request: { - headers: headers ? winterCGHeadersToDict(headers) : undefined, - }, - }, }, span => { return handleCallbackErrors( @@ -97,9 +87,6 @@ export function wrapGenerationFunctionWithSentry a captureException(err, { mechanism: { handled: false, - data: { - function: 'wrapGenerationFunctionWithSentry', - }, }, }); } diff --git a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts index 1a6743765cd6..2dbe5c34d6c9 100644 --- a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts @@ -1,19 +1,16 @@ import { addTracingExtensions, + getActiveSpan, getClient, - getCurrentScope, getDynamicSamplingContextFromSpan, + getRootSpan, spanToTraceHeader, } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type { NextPage } from 'next'; import { isBuild } from './utils/isBuild'; -import { - getTransactionFromRequest, - withErrorInstrumentation, - withTracedServerSideDataFetcher, -} from './utils/wrapperUtils'; +import { getSpanFromRequest, withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; type GetInitialProps = Required['getInitialProps']; @@ -55,12 +52,13 @@ export function wrapGetInitialPropsWithSentry(origGetInitialProps: GetInitialPro _sentryBaggage?: string; } = (await tracedGetInitialProps.apply(thisArg, args)) ?? {}; // Next.js allows undefined to be returned from a getInitialPropsFunction. - // eslint-disable-next-line deprecation/deprecation - const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); - if (requestTransaction) { - initialProps._sentryTraceData = spanToTraceHeader(requestTransaction); + const activeSpan = getActiveSpan(); + const requestSpan = getSpanFromRequest(req) ?? (activeSpan ? getRootSpan(activeSpan) : undefined); + + if (requestSpan) { + initialProps._sentryTraceData = spanToTraceHeader(requestSpan); - const dynamicSamplingContext = getDynamicSamplingContextFromSpan(requestTransaction); + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(requestSpan); initialProps._sentryBaggage = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); } diff --git a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts b/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts index 691570f87683..1f21952ec373 100644 --- a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts +++ b/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts @@ -1,19 +1,16 @@ import { addTracingExtensions, + getActiveSpan, getClient, - getCurrentScope, getDynamicSamplingContextFromSpan, + getRootSpan, spanToTraceHeader, } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; import type { GetServerSideProps } from 'next'; import { isBuild } from './utils/isBuild'; -import { - getTransactionFromRequest, - withErrorInstrumentation, - withTracedServerSideDataFetcher, -} from './utils/wrapperUtils'; +import { getSpanFromRequest, withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; /** * Create a wrapped version of the user's exported `getServerSideProps` function @@ -52,8 +49,8 @@ export function wrapGetServerSidePropsWithSentry( >); if (serverSideProps && 'props' in serverSideProps) { - // eslint-disable-next-line deprecation/deprecation - const requestTransaction = getTransactionFromRequest(req) ?? getCurrentScope().getTransaction(); + const activeSpan = getActiveSpan(); + const requestTransaction = getSpanFromRequest(req) ?? (activeSpan ? getRootSpan(activeSpan) : undefined); if (requestTransaction) { serverSideProps.props._sentryTraceData = spanToTraceHeader(requestTransaction); diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 99b9cf99b9b9..e4a475f6ced6 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -1,13 +1,15 @@ import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, addTracingExtensions, captureException, - getCurrentScope, + continueTrace, handleCallbackErrors, - runWithAsyncContext, setHttpStatus, startSpan, + withIsolationScope, } from '@sentry/core'; -import { tracingContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; +import { winterCGHeadersToDict } from '@sentry/utils'; import { isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; @@ -27,63 +29,61 @@ export function wrapRouteHandlerWithSentry any>( const { method, parameterizedRoute, baggageHeader, sentryTraceHeader, headers } = context; return new Proxy(routeHandler, { apply: (originalFunction, thisArg, args) => { - return runWithAsyncContext(async () => { - const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( - sentryTraceHeader ?? headers?.get('sentry-trace') ?? undefined, - baggageHeader ?? headers?.get('baggage'), - ); - getCurrentScope().setPropagationContext(propagationContext); - - let result; - - try { - result = await startSpan( - { - op: 'http.server', - name: `${method} ${parameterizedRoute}`, - status: 'ok', - ...traceparentData, - metadata: { - request: { - headers: headers ? winterCGHeadersToDict(headers) : undefined, + return withIsolationScope(async isolationScope => { + isolationScope.setSDKProcessingMetadata({ + request: { + headers: headers ? winterCGHeadersToDict(headers) : undefined, + }, + }); + return continueTrace( + { + sentryTrace: sentryTraceHeader ?? headers?.get('sentry-trace') ?? undefined, + baggage: baggageHeader ?? headers?.get('baggage'), + }, + async () => { + try { + return await startSpan( + { + op: 'http.server', + name: `${method} ${parameterizedRoute}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + }, }, - source: 'route', - dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, - }, - }, - async span => { - const response: Response = await handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - error => { - // Next.js throws errors when calling `redirect()`. We don't wanna report these. - if (!isRedirectNavigationError(error)) { - captureException(error, { - mechanism: { - handled: false, - }, - }); + async span => { + const response: Response = await handleCallbackErrors( + () => originalFunction.apply(thisArg, args), + error => { + // Next.js throws errors when calling `redirect()`. We don't wanna report these. + if (!isRedirectNavigationError(error)) { + captureException(error, { + mechanism: { + handled: false, + }, + }); + } + }, + ); + + try { + span && setHttpStatus(span, response.status); + } catch { + // best effort - response may be undefined? } + + return response; }, ); - - try { - span && setHttpStatus(span, response.status); - } catch { - // best effort + } finally { + if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') { + // 1. Edge transport requires manual flushing + // 2. Lambdas require manual flushing to prevent execution freeze before the event is sent + await flushQueue(); } - - return response; - }, - ); - } finally { - if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') { - // 1. Edge tranpsort requires manual flushing - // 2. Lambdas require manual flushing to prevent execution freeze before the event is sent - await flushQueue(); - } - } - - return result; + } + }, + ); }); }, }); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index f8b6c5698550..de0c1da9c1f9 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -2,13 +2,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, addTracingExtensions, captureException, - continueTrace, getCurrentScope, handleCallbackErrors, - runWithAsyncContext, startSpanManual, + withIsolationScope, } from '@sentry/core'; -import { winterCGHeadersToDict } from '@sentry/utils'; +import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; @@ -32,47 +31,37 @@ export function wrapServerComponentWithSentry any> // hook. 🤯 return new Proxy(appDirComponent, { apply: (originalFunction, thisArg, args) => { - return runWithAsyncContext(() => { + // TODO: If we ever allow withIsolationScope to take a scope, we should pass a scope here that is shared between all of the server components, similar to what `commonObjectToPropagationContext` does. + return withIsolationScope(isolationScope => { const completeHeadersDict: Record = context.headers ? winterCGHeadersToDict(context.headers) : {}; - const transactionContext = continueTrace({ + isolationScope.setSDKProcessingMetadata({ + request: { + headers: completeHeadersDict, + }, + }); + + const incomingPropagationContext = propagationContextFromHeaders( // eslint-disable-next-line deprecation/deprecation - sentryTrace: context.sentryTraceHeader ?? completeHeadersDict['sentry-trace'], + context.sentryTraceHeader ?? completeHeadersDict['sentry-trace'], // eslint-disable-next-line deprecation/deprecation - baggage: context.baggageHeader ?? completeHeadersDict['baggage'], - }); + context.baggageHeader ?? completeHeadersDict['baggage'], + ); - // If there is no incoming trace, we are setting the transaction context to one that is shared between all other - // transactions for this request. We do this based on the `headers` object, which is the same for all components. - const propagationContext = getCurrentScope().getPropagationContext(); - if (!transactionContext.traceId && !transactionContext.parentSpanId) { - const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext( - context.headers, - propagationContext, - ); - transactionContext.traceId = commonTraceId; - transactionContext.parentSpanId = commonSpanId; - } + const propagationContext = commonObjectToPropagationContext(context.headers, incomingPropagationContext); + isolationScope.setPropagationContext(propagationContext); + getCurrentScope().setPropagationContext(propagationContext); - const res = startSpanManual( + return startSpanManual( { - ...transactionContext, op: 'function.nextjs', name: `${componentType} Server Component (${componentRoute})`, - status: 'ok', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', }, - metadata: { - // eslint-disable-next-line deprecation/deprecation - ...transactionContext.metadata, - request: { - headers: completeHeadersDict, - }, - }, }, span => { return handleCallbackErrors( @@ -86,7 +75,6 @@ export function wrapServerComponentWithSentry any> span?.setStatus('ok'); } else { span?.setStatus('internal_error'); - captureException(error, { mechanism: { handled: false, @@ -104,8 +92,6 @@ export function wrapServerComponentWithSentry any> ); }, ); - - return res; }); }, }); diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 21d51a01ee56..2328208e28c5 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -23,6 +23,7 @@ export declare function init( // eslint-disable-next-line deprecation/deprecation export declare const Integrations: typeof clientSdk.Integrations & typeof serverSdk.Integrations & + // eslint-disable-next-line deprecation/deprecation typeof edgeSdk.Integrations; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index f4ec99c3cc71..0ce7733dc137 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -117,111 +117,107 @@ describe('Client init()', () => { expect(installedBreadcrumbsIntegration).toBeDefined(); }); - describe('`BrowserTracing` integration', () => { - it('adds `BrowserTracing` integration if `tracesSampleRate` is set', () => { - init({ - dsn: TEST_DSN, - tracesSampleRate: 1.0, - }); + it('forces correct router instrumentation if user provides `BrowserTracing` in an array', () => { + init({ + dsn: TEST_DSN, + tracesSampleRate: 1.0, + // eslint-disable-next-line deprecation/deprecation + integrations: [new BrowserTracing({ finalTimeout: 10 })], + }); - const client = getClient()!; - const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + const client = getClient()!; + // eslint-disable-next-line deprecation/deprecation + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + + expect(browserTracingIntegration).toBeDefined(); + expect(browserTracingIntegration?.options).toEqual( + expect.objectContaining({ + // eslint-disable-next-line deprecation/deprecation + routingInstrumentation: nextRouterInstrumentation, + // This proves it's still the user's copy + finalTimeout: 10, + }), + ); + }); - expect(browserTracingIntegration).toBeDefined(); - expect(browserTracingIntegration?.options).toEqual( - expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - }), - ); + it('forces correct router instrumentation if user provides `browserTracingIntegration`', () => { + init({ + dsn: TEST_DSN, + integrations: [browserTracingIntegration({ finalTimeout: 10 })], + enableTracing: true, }); - it('adds `BrowserTracing` integration if `tracesSampler` is set', () => { - init({ - dsn: TEST_DSN, - tracesSampler: () => true, - }); - - const client = getClient()!; - const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + const client = getClient()!; + // eslint-disable-next-line deprecation/deprecation + const integration = client.getIntegrationByName('BrowserTracing'); + + expect(integration).toBeDefined(); + expect(integration?.options).toEqual( + expect.objectContaining({ + // eslint-disable-next-line deprecation/deprecation + routingInstrumentation: nextRouterInstrumentation, + // This proves it's still the user's copy + finalTimeout: 10, + }), + ); + }); - expect(browserTracingIntegration).toBeDefined(); - expect(browserTracingIntegration?.options).toEqual( - expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - }), - ); + it('forces correct router instrumentation if user provides `BrowserTracing` in a function', () => { + init({ + dsn: TEST_DSN, + tracesSampleRate: 1.0, + // eslint-disable-next-line deprecation/deprecation + integrations: defaults => [...defaults, new BrowserTracing({ startTransactionOnLocationChange: false })], }); - it('does not add `BrowserTracing` integration if tracing not enabled in SDK', () => { - init({ - dsn: TEST_DSN, - }); + const client = getClient()!; - const client = getClient()!; - const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + // eslint-disable-next-line deprecation/deprecation + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); - expect(browserTracingIntegration).toBeUndefined(); - }); + expect(browserTracingIntegration).toBeDefined(); + expect(browserTracingIntegration?.options).toEqual( + expect.objectContaining({ + // eslint-disable-next-line deprecation/deprecation + routingInstrumentation: nextRouterInstrumentation, + // This proves it's still the user's copy + startTransactionOnLocationChange: false, + }), + ); + }); - it('forces correct router instrumentation if user provides `BrowserTracing` in an array', () => { + describe('browserTracingIntegration()', () => { + it('adds `browserTracingIntegration()` integration if `tracesSampleRate` is set', () => { init({ dsn: TEST_DSN, tracesSampleRate: 1.0, - integrations: [new BrowserTracing({ finalTimeout: 10 })], }); const client = getClient()!; - const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); - - expect(browserTracingIntegration).toBeDefined(); - expect(browserTracingIntegration?.options).toEqual( - expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - // This proves it's still the user's copy - finalTimeout: 10, - }), - ); + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + expect(browserTracingIntegration?.name).toBe('BrowserTracing'); }); - it('forces correct router instrumentation if user provides `browserTracingIntegration`', () => { + it('adds `browserTracingIntegration()` integration if `tracesSampler` is set', () => { init({ dsn: TEST_DSN, - integrations: [browserTracingIntegration({ finalTimeout: 10 })], - enableTracing: true, + tracesSampler: () => true, }); const client = getClient()!; - const integration = client.getIntegrationByName('BrowserTracing'); - - expect(integration).toBeDefined(); - expect(integration?.options).toEqual( - expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - // This proves it's still the user's copy - finalTimeout: 10, - }), - ); + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + expect(browserTracingIntegration?.name).toBe('BrowserTracing'); }); - it('forces correct router instrumentation if user provides `BrowserTracing` in a function', () => { + it('does not add `browserTracingIntegration()` integration if tracing not enabled in SDK', () => { init({ dsn: TEST_DSN, - tracesSampleRate: 1.0, - integrations: defaults => [...defaults, new BrowserTracing({ startTransactionOnLocationChange: false })], }); const client = getClient()!; - const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); - - expect(browserTracingIntegration).toBeDefined(); - expect(browserTracingIntegration?.options).toEqual( - expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - // This proves it's still the user's copy - startTransactionOnLocationChange: false, - }), - ); + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + expect(browserTracingIntegration).toBeUndefined(); }); }); }); diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index 95b003e4e14d..b15af158a098 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -1,77 +1,73 @@ import type { IncomingMessage, ServerResponse } from 'http'; import * as SentryCore from '@sentry/core'; -import { addTracingExtensions } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, addTracingExtensions } from '@sentry/core'; import type { Client } from '@sentry/types'; import { wrapGetInitialPropsWithSentry, wrapGetServerSidePropsWithSentry } from '../../src/common'; -const startTransactionSpy = jest.spyOn(SentryCore, 'startTransaction'); +const startSpanManualSpy = jest.spyOn(SentryCore, 'startSpanManual'); // The wrap* functions require the hub to have tracing extensions. This is normally called by the NodeClient // constructor but the client isn't used in these tests. addTracingExtensions(); -describe('data-fetching function wrappers', () => { +describe('data-fetching function wrappers should create spans', () => { const route = '/tricks/[trickName]'; let req: IncomingMessage; let res: ServerResponse; - describe('starts a transaction and puts request in metadata if tracing enabled', () => { - beforeEach(() => { - req = { headers: {}, url: 'http://dogs.are.great/tricks/kangaroo' } as IncomingMessage; - res = { end: jest.fn() } as unknown as ServerResponse; + beforeEach(() => { + req = { headers: {}, url: 'http://dogs.are.great/tricks/kangaroo' } as IncomingMessage; + res = { end: jest.fn() } as unknown as ServerResponse; - jest.spyOn(SentryCore, 'hasTracingEnabled').mockReturnValue(true); - jest.spyOn(SentryCore, 'getClient').mockImplementation(() => { - return { - getOptions: () => ({ instrumenter: 'sentry' }), - getDsn: () => {}, - } as Client; - }); + jest.spyOn(SentryCore, 'hasTracingEnabled').mockReturnValue(true); + jest.spyOn(SentryCore, 'getClient').mockImplementation(() => { + return { + getOptions: () => ({ instrumenter: 'sentry' }), + getDsn: () => {}, + } as Client; }); + }); - afterEach(() => { - jest.clearAllMocks(); - }); + afterEach(() => { + jest.clearAllMocks(); + }); - test('wrapGetServerSidePropsWithSentry', async () => { - const origFunction = jest.fn(async () => ({ props: {} })); + test('wrapGetServerSidePropsWithSentry', async () => { + const origFunction = jest.fn(async () => ({ props: {} })); - const wrappedOriginal = wrapGetServerSidePropsWithSentry(origFunction, route); - await wrappedOriginal({ req, res } as any); + const wrappedOriginal = wrapGetServerSidePropsWithSentry(origFunction, route); + await wrappedOriginal({ req, res } as any); - expect(startTransactionSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: '/tricks/[trickName]', - op: 'http.server', - metadata: expect.objectContaining({ source: 'route', request: req }), - }), - { - request: expect.objectContaining({ - url: 'http://dogs.are.great/tricks/kangaroo', - }), + expect(startSpanManualSpy).toHaveBeenCalledWith( + { + name: 'getServerSideProps (/tricks/[trickName])', + op: 'function.nextjs', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', }, - ); - }); + }, + expect.any(Function), + ); + }); - test('wrapGetInitialPropsWithSentry', async () => { - const origFunction = jest.fn(async () => ({})); + test('wrapGetInitialPropsWithSentry', async () => { + const origFunction = jest.fn(async () => ({})); - const wrappedOriginal = wrapGetInitialPropsWithSentry(origFunction); - await wrappedOriginal({ req, res, pathname: route } as any); + const wrappedOriginal = wrapGetInitialPropsWithSentry(origFunction); + await wrappedOriginal({ req, res, pathname: route } as any); - expect(startTransactionSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: '/tricks/[trickName]', - op: 'http.server', - metadata: expect.objectContaining({ source: 'route', request: req }), - }), - { - request: expect.objectContaining({ - url: 'http://dogs.are.great/tricks/kangaroo', - }), + expect(startSpanManualSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'getInitialProps (/tricks/[trickName])', + op: 'function.nextjs', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', }, - ); - }); + }), + expect.any(Function), + ); }); }); diff --git a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts index 3337b99ab9a9..34a6b31fc60f 100644 --- a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts +++ b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts @@ -30,7 +30,10 @@ describe('appRouterInstrumentation', () => { it('should create a pageload transactions with the current location name', () => { setUpPage('https://example.com/some/page?someParam=foobar'); const startTransactionCallbackFn = jest.fn(); - appRouterInstrumentation(startTransactionCallbackFn, true, false); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); + + appRouterInstrumentation(startTransactionCallbackFn, true, false, mockStartPageloadSpan, mockStartNavigationSpan); expect(startTransactionCallbackFn).toHaveBeenCalledWith( expect.objectContaining({ name: '/some/page', @@ -42,12 +45,26 @@ describe('appRouterInstrumentation', () => { metadata: { source: 'url' }, }), ); + expect(mockStartPageloadSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/some/page', + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + tags: { + 'routing.instrumentation': 'next-app-router', + }, + metadata: { source: 'url' }, + }), + ); }); it('should not create a pageload transaction when `startTransactionOnPageLoad` is false', () => { setUpPage('https://example.com/some/page?someParam=foobar'); const startTransactionCallbackFn = jest.fn(); - appRouterInstrumentation(startTransactionCallbackFn, false, false); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); + + appRouterInstrumentation(startTransactionCallbackFn, false, false, mockStartPageloadSpan, mockStartNavigationSpan); expect(startTransactionCallbackFn).not.toHaveBeenCalled(); }); @@ -60,7 +77,10 @@ describe('appRouterInstrumentation', () => { }); const startTransactionCallbackFn = jest.fn(); - appRouterInstrumentation(startTransactionCallbackFn, false, true); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); + + appRouterInstrumentation(startTransactionCallbackFn, false, true, mockStartPageloadSpan, mockStartNavigationSpan); fetchInstrumentationHandlerCallback!({ args: [ @@ -85,6 +105,16 @@ describe('appRouterInstrumentation', () => { 'routing.instrumentation': 'next-app-router', }, }); + expect(mockStartNavigationSpan).toHaveBeenCalledWith({ + name: '/some/server/component/page', + op: 'navigation', + origin: 'auto.navigation.nextjs.app_router_instrumentation', + metadata: { source: 'url' }, + tags: { + from: '/some/page', + 'routing.instrumentation': 'next-app-router', + }, + }); }); it.each([ @@ -133,7 +163,7 @@ describe('appRouterInstrumentation', () => { }, ], ])( - 'should not create naviagtion transactions for fetch requests that are not navigating RSC requests (%s)', + 'should not create navigation transactions for fetch requests that are not navigating RSC requests (%s)', (_, fetchCallbackData) => { setUpPage('https://example.com/some/page?someParam=foobar'); let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void; @@ -143,9 +173,13 @@ describe('appRouterInstrumentation', () => { }); const startTransactionCallbackFn = jest.fn(); - appRouterInstrumentation(startTransactionCallbackFn, false, true); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); + + appRouterInstrumentation(startTransactionCallbackFn, false, true, mockStartPageloadSpan, mockStartNavigationSpan); fetchInstrumentationHandlerCallback!(fetchCallbackData); expect(startTransactionCallbackFn).not.toHaveBeenCalled(); + expect(mockStartNavigationSpan).not.toHaveBeenCalled(); }, ); @@ -153,9 +187,12 @@ describe('appRouterInstrumentation', () => { setUpPage('https://example.com/some/page?someParam=foobar'); const addFetchInstrumentationHandlerImpl = jest.fn(); const startTransactionCallbackFn = jest.fn(); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); addFetchInstrumentationHandlerSpy.mockImplementationOnce(addFetchInstrumentationHandlerImpl); - appRouterInstrumentation(startTransactionCallbackFn, false, false); + appRouterInstrumentation(startTransactionCallbackFn, false, false, mockStartPageloadSpan, mockStartNavigationSpan); expect(addFetchInstrumentationHandlerImpl).not.toHaveBeenCalled(); + expect(mockStartNavigationSpan).not.toHaveBeenCalled(); }); }); diff --git a/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts b/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts index 592df911bde2..3e032c1f01d1 100644 --- a/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts +++ b/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts @@ -211,20 +211,41 @@ describe('pagesRouterInstrumentation', () => { 'creates a pageload transaction (#%#)', (url, route, query, props, hasNextData, expectedStartTransactionArgument) => { const mockStartTransaction = createMockStartTransaction(); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); + setUpNextPage({ url, route, query, props, hasNextData }); - pagesRouterInstrumentation(mockStartTransaction); + pagesRouterInstrumentation( + mockStartTransaction, + undefined, + undefined, + mockStartPageloadSpan, + mockStartNavigationSpan, + ); + expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith( expect.objectContaining(expectedStartTransactionArgument), ); + expect(mockStartPageloadSpan).toHaveBeenCalledWith(expect.objectContaining(expectedStartTransactionArgument)); }, ); it('does not create a pageload transaction if option not given', () => { const mockStartTransaction = createMockStartTransaction(); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); + setUpNextPage({ url: 'https://example.com/', route: '/', hasNextData: false }); - pagesRouterInstrumentation(mockStartTransaction, false); - expect(mockStartTransaction).toHaveBeenCalledTimes(0); + pagesRouterInstrumentation( + mockStartTransaction, + false, + undefined, + mockStartPageloadSpan, + mockStartNavigationSpan, + ); + expect(mockStartTransaction).not.toHaveBeenCalled(); + expect(mockStartPageloadSpan).not.toHaveBeenCalled(); }); }); @@ -252,6 +273,8 @@ describe('pagesRouterInstrumentation', () => { 'should create a parameterized transaction on route change (%s)', (targetLocation, expectedTransactionName, expectedTransactionSource) => { const mockStartTransaction = createMockStartTransaction(); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); setUpNextPage({ url: 'https://example.com/home', @@ -270,7 +293,7 @@ describe('pagesRouterInstrumentation', () => { ], }); - pagesRouterInstrumentation(mockStartTransaction, false, true); + pagesRouterInstrumentation(mockStartTransaction, false, true, mockStartPageloadSpan, mockStartNavigationSpan); Router.events.emit('routeChangeStart', targetLocation); @@ -287,6 +310,18 @@ describe('pagesRouterInstrumentation', () => { }), }), ); + expect(mockStartNavigationSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: expectedTransactionName, + op: 'navigation', + tags: expect.objectContaining({ + 'routing.instrumentation': 'next-pages-router', + }), + metadata: expect.objectContaining({ + source: expectedTransactionSource, + }), + }), + ); Router.events.emit('routeChangeComplete', targetLocation); // eslint-disable-next-line @typescript-eslint/unbound-method @@ -298,6 +333,8 @@ describe('pagesRouterInstrumentation', () => { it('should not create transaction when navigation transactions are disabled', () => { const mockStartTransaction = createMockStartTransaction(); + const mockStartPageloadSpan = jest.fn(); + const mockStartNavigationSpan = jest.fn(); setUpNextPage({ url: 'https://example.com/home', @@ -306,11 +343,12 @@ describe('pagesRouterInstrumentation', () => { navigatableRoutes: ['/home', '/posts/[id]'], }); - pagesRouterInstrumentation(mockStartTransaction, false, false); + pagesRouterInstrumentation(mockStartTransaction, false, false, mockStartPageloadSpan, mockStartNavigationSpan); Router.events.emit('routeChangeStart', '/posts/42'); expect(mockStartTransaction).not.toHaveBeenCalled(); + expect(mockStartNavigationSpan).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/node-experimental/package.json b/packages/node-experimental/package.json index 264bb55eb65b..cedda1f83426 100644 --- a/packages/node-experimental/package.json +++ b/packages/node-experimental/package.json @@ -29,25 +29,26 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "~1.6.0", - "@opentelemetry/context-async-hooks": "~1.17.1", - "@opentelemetry/core": "~1.17.1", - "@opentelemetry/instrumentation": "0.44.0", - "@opentelemetry/instrumentation-express": "0.33.2", - "@opentelemetry/instrumentation-fastify": "0.32.3", - "@opentelemetry/instrumentation-graphql": "0.35.2", - "@opentelemetry/instrumentation-hapi": "0.33.1", - "@opentelemetry/instrumentation-http": "0.44.0", - "@opentelemetry/instrumentation-mongodb": "0.37.1", - "@opentelemetry/instrumentation-mongoose": "0.33.2", - "@opentelemetry/instrumentation-mysql": "0.34.2", - "@opentelemetry/instrumentation-mysql2": "0.34.2", - "@opentelemetry/instrumentation-nestjs-core": "0.33.2", - "@opentelemetry/instrumentation-pg": "0.36.2", - "@opentelemetry/resources": "~1.17.1", - "@opentelemetry/sdk-trace-base": "~1.17.1", - "@opentelemetry/semantic-conventions": "~1.17.1", - "@prisma/instrumentation": "5.4.2", + "@opentelemetry/api": "1.7.0", + "@opentelemetry/context-async-hooks": "1.21.0", + "@opentelemetry/core": "1.21.0", + "@opentelemetry/instrumentation": "0.48.0", + "@opentelemetry/instrumentation-express": "0.35.0", + "@opentelemetry/instrumentation-fastify": "0.33.0", + "@opentelemetry/instrumentation-graphql": "0.37.0", + "@opentelemetry/instrumentation-hapi": "0.34.0", + "@opentelemetry/instrumentation-http": "0.48.0", + "@opentelemetry/instrumentation-koa": "0.37.0", + "@opentelemetry/instrumentation-mongodb": "0.39.0", + "@opentelemetry/instrumentation-mongoose": "0.35.0", + "@opentelemetry/instrumentation-mysql": "0.35.0", + "@opentelemetry/instrumentation-mysql2": "0.35.0", + "@opentelemetry/instrumentation-nestjs-core": "0.34.0", + "@opentelemetry/instrumentation-pg": "0.38.0", + "@opentelemetry/resources": "1.21.0", + "@opentelemetry/sdk-trace-base": "1.21.0", + "@opentelemetry/semantic-conventions": "1.21.0", + "@prisma/instrumentation": "5.9.0", "@sentry/core": "7.99.0", "@sentry/node": "7.99.0", "@sentry/opentelemetry": "7.99.0", diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index 2fcb4ee1b166..b5ffeb6de0c9 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -1,15 +1,27 @@ import { Integrations as CoreIntegrations } from '@sentry/core'; import * as NodeExperimentalIntegrations from './integrations'; +export { expressIntegration } from './integrations/express'; +export { fastifyIntegration } from './integrations/fastify'; +export { graphqlIntegration } from './integrations/graphql'; +export { httpIntegration } from './integrations/http'; +export { mongoIntegration } from './integrations/mongo'; +export { mongooseIntegration } from './integrations/mongoose'; +export { mysqlIntegration } from './integrations/mysql'; +export { mysql2Integration } from './integrations/mysql2'; +export { nestIntegration } from './integrations/nest'; +export { nativeNodeFetchIntegration } from './integrations/node-fetch'; +export { postgresIntegration } from './integrations/postgres'; +export { prismaIntegration } from './integrations/prisma'; -const INTEGRATIONS = { +/** @deprecated Import the integration function directly, e.g. `inboundFiltersIntegration()` instead of `new Integrations.InboundFilter(). */ +export const Integrations = { // eslint-disable-next-line deprecation/deprecation ...CoreIntegrations, ...NodeExperimentalIntegrations, }; export { init } from './sdk/init'; -export { INTEGRATIONS as Integrations }; export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanceIntegrations'; export * as Handlers from './sdk/handlers'; export type { Span } from './types'; @@ -69,11 +81,23 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, captureCheckIn, withMonitor, hapiErrorPlugin, + consoleIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + modulesIntegration, + contextLinesIntegration, + nodeContextIntegration, + localVariablesIntegration, + requestDataIntegration, + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, } from '@sentry/node'; export type { diff --git a/packages/node-experimental/src/integrations/express.ts b/packages/node-experimental/src/integrations/express.ts index 0bbe3a19a11d..1931038da714 100644 --- a/packages/node-experimental/src/integrations/express.ts +++ b/packages/node-experimental/src/integrations/express.ts @@ -1,14 +1,36 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _expressIntegration = (() => { + return { + name: 'Express', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + new ExpressInstrumentation({ + requestHook(span) { + addOriginToSpan(span, 'auto.http.otel.express'); + }, + }), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const expressIntegration = defineIntegration(_expressIntegration); + /** * Express integration * * Capture tracing data for express. + * @deprecated Use `expressIntegration()` instead. */ export class Express extends NodePerformanceIntegration implements Integration { /** @@ -23,6 +45,7 @@ export class Express extends NodePerformanceIntegration implements Integra public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Express.id; } diff --git a/packages/node-experimental/src/integrations/fastify.ts b/packages/node-experimental/src/integrations/fastify.ts index 4d32037887b1..b34d267934aa 100644 --- a/packages/node-experimental/src/integrations/fastify.ts +++ b/packages/node-experimental/src/integrations/fastify.ts @@ -1,14 +1,37 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { FastifyInstrumentation } from '@opentelemetry/instrumentation-fastify'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _fastifyIntegration = (() => { + return { + name: 'Fastify', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + new FastifyInstrumentation({ + requestHook(span) { + addOriginToSpan(span, 'auto.http.otel.fastify'); + }, + }), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const fastifyIntegration = defineIntegration(_fastifyIntegration); + /** * Express integration * * Capture tracing data for fastify. + * + * @deprecated Use `fastifyIntegration()` instead. */ export class Fastify extends NodePerformanceIntegration implements Integration { /** @@ -23,6 +46,7 @@ export class Fastify extends NodePerformanceIntegration implements Integra public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Fastify.id; } diff --git a/packages/node-experimental/src/integrations/getAutoPerformanceIntegrations.ts b/packages/node-experimental/src/integrations/getAutoPerformanceIntegrations.ts index 1a4200ab6fb0..ce3773fec1eb 100644 --- a/packages/node-experimental/src/integrations/getAutoPerformanceIntegrations.ts +++ b/packages/node-experimental/src/integrations/getAutoPerformanceIntegrations.ts @@ -1,73 +1,34 @@ import type { Integration } from '@sentry/types'; -import type { NodePerformanceIntegration } from './NodePerformanceIntegration'; -import { Express } from './express'; -import { Fastify } from './fastify'; -import { GraphQL } from './graphql'; -import { Hapi } from './hapi'; -import { Mongo } from './mongo'; -import { Mongoose } from './mongoose'; -import { Mysql } from './mysql'; -import { Mysql2 } from './mysql2'; -import { Nest } from './nest'; -import { Postgres } from './postgres'; -import { Prisma } from './prisma'; - -const INTEGRATIONS: (() => NodePerformanceIntegration)[] = [ - () => { - return new Express(); - }, - () => { - return new Fastify(); - }, - () => { - return new GraphQL(); - }, - () => { - return new Mongo(); - }, - () => { - return new Mongoose(); - }, - () => { - return new Mysql(); - }, - () => { - return new Mysql2(); - }, - () => { - return new Postgres(); - }, - () => { - return new Prisma(); - }, - () => { - return new Nest(); - }, - () => { - return new Hapi(); - }, -]; +import { expressIntegration } from './express'; +import { fastifyIntegration } from './fastify'; +import { graphqlIntegration } from './graphql'; +import { hapiIntegration } from './hapi'; +import { koaIntegration } from './koa'; +import { mongoIntegration } from './mongo'; +import { mongooseIntegration } from './mongoose'; +import { mysqlIntegration } from './mysql'; +import { mysql2Integration } from './mysql2'; +import { nestIntegration } from './nest'; +import { postgresIntegration } from './postgres'; +import { prismaIntegration } from './prisma'; /** - * Get auto-dsicovered performance integrations. - * Note that due to the way OpenTelemetry instrumentation works, this will generally still return Integrations - * for stuff that may not be installed. This is because Otel only instruments when the module is imported/required, - * so if the package is not required at all it will not be patched, and thus not instrumented. - * But the _Sentry_ Integration will still be added. - * This _may_ be a bit confusing because it shows all integrations as being installed in the debug logs, but this is - * technically not wrong because we install it (it just doesn't do anything). + * With OTEL, all performance integrations will be added, as OTEL only initializes them when the patched package is actually required. */ export function getAutoPerformanceIntegrations(): Integration[] { - const loadedIntegrations = INTEGRATIONS.map(tryLoad => { - try { - const integration = tryLoad(); - const isLoaded = integration.loadInstrumentations(); - return isLoaded ? integration : false; - } catch (_) { - return false; - } - }).filter(integration => !!integration) as Integration[]; - - return loadedIntegrations; + return [ + expressIntegration(), + fastifyIntegration(), + graphqlIntegration(), + mongoIntegration(), + mongooseIntegration(), + mysqlIntegration(), + mysql2Integration(), + postgresIntegration(), + prismaIntegration(), + nestIntegration(), + hapiIntegration(), + koaIntegration(), + ]; } diff --git a/packages/node-experimental/src/integrations/graphql.ts b/packages/node-experimental/src/integrations/graphql.ts index b4a529df713e..576d049c44b9 100644 --- a/packages/node-experimental/src/integrations/graphql.ts +++ b/packages/node-experimental/src/integrations/graphql.ts @@ -1,14 +1,38 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _graphqlIntegration = (() => { + return { + name: 'Graphql', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + new GraphQLInstrumentation({ + ignoreTrivialResolveSpans: true, + responseHook(span) { + addOriginToSpan(span, 'auto.graphql.otel.graphql'); + }, + }), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const graphqlIntegration = defineIntegration(_graphqlIntegration); + /** * GraphQL integration * * Capture tracing data for GraphQL. + * + * @deprecated Use `graphqlIntegration()` instead. */ export class GraphQL extends NodePerformanceIntegration implements Integration { /** @@ -23,6 +47,7 @@ export class GraphQL extends NodePerformanceIntegration implements Integra public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = GraphQL.id; } diff --git a/packages/node-experimental/src/integrations/hapi.ts b/packages/node-experimental/src/integrations/hapi.ts index 3f486e07961c..1376bcb49ccf 100644 --- a/packages/node-experimental/src/integrations/hapi.ts +++ b/packages/node-experimental/src/integrations/hapi.ts @@ -1,13 +1,30 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { HapiInstrumentation } from '@opentelemetry/instrumentation-hapi'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _hapiIntegration = (() => { + return { + name: 'Hapi', + setupOnce() { + registerInstrumentations({ + instrumentations: [new HapiInstrumentation()], + }); + }, + }; +}) satisfies IntegrationFn; + +export const hapiIntegration = defineIntegration(_hapiIntegration); + /** * Hapi integration * * Capture tracing data for Hapi. + * + * @deprecated Use `hapiIntegration()` instead. */ export class Hapi extends NodePerformanceIntegration implements Integration { /** @@ -22,6 +39,7 @@ export class Hapi extends NodePerformanceIntegration implements Integratio public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Hapi.id; } diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index 66606bbf8258..6894ddc4ab2e 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -3,9 +3,9 @@ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { addBreadcrumb, hasTracingEnabled, isSentryRequestUrl } from '@sentry/core'; +import { addBreadcrumb, defineIntegration, hasTracingEnabled, isSentryRequestUrl } from '@sentry/core'; import { _INTERNAL, getClient, getSpanKind, setSpanMetadata } from '@sentry/opentelemetry'; -import type { EventProcessor, Hub, Integration } from '@sentry/types'; +import type { EventProcessor, Hub, Integration, IntegrationFn } from '@sentry/types'; import { stringMatchesSomePattern } from '@sentry/utils'; import { getIsolationScope, setIsolationScope } from '../sdk/api'; @@ -14,6 +14,97 @@ import { addOriginToSpan } from '../utils/addOriginToSpan'; import { getRequestUrl } from '../utils/getRequestUrl'; interface HttpOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * Defaults to true + */ + breadcrumbs?: boolean; + + /** + * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + */ + ignoreOutgoingRequests?: (url: string) => boolean; + + /** + * Do not capture spans or breadcrumbs for incoming HTTP requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + */ + ignoreIncomingRequests?: (url: string) => boolean; +} + +const _httpIntegration = ((options: HttpOptions = {}) => { + const _breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs; + const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; + const _ignoreIncomingRequests = options.ignoreIncomingRequests; + + return { + name: 'Http', + setupOnce() { + const instrumentations = [ + new HttpInstrumentation({ + ignoreOutgoingRequestHook: request => { + const url = getRequestUrl(request); + + if (!url) { + return false; + } + + if (isSentryRequestUrl(url, getClient())) { + return true; + } + + if (_ignoreOutgoingRequests && _ignoreOutgoingRequests(url)) { + return true; + } + + return false; + }, + + ignoreIncomingRequestHook: request => { + const url = getRequestUrl(request); + + const method = request.method?.toUpperCase(); + // We do not capture OPTIONS/HEAD requests as transactions + if (method === 'OPTIONS' || method === 'HEAD') { + return true; + } + + if (_ignoreIncomingRequests && _ignoreIncomingRequests(url)) { + return true; + } + + return false; + }, + + requireParentforOutgoingSpans: true, + requireParentforIncomingSpans: false, + requestHook: (span, req) => { + _updateSpan(span, req); + + // Update the isolation scope, isolate this request + if (getSpanKind(span) === SpanKind.SERVER) { + setIsolationScope(getIsolationScope().clone()); + } + }, + responseHook: (span, res) => { + if (_breadcrumbs) { + _addRequestBreadcrumb(span, res); + } + }, + }), + ]; + + registerInstrumentations({ + instrumentations, + }); + }, + }; +}) satisfies IntegrationFn; + +export const httpIntegration = defineIntegration(_httpIntegration); + +interface OldHttpOptions { /** * Whether breadcrumbs should be recorded for requests * Defaults to true @@ -39,6 +130,8 @@ interface HttpOptions { * * Create spans for outgoing requests * * Note that this integration is also needed for the Express integration to work! + * + * @deprecated Use `httpIntegration()` instead. */ export class Http implements Integration { /** @@ -65,7 +158,8 @@ export class Http implements Integration { /** * @inheritDoc */ - public constructor(options: HttpOptions = {}) { + public constructor(options: OldHttpOptions = {}) { + // eslint-disable-next-line deprecation/deprecation this.name = Http.id; this._breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs; this._spans = typeof options.spans === 'undefined' ? undefined : options.spans; @@ -127,7 +221,7 @@ export class Http implements Integration { requireParentforOutgoingSpans: true, requireParentforIncomingSpans: false, requestHook: (span, req) => { - this._updateSpan(span, req); + _updateSpan(span, req); // Update the isolation scope, isolate this request if (getSpanKind(span) === SpanKind.SERVER) { @@ -135,7 +229,9 @@ export class Http implements Integration { } }, responseHook: (span, res) => { - this._addRequestBreadcrumb(span, res); + if (this._breadcrumbs) { + _addRequestBreadcrumb(span, res); + } }, }), ], @@ -148,39 +244,39 @@ export class Http implements Integration { public unregister(): void { this._unload?.(); } +} - /** Update the span with data we need. */ - private _updateSpan(span: Span, request: ClientRequest | IncomingMessage): void { - addOriginToSpan(span, 'auto.http.otel.http'); +/** Update the span with data we need. */ +function _updateSpan(span: Span, request: ClientRequest | IncomingMessage): void { + addOriginToSpan(span, 'auto.http.otel.http'); - if (getSpanKind(span) === SpanKind.SERVER) { - setSpanMetadata(span, { request }); - } + if (getSpanKind(span) === SpanKind.SERVER) { + setSpanMetadata(span, { request }); } +} - /** Add a breadcrumb for outgoing requests. */ - private _addRequestBreadcrumb(span: Span, response: IncomingMessage | ServerResponse): void { - if (!this._breadcrumbs || getSpanKind(span) !== SpanKind.CLIENT) { - return; - } +/** Add a breadcrumb for outgoing requests. */ +function _addRequestBreadcrumb(span: Span, response: IncomingMessage | ServerResponse): void { + if (getSpanKind(span) !== SpanKind.CLIENT) { + return; + } - const data = _INTERNAL.getRequestSpanData(span); - addBreadcrumb( - { - category: 'http', - data: { - status_code: response.statusCode, - ...data, - }, - type: 'http', + const data = _INTERNAL.getRequestSpanData(span); + addBreadcrumb( + { + category: 'http', + data: { + status_code: response.statusCode, + ...data, }, - { - event: 'response', - // TODO FN: Do we need access to `request` here? - // If we do, we'll have to use the `applyCustomAttributesOnSpan` hook instead, - // but this has worse context semantics than request/responseHook. - response, - }, - ); - } + type: 'http', + }, + { + event: 'response', + // TODO FN: Do we need access to `request` here? + // If we do, we'll have to use the `applyCustomAttributesOnSpan` hook instead, + // but this has worse context semantics than request/responseHook. + response, + }, + ); } diff --git a/packages/node-experimental/src/integrations/index.ts b/packages/node-experimental/src/integrations/index.ts index b625872d2fb7..a37c0f3b615e 100644 --- a/packages/node-experimental/src/integrations/index.ts +++ b/packages/node-experimental/src/integrations/index.ts @@ -9,6 +9,7 @@ const { Context, RequestData, LocalVariables, + // eslint-disable-next-line deprecation/deprecation } = NodeIntegrations; export { @@ -22,6 +23,7 @@ export { LocalVariables, }; +/* eslint-disable deprecation/deprecation */ export { Express } from './express'; export { Http } from './http'; export { NodeFetch } from './node-fetch'; diff --git a/packages/node-experimental/src/integrations/koa.ts b/packages/node-experimental/src/integrations/koa.ts new file mode 100644 index 000000000000..2d85703c054a --- /dev/null +++ b/packages/node-experimental/src/integrations/koa.ts @@ -0,0 +1,17 @@ +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { KoaInstrumentation } from '@opentelemetry/instrumentation-koa'; +import { defineIntegration } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; + +const _koaIntegration = (() => { + return { + name: 'Koa', + setupOnce() { + registerInstrumentations({ + instrumentations: [new KoaInstrumentation()], + }); + }, + }; +}) satisfies IntegrationFn; + +export const koaIntegration = defineIntegration(_koaIntegration); diff --git a/packages/node-experimental/src/integrations/mongo.ts b/packages/node-experimental/src/integrations/mongo.ts index f8be482be946..bcfaaaf1bc62 100644 --- a/packages/node-experimental/src/integrations/mongo.ts +++ b/packages/node-experimental/src/integrations/mongo.ts @@ -1,14 +1,37 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _mongoIntegration = (() => { + return { + name: 'Mongo', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + new MongoDBInstrumentation({ + responseHook(span) { + addOriginToSpan(span, 'auto.db.otel.mongo'); + }, + }), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const mongoIntegration = defineIntegration(_mongoIntegration); + /** * MongoDB integration * * Capture tracing data for MongoDB. + * + * @deprecated Use `mongoIntegration()` instead. */ export class Mongo extends NodePerformanceIntegration implements Integration { /** @@ -23,6 +46,7 @@ export class Mongo extends NodePerformanceIntegration implements Integrati public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Mongo.id; } diff --git a/packages/node-experimental/src/integrations/mongoose.ts b/packages/node-experimental/src/integrations/mongoose.ts index a5361a620bc2..a14c7d54a266 100644 --- a/packages/node-experimental/src/integrations/mongoose.ts +++ b/packages/node-experimental/src/integrations/mongoose.ts @@ -1,14 +1,37 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { MongooseInstrumentation } from '@opentelemetry/instrumentation-mongoose'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _mongooseIntegration = (() => { + return { + name: 'Mongoose', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + new MongooseInstrumentation({ + responseHook(span) { + addOriginToSpan(span, 'auto.db.otel.mongoose'); + }, + }), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const mongooseIntegration = defineIntegration(_mongooseIntegration); + /** * Mongoose integration * * Capture tracing data for Mongoose. + * + * @deprecated Use `mongooseIntegration()` instead. */ export class Mongoose extends NodePerformanceIntegration implements Integration { /** @@ -23,6 +46,7 @@ export class Mongoose extends NodePerformanceIntegration implements Integr public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Mongoose.id; } diff --git a/packages/node-experimental/src/integrations/mysql.ts b/packages/node-experimental/src/integrations/mysql.ts index 3973f07f4685..3cf0f4e42c87 100644 --- a/packages/node-experimental/src/integrations/mysql.ts +++ b/packages/node-experimental/src/integrations/mysql.ts @@ -1,13 +1,30 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { MySQLInstrumentation } from '@opentelemetry/instrumentation-mysql'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _mysqlIntegration = (() => { + return { + name: 'Mysql', + setupOnce() { + registerInstrumentations({ + instrumentations: [new MySQLInstrumentation({})], + }); + }, + }; +}) satisfies IntegrationFn; + +export const mysqlIntegration = defineIntegration(_mysqlIntegration); + /** * MySQL integration * * Capture tracing data for mysql. + * + * @deprecated Use `mysqlIntegration()` instead. */ export class Mysql extends NodePerformanceIntegration implements Integration { /** @@ -22,6 +39,7 @@ export class Mysql extends NodePerformanceIntegration implements Integrati public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Mysql.id; } diff --git a/packages/node-experimental/src/integrations/mysql2.ts b/packages/node-experimental/src/integrations/mysql2.ts index 9a87de98fd66..bb89d0aa01cb 100644 --- a/packages/node-experimental/src/integrations/mysql2.ts +++ b/packages/node-experimental/src/integrations/mysql2.ts @@ -1,14 +1,37 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { MySQL2Instrumentation } from '@opentelemetry/instrumentation-mysql2'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _mysql2Integration = (() => { + return { + name: 'Mysql2', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + new MySQL2Instrumentation({ + responseHook(span) { + addOriginToSpan(span, 'auto.db.otel.mysql2'); + }, + }), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const mysql2Integration = defineIntegration(_mysql2Integration); + /** * MySQL2 integration * * Capture tracing data for mysql2 + * + * @deprecated Use `mysql2Integration()` instead. */ export class Mysql2 extends NodePerformanceIntegration implements Integration { /** @@ -23,6 +46,7 @@ export class Mysql2 extends NodePerformanceIntegration implements Integrat public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Mysql2.id; } diff --git a/packages/node-experimental/src/integrations/nest.ts b/packages/node-experimental/src/integrations/nest.ts index b7e47b2f49c8..c03955f71193 100644 --- a/packages/node-experimental/src/integrations/nest.ts +++ b/packages/node-experimental/src/integrations/nest.ts @@ -1,13 +1,30 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _nestIntegration = (() => { + return { + name: 'Nest', + setupOnce() { + registerInstrumentations({ + instrumentations: [new NestInstrumentation({})], + }); + }, + }; +}) satisfies IntegrationFn; + +export const nestIntegration = defineIntegration(_nestIntegration); + /** * Nest framework integration * * Capture tracing data for nest. + * + * @deprecated Use `nestIntegration()` instead. */ export class Nest extends NodePerformanceIntegration implements Integration { /** @@ -22,6 +39,7 @@ export class Nest extends NodePerformanceIntegration implements Integratio public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Nest.id; } diff --git a/packages/node-experimental/src/integrations/node-fetch.ts b/packages/node-experimental/src/integrations/node-fetch.ts index a2b7b61bdfc0..35bf982d286e 100644 --- a/packages/node-experimental/src/integrations/node-fetch.ts +++ b/packages/node-experimental/src/integrations/node-fetch.ts @@ -1,9 +1,10 @@ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import type { Instrumentation } from '@opentelemetry/instrumentation'; -import { addBreadcrumb, hasTracingEnabled } from '@sentry/core'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { addBreadcrumb, defineIntegration, hasTracingEnabled } from '@sentry/core'; import { _INTERNAL, getClient, getSpanKind } from '@sentry/opentelemetry'; -import type { Integration } from '@sentry/types'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { parseSemver } from '@sentry/utils'; import type { NodeExperimentalClient } from '../types'; @@ -13,6 +14,70 @@ import { NodePerformanceIntegration } from './NodePerformanceIntegration'; const NODE_VERSION: ReturnType = parseSemver(process.versions.node); interface NodeFetchOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * Defaults to true + */ + breadcrumbs?: boolean; + + /** + * Do not capture spans or breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + */ + ignoreOutgoingRequests?: (url: string) => boolean; +} + +const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { + const _breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs; + const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; + + function getInstrumentation(): [Instrumentation] | void { + // Only add NodeFetch if Node >= 16, as previous versions do not support it + if (!NODE_VERSION.major || NODE_VERSION.major < 16) { + return; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { FetchInstrumentation } = require('opentelemetry-instrumentation-fetch-node'); + return [ + new FetchInstrumentation({ + ignoreRequestHook: (request: { origin?: string }) => { + const url = request.origin; + return _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); + }, + + onRequest: ({ span }: { span: Span }) => { + _updateSpan(span); + + if (_breadcrumbs) { + _addRequestBreadcrumb(span); + } + }, + }), + ]; + } catch (error) { + // Could not load instrumentation + } + } + + return { + name: 'NodeFetch', + setupOnce() { + const instrumentations = getInstrumentation(); + + if (instrumentations) { + registerInstrumentations({ + instrumentations, + }); + } + }, + }; +}) satisfies IntegrationFn; + +export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration); + +interface OldNodeFetchOptions { /** * Whether breadcrumbs should be recorded for requests * Defaults to true @@ -31,8 +96,10 @@ interface NodeFetchOptions { * This instrumentation does two things: * * Create breadcrumbs for outgoing requests * * Create spans for outgoing requests + * + * @deprecated Use `nativeNodeFetchIntegration()` instead. */ -export class NodeFetch extends NodePerformanceIntegration implements Integration { +export class NodeFetch extends NodePerformanceIntegration implements Integration { /** * @inheritDoc */ @@ -55,9 +122,10 @@ export class NodeFetch extends NodePerformanceIntegration impl /** * @inheritDoc */ - public constructor(options: NodeFetchOptions = {}) { + public constructor(options: OldNodeFetchOptions = {}) { super(options); + // eslint-disable-next-line deprecation/deprecation this.name = NodeFetch.id; this._breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs; this._spans = typeof options.spans === 'undefined' ? undefined : options.spans; @@ -79,8 +147,11 @@ export class NodeFetch extends NodePerformanceIntegration impl return [ new FetchInstrumentation({ onRequest: ({ span }: { span: Span }) => { - this._updateSpan(span); - this._addRequestBreadcrumb(span); + _updateSpan(span); + + if (this._breadcrumbs) { + _addRequestBreadcrumb(span); + } }, }), ]; @@ -109,25 +180,25 @@ export class NodeFetch extends NodePerformanceIntegration impl public unregister(): void { this._unload?.(); } +} - /** Update the span with data we need. */ - private _updateSpan(span: Span): void { - addOriginToSpan(span, 'auto.http.otel.node_fetch'); - } - - /** Add a breadcrumb for outgoing requests. */ - private _addRequestBreadcrumb(span: Span): void { - if (!this._breadcrumbs || getSpanKind(span) !== SpanKind.CLIENT) { - return; - } +/** Update the span with data we need. */ +function _updateSpan(span: Span): void { + addOriginToSpan(span, 'auto.http.otel.node_fetch'); +} - const data = _INTERNAL.getRequestSpanData(span); - addBreadcrumb({ - category: 'http', - data: { - ...data, - }, - type: 'http', - }); +/** Add a breadcrumb for outgoing requests. */ +function _addRequestBreadcrumb(span: Span): void { + if (getSpanKind(span) !== SpanKind.CLIENT) { + return; } + + const data = _INTERNAL.getRequestSpanData(span); + addBreadcrumb({ + category: 'http', + data: { + ...data, + }, + type: 'http', + }); } diff --git a/packages/node-experimental/src/integrations/postgres.ts b/packages/node-experimental/src/integrations/postgres.ts index 85584f8a6507..91a6a710ffdd 100644 --- a/packages/node-experimental/src/integrations/postgres.ts +++ b/packages/node-experimental/src/integrations/postgres.ts @@ -1,14 +1,38 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _postgresIntegration = (() => { + return { + name: 'Postgres', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + new PgInstrumentation({ + requireParentSpan: true, + requestHook(span) { + addOriginToSpan(span, 'auto.db.otel.postgres'); + }, + }), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const postgresIntegration = defineIntegration(_postgresIntegration); + /** * Postgres integration * * Capture tracing data for pg. + * + * @deprecated Use `postgresIntegration()` instead. */ export class Postgres extends NodePerformanceIntegration implements Integration { /** @@ -23,6 +47,7 @@ export class Postgres extends NodePerformanceIntegration implements Integr public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Postgres.id; } diff --git a/packages/node-experimental/src/integrations/prisma.ts b/packages/node-experimental/src/integrations/prisma.ts index 203e9d8ed6b1..9edd6ce9d02d 100644 --- a/packages/node-experimental/src/integrations/prisma.ts +++ b/packages/node-experimental/src/integrations/prisma.ts @@ -1,9 +1,27 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { PrismaInstrumentation } from '@prisma/instrumentation'; -import type { Integration } from '@sentry/types'; +import { defineIntegration } from '@sentry/core'; +import type { Integration, IntegrationFn } from '@sentry/types'; import { NodePerformanceIntegration } from './NodePerformanceIntegration'; +const _prismaIntegration = (() => { + return { + name: 'Prisma', + setupOnce() { + registerInstrumentations({ + instrumentations: [ + // does not have a hook to adjust spans & add origin + new PrismaInstrumentation({}), + ], + }); + }, + }; +}) satisfies IntegrationFn; + +export const prismaIntegration = defineIntegration(_prismaIntegration); + /** * Prisma integration * @@ -12,6 +30,8 @@ import { NodePerformanceIntegration } from './NodePerformanceIntegration'; * previewFeatures = ["tracing"] * For the prisma client. * See https://www.prisma.io/docs/concepts/components/prisma-client/opentelemetry-tracing for more details. + * + * @deprecated Use `prismaIntegration()` instead. */ export class Prisma extends NodePerformanceIntegration implements Integration { /** @@ -26,6 +46,7 @@ export class Prisma extends NodePerformanceIntegration implements Integrat public constructor() { super(); + // eslint-disable-next-line deprecation/deprecation this.name = Prisma.id; } diff --git a/packages/node-experimental/src/sdk/init.ts b/packages/node-experimental/src/sdk/init.ts index 353d38be90f2..d617845b9e2e 100644 --- a/packages/node-experimental/src/sdk/init.ts +++ b/packages/node-experimental/src/sdk/init.ts @@ -1,25 +1,25 @@ import { endSession, getIntegrationsToSetup, hasTracingEnabled, startSession } from '@sentry/core'; import { - Integrations, defaultIntegrations as defaultNodeIntegrations, defaultStackParser, getDefaultIntegrations as getDefaultNodeIntegrations, getSentryRelease, makeNodeTransport, + spotlightIntegration, } from '@sentry/node'; import type { Client, Integration, Options } from '@sentry/types'; import { consoleSandbox, dropUndefinedKeys, logger, + propagationContextFromHeaders, stackParserFromStackParserOptions, - tracingContextFromHeaders, } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; import { getAutoPerformanceIntegrations } from '../integrations/getAutoPerformanceIntegrations'; -import { Http } from '../integrations/http'; -import { NodeFetch } from '../integrations/node-fetch'; +import { httpIntegration } from '../integrations/http'; +import { nativeNodeFetchIntegration } from '../integrations/node-fetch'; import { setOpenTelemetryContextAsyncContextStrategy } from '../otel/asyncContextStrategy'; import type { NodeExperimentalClientOptions, NodeExperimentalOptions } from '../types'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from './api'; @@ -34,16 +34,16 @@ const ignoredDefaultIntegrations = ['Http', 'Undici']; export const defaultIntegrations: Integration[] = [ // eslint-disable-next-line deprecation/deprecation ...defaultNodeIntegrations.filter(i => !ignoredDefaultIntegrations.includes(i.name)), - new Http(), - new NodeFetch(), + httpIntegration(), + nativeNodeFetchIntegration(), ]; /** Get the default integrations for the Node Experimental SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { return [ ...getDefaultNodeIntegrations(options).filter(i => !ignoredDefaultIntegrations.includes(i.name)), - new Http(), - new NodeFetch(), + httpIntegration(), + nativeNodeFetchIntegration(), ...(hasTracingEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; } @@ -94,7 +94,7 @@ export function init(options: NodeExperimentalOptions | undefined = {}): void { client.addIntegration(integration); } client.addIntegration( - new Integrations.Spotlight({ + spotlightIntegration({ sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined, }), ); @@ -190,7 +190,7 @@ function updateScopeFromEnvVariables(): void { if (!['false', 'n', 'no', 'off', '0'].includes(sentryUseEnvironment)) { const sentryTraceEnv = process.env.SENTRY_TRACE; const baggageEnv = process.env.SENTRY_BAGGAGE; - const { propagationContext } = tracingContextFromHeaders(sentryTraceEnv, baggageEnv); + const propagationContext = propagationContextFromHeaders(sentryTraceEnv, baggageEnv); getCurrentScope().setPropagationContext(propagationContext); } } diff --git a/packages/node-experimental/src/sdk/spanProcessor.ts b/packages/node-experimental/src/sdk/spanProcessor.ts index 226add7753cf..a85085077e94 100644 --- a/packages/node-experimental/src/sdk/spanProcessor.ts +++ b/packages/node-experimental/src/sdk/spanProcessor.ts @@ -33,12 +33,15 @@ export class NodeExperimentalSentrySpanProcessor extends SentrySpanProcessor { /** @inheritDoc */ protected _shouldSendSpanToSentry(span: Span): boolean { const client = getClient(); + // eslint-disable-next-line deprecation/deprecation const httpIntegration = client ? client.getIntegrationByName('Http') : undefined; + // eslint-disable-next-line deprecation/deprecation const fetchIntegration = client ? client.getIntegrationByName('NodeFetch') : undefined; // If we encounter a client or server span with url & method, we assume this comes from the http instrumentation // In this case, if `shouldCreateSpansForRequests` is false, we want to _record_ the span but not _sample_ it, // So we can generate a breadcrumb for it but no span will be sent + // TODO v8: Remove this if ( (span.kind === SpanKind.CLIENT || span.kind === SpanKind.SERVER) && span.attributes[SemanticAttributes.HTTP_URL] && diff --git a/packages/node-experimental/test/integration/transactions.test.ts b/packages/node-experimental/test/integration/transactions.test.ts index 929b286452f3..d379070c4ee1 100644 --- a/packages/node-experimental/test/integration/transactions.test.ts +++ b/packages/node-experimental/test/integration/transactions.test.ts @@ -540,6 +540,7 @@ describe('Integration | Transactions', () => { if (name === 'Http') { return { shouldCreateSpansForRequests: false, + // eslint-disable-next-line deprecation/deprecation } as Http; } @@ -604,6 +605,7 @@ describe('Integration | Transactions', () => { if (name === 'NodeFetch') { return { shouldCreateSpansForRequests: false, + // eslint-disable-next-line deprecation/deprecation } as NodeFetch; } diff --git a/packages/node/package.json b/packages/node/package.json index 689fcb3849aa..31ba67574c1c 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -63,7 +63,7 @@ "test:express": "node test/manual/express-scope-separation/start.js", "test:jest": "jest", "test:release-health": "node test/manual/release-health/runner.js", - "test:webpack": "cd test/manual/webpack-async-context/ && yarn --silent && node npm-build.js", + "test:webpack": "cd test/manual/webpack-async-context/ && yarn --silent --ignore-engines && node npm-build.js", "test:watch": "jest --watch", "yalc:publish": "ts-node ../../scripts/prepack.ts && yalc publish build --push --sig" }, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 79edd5eddd89..8d0a82ecbfe6 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -68,6 +68,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, withScope, @@ -122,22 +123,36 @@ import * as Handlers from './handlers'; import * as NodeIntegrations from './integrations'; import * as TracingIntegrations from './tracing/integrations'; -const INTEGRATIONS = { +// TODO: Deprecate this once we migrated tracing integrations +export const Integrations = { // eslint-disable-next-line deprecation/deprecation ...CoreIntegrations, ...NodeIntegrations, ...TracingIntegrations, }; +export { consoleIntegration } from './integrations/console'; +export { onUncaughtExceptionIntegration } from './integrations/onuncaughtexception'; +export { onUnhandledRejectionIntegration } from './integrations/onunhandledrejection'; +export { modulesIntegration } from './integrations/modules'; +export { contextLinesIntegration } from './integrations/contextlines'; +export { nodeContextIntegration } from './integrations/context'; +export { localVariablesIntegration } from './integrations/local-variables'; +export { spotlightIntegration } from './integrations/spotlight'; +export { anrIntegration } from './integrations/anr'; +export { hapiIntegration } from './integrations/hapi'; +// eslint-disable-next-line deprecation/deprecation +export { Undici, nativeNodeFetchintegration } from './integrations/undici'; +// eslint-disable-next-line deprecation/deprecation +export { Http, httpIntegration } from './integrations/http'; + // TODO(v8): Remove all of these exports. They were part of a hotfix #10339 where we produced wrong .d.ts files because we were packing packages inside the /build folder. export type { LocalVariablesIntegrationOptions } from './integrations/local-variables/common'; export type { DebugSession } from './integrations/local-variables/local-variables-sync'; export type { AnrIntegrationOptions } from './integrations/anr/common'; -export { Undici } from './integrations/undici'; -export { Http } from './integrations/http'; // --- -export { INTEGRATIONS as Integrations, Handlers }; +export { Handlers }; export { hapiErrorPlugin } from './integrations/hapi'; diff --git a/packages/node/src/integrations/anr/index.ts b/packages/node/src/integrations/anr/index.ts index 81b45bf5bf03..91deb2259e72 100644 --- a/packages/node/src/integrations/anr/index.ts +++ b/packages/node/src/integrations/anr/index.ts @@ -1,6 +1,6 @@ // TODO (v8): This import can be removed once we only support Node with global URL import { URL } from 'url'; -import { convertIntegrationFnToClass, getCurrentScope } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration, getCurrentScope } from '@sentry/core'; import type { Client, Contexts, Event, EventHint, Integration, IntegrationClass, IntegrationFn } from '@sentry/types'; import { dynamicRequire, logger } from '@sentry/utils'; import type { Worker, WorkerOptions } from 'worker_threads'; @@ -52,7 +52,7 @@ interface InspectorApi { const INTEGRATION_NAME = 'Anr'; -const anrIntegration = ((options: Partial = {}) => { +const _anrIntegration = ((options: Partial = {}) => { return { name: INTEGRATION_NAME, // TODO v8: Remove this @@ -68,10 +68,14 @@ const anrIntegration = ((options: Partial = {}) => { }; }) satisfies IntegrationFn; +export const anrIntegration = defineIntegration(_anrIntegration); + /** * Starts a thread to detect App Not Responding (ANR) events * * ANR detection requires Node 16.17.0 or later + * + * @deprecated Use `anrIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const Anr = convertIntegrationFnToClass(INTEGRATION_NAME, anrIntegration) as IntegrationClass< @@ -80,6 +84,9 @@ export const Anr = convertIntegrationFnToClass(INTEGRATION_NAME, anrIntegration) new (options?: Partial): Integration & { setup(client: Client): void }; }; +// eslint-disable-next-line deprecation/deprecation +export type Anr = typeof Anr; + /** * Starts the ANR worker thread */ diff --git a/packages/node/src/integrations/anr/legacy.ts b/packages/node/src/integrations/anr/legacy.ts index 1d1ebc3024e3..d8b4ff1bc6dc 100644 --- a/packages/node/src/integrations/anr/legacy.ts +++ b/packages/node/src/integrations/anr/legacy.ts @@ -26,6 +26,7 @@ interface LegacyOptions { */ export function enableAnrDetection(options: Partial): Promise { const client = getClient() as NodeClient; + // eslint-disable-next-line deprecation/deprecation const integration = new Anr(options); integration.setup(client); return Promise.resolve(); diff --git a/packages/node/src/integrations/console.ts b/packages/node/src/integrations/console.ts index 1d4e0182e59a..aacf3447ea2a 100644 --- a/packages/node/src/integrations/console.ts +++ b/packages/node/src/integrations/console.ts @@ -1,11 +1,11 @@ import * as util from 'util'; -import { addBreadcrumb, convertIntegrationFnToClass, getClient } from '@sentry/core'; +import { addBreadcrumb, convertIntegrationFnToClass, defineIntegration, getClient } from '@sentry/core'; import type { Client, Integration, IntegrationClass, IntegrationFn } from '@sentry/types'; import { addConsoleInstrumentationHandler, severityLevelFromString } from '@sentry/utils'; const INTEGRATION_NAME = 'Console'; -const consoleIntegration = (() => { +const _consoleIntegration = (() => { return { name: INTEGRATION_NAME, // TODO v8: Remove this @@ -32,8 +32,16 @@ const consoleIntegration = (() => { }; }) satisfies IntegrationFn; -/** Console module integration */ +export const consoleIntegration = defineIntegration(_consoleIntegration); + +/** + * Console module integration. + * @deprecated Use `consoleIntegration()` instead. + */ // eslint-disable-next-line deprecation/deprecation export const Console = convertIntegrationFnToClass(INTEGRATION_NAME, consoleIntegration) as IntegrationClass< Integration & { setup: (client: Client) => void } >; + +// eslint-disable-next-line deprecation/deprecation +export type Console = typeof Console; diff --git a/packages/node/src/integrations/context.ts b/packages/node/src/integrations/context.ts index 058ce40b4c11..db712f4ea95d 100644 --- a/packages/node/src/integrations/context.ts +++ b/packages/node/src/integrations/context.ts @@ -4,7 +4,7 @@ import { readFile, readdir } from 'fs'; import * as os from 'os'; import { join } from 'path'; import { promisify } from 'util'; -import { convertIntegrationFnToClass } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration } from '@sentry/core'; import type { AppContext, CloudResourceContext, @@ -37,7 +37,7 @@ interface ContextOptions { cloudResource?: boolean; } -const nodeContextIntegration = ((options: ContextOptions = {}) => { +const _nodeContextIntegration = ((options: ContextOptions = {}) => { let cachedContext: Promise | undefined; const _options = { @@ -110,7 +110,12 @@ const nodeContextIntegration = ((options: ContextOptions = {}) => { }; }) satisfies IntegrationFn; -/** Add node modules / packages to the event */ +export const nodeContextIntegration = defineIntegration(_nodeContextIntegration); + +/** + * Add node modules / packages to the event. + * @deprecated Use `nodeContextIntegration()` instead. + */ // eslint-disable-next-line deprecation/deprecation export const Context = convertIntegrationFnToClass(INTEGRATION_NAME, nodeContextIntegration) as IntegrationClass< Integration & { processEvent: (event: Event) => Promise } @@ -124,6 +129,9 @@ export const Context = convertIntegrationFnToClass(INTEGRATION_NAME, nodeContext }): Integration; }; +// eslint-disable-next-line deprecation/deprecation +export type Context = typeof Context; + /** * Updates the context with dynamic values that can change */ diff --git a/packages/node/src/integrations/contextlines.ts b/packages/node/src/integrations/contextlines.ts index eccc80f7527a..5e98c7cbb813 100644 --- a/packages/node/src/integrations/contextlines.ts +++ b/packages/node/src/integrations/contextlines.ts @@ -1,5 +1,5 @@ import { readFile } from 'fs'; -import { convertIntegrationFnToClass } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration } from '@sentry/core'; import type { Event, Integration, IntegrationClass, IntegrationFn, StackFrame } from '@sentry/types'; import { LRUMap, addContextToFrame } from '@sentry/utils'; @@ -35,7 +35,7 @@ interface ContextLinesOptions { frameContextLines?: number; } -const contextLinesIntegration = ((options: ContextLinesOptions = {}) => { +const _contextLinesIntegration = ((options: ContextLinesOptions = {}) => { const contextLines = options.frameContextLines !== undefined ? options.frameContextLines : DEFAULT_LINES_OF_CONTEXT; return { @@ -48,7 +48,12 @@ const contextLinesIntegration = ((options: ContextLinesOptions = {}) => { }; }) satisfies IntegrationFn; -/** Add node modules / packages to the event */ +export const contextLinesIntegration = defineIntegration(_contextLinesIntegration); + +/** + * Add node modules / packages to the event. + * @deprecated Use `contextLinesIntegration()` instead. + */ // eslint-disable-next-line deprecation/deprecation export const ContextLines = convertIntegrationFnToClass(INTEGRATION_NAME, contextLinesIntegration) as IntegrationClass< Integration & { processEvent: (event: Event) => Promise } @@ -119,6 +124,9 @@ function addSourceContextToFrames(frames: StackFrame[], contextLines: number): v } } +// eslint-disable-next-line deprecation/deprecation +export type ContextLines = typeof ContextLines; + /** * Reads file contents and caches them in a global LRU cache. * If reading fails, mark the file as null in the cache so we don't try again. diff --git a/packages/node/src/integrations/hapi/index.ts b/packages/node/src/integrations/hapi/index.ts index c80265926ce4..82f8737721a9 100644 --- a/packages/node/src/integrations/hapi/index.ts +++ b/packages/node/src/integrations/hapi/index.ts @@ -3,6 +3,7 @@ import { captureException, continueTrace, convertIntegrationFnToClass, + defineIntegration, getActiveTransaction, getCurrentScope, getDynamicSamplingContextFromSpan, @@ -139,7 +140,7 @@ export type HapiOptions = { const INTEGRATION_NAME = 'Hapi'; -const hapiIntegration = ((options: HapiOptions = {}) => { +const _hapiIntegration = ((options: HapiOptions = {}) => { const server = options.server as undefined | Server; return { @@ -161,8 +162,14 @@ const hapiIntegration = ((options: HapiOptions = {}) => { }; }) satisfies IntegrationFn; +export const hapiIntegration = defineIntegration(_hapiIntegration); + /** - * Hapi Framework Integration + * Hapi Framework Integration. + * @deprecated Use `hapiIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const Hapi = convertIntegrationFnToClass(INTEGRATION_NAME, hapiIntegration); + +// eslint-disable-next-line deprecation/deprecation +export type Hapi = typeof Hapi; diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index de013541257e..f572fcc160f2 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,7 +1,8 @@ +/* eslint-disable max-lines */ import type * as http from 'http'; import type * as https from 'https'; import type { Hub } from '@sentry/core'; -import { getIsolationScope } from '@sentry/core'; +import { defineIntegration, getIsolationScope, hasTracingEnabled } from '@sentry/core'; import { addBreadcrumb, getActiveSpan, @@ -15,9 +16,18 @@ import { spanToJSON, spanToTraceHeader, } from '@sentry/core'; -import type { EventProcessor, Integration, SanitizedRequestData, TracePropagationTargets } from '@sentry/types'; +import type { + ClientOptions, + EventProcessor, + Integration, + IntegrationFn, + IntegrationFnResult, + SanitizedRequestData, + TracePropagationTargets, +} from '@sentry/types'; import { LRUMap, + dropUndefinedKeys, dynamicSamplingContextToSentryBaggageHeader, fill, generateSentryTraceHeader, @@ -28,6 +38,7 @@ import { import type { NodeClient } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import { NODE_VERSION } from '../nodeVersion'; +import type { NodeClientOptions } from '../types'; import type { RequestMethod, RequestMethodArgs, RequestOptions } from './utils/http'; import { cleanSpanDescription, extractRawUrl, extractUrl, normalizeRequestArgs } from './utils/http'; @@ -56,6 +67,12 @@ interface TracingOptions { * By default, spans will be created for all outgoing requests. */ shouldCreateSpanForRequest?: (url: string) => boolean; + + /** + * This option is just for compatibility with v7. + * In v8, this will be the default behavior. + */ + enableIfHasTracingEnabled?: boolean; } interface HttpOptions { @@ -72,9 +89,59 @@ interface HttpOptions { tracing?: TracingOptions | boolean; } +/* These are the newer options for `httpIntegration`. */ +interface HttpIntegrationOptions { + /** + * Whether breadcrumbs should be recorded for requests + * Defaults to true. + */ + breadcrumbs?: boolean; + + /** + * Whether tracing spans should be created for requests + * If not set, this will be enabled/disabled based on if tracing is enabled. + */ + tracing?: boolean; + + /** + * Function determining whether or not to create spans to track outgoing requests to the given URL. + * By default, spans will be created for all outgoing requests. + */ + shouldCreateSpanForRequest?: (url: string) => boolean; +} + +const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { + const { breadcrumbs, tracing, shouldCreateSpanForRequest } = options; + + const convertedOptions: HttpOptions = { + breadcrumbs, + tracing: + tracing === false + ? false + : dropUndefinedKeys({ + // If tracing is forced to `true`, we don't want to set `enableIfHasTracingEnabled` + enableIfHasTracingEnabled: tracing === true ? undefined : true, + shouldCreateSpanForRequest, + }), + }; + + // eslint-disable-next-line deprecation/deprecation + return new Http(convertedOptions) as unknown as IntegrationFnResult; +}) satisfies IntegrationFn; + +/** + * The http module integration instruments Node's internal http module. It creates breadcrumbs, spans for outgoing + * http requests, and attaches trace data when tracing is enabled via its `tracing` option. + * + * By default, this will always create breadcrumbs, and will create spans if tracing is enabled. + */ +export const httpIntegration = defineIntegration(_httpIntegration); + /** * The http module integration instruments Node's internal http module. It creates breadcrumbs, transactions for outgoing * http requests and attaches trace data when tracing is enabled via its `tracing` option. + * + * @deprecated Use `httpIntegration()` instead. */ export class Http implements Integration { /** @@ -85,6 +152,7 @@ export class Http implements Integration { /** * @inheritDoc */ + // eslint-disable-next-line deprecation/deprecation public name: string = Http.id; private readonly _breadcrumbs: boolean; @@ -105,23 +173,26 @@ export class Http implements Integration { _addGlobalEventProcessor: (callback: EventProcessor) => void, setupOnceGetCurrentHub: () => Hub, ): void { + // eslint-disable-next-line deprecation/deprecation + const clientOptions = setupOnceGetCurrentHub().getClient()?.getOptions(); + + // If `tracing` is not explicitly set, we default this based on whether or not tracing is enabled. + // But for compatibility, we only do that if `enableIfHasTracingEnabled` is set. + const shouldCreateSpans = _shouldCreateSpans(this._tracing, clientOptions); + // No need to instrument if we don't want to track anything - if (!this._breadcrumbs && !this._tracing) { + if (!this._breadcrumbs && !shouldCreateSpans) { return; } - // eslint-disable-next-line deprecation/deprecation - const clientOptions = setupOnceGetCurrentHub().getClient()?.getOptions(); - // Do not auto-instrument for other instrumenter if (clientOptions && clientOptions.instrumenter !== 'sentry') { DEBUG_BUILD && logger.log('HTTP Integration is skipped because of instrumenter configuration.'); return; } - const shouldCreateSpanForRequest = - // eslint-disable-next-line deprecation/deprecation - this._tracing?.shouldCreateSpanForRequest || clientOptions?.shouldCreateSpanForRequest; + const shouldCreateSpanForRequest = _getShouldCreateSpanForRequest(shouldCreateSpans, this._tracing, clientOptions); + // eslint-disable-next-line deprecation/deprecation const tracePropagationTargets = clientOptions?.tracePropagationTargets || this._tracing?.tracePropagationTargets; @@ -389,3 +460,29 @@ function normalizeBaggageHeader( // we say this is undefined behaviour, since it would not be baggage spec conform if the user did this. return [requestOptions.headers.baggage, sentryBaggageHeader] as string[]; } + +/** Exported for tests only. */ +export function _shouldCreateSpans( + tracingOptions: TracingOptions | undefined, + clientOptions: Partial | undefined, +): boolean { + return tracingOptions === undefined + ? false + : tracingOptions.enableIfHasTracingEnabled + ? hasTracingEnabled(clientOptions) + : true; +} + +/** Exported for tests only. */ +export function _getShouldCreateSpanForRequest( + shouldCreateSpans: boolean, + tracingOptions: TracingOptions | undefined, + clientOptions: Partial | undefined, +): undefined | ((url: string) => boolean) { + const handler = shouldCreateSpans + ? // eslint-disable-next-line deprecation/deprecation + tracingOptions?.shouldCreateSpanForRequest || clientOptions?.shouldCreateSpanForRequest + : () => false; + + return handler; +} diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 3e1a60f6951f..12f57116c533 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable deprecation/deprecation */ export { Console } from './console'; export { Http } from './http'; export { OnUncaughtException } from './onuncaughtexception'; @@ -5,7 +6,6 @@ export { OnUnhandledRejection } from './onunhandledrejection'; export { Modules } from './modules'; export { ContextLines } from './contextlines'; export { Context } from './context'; -// eslint-disable-next-line deprecation/deprecation export { RequestData } from '@sentry/core'; export { LocalVariables } from './local-variables'; export { Undici } from './undici'; diff --git a/packages/node/src/integrations/local-variables/index.ts b/packages/node/src/integrations/local-variables/index.ts index 708b4b41ea24..79cf3b0a7d67 100644 --- a/packages/node/src/integrations/local-variables/index.ts +++ b/packages/node/src/integrations/local-variables/index.ts @@ -1,6 +1,13 @@ -import { LocalVariablesSync } from './local-variables-sync'; +import { LocalVariablesSync, localVariablesSyncIntegration } from './local-variables-sync'; /** - * Adds local variables to exception frames + * Adds local variables to exception frames. + * + * @deprecated Use `localVariablesIntegration()` instead. */ +// eslint-disable-next-line deprecation/deprecation export const LocalVariables = LocalVariablesSync; +// eslint-disable-next-line deprecation/deprecation +export type LocalVariables = LocalVariablesSync; + +export const localVariablesIntegration = localVariablesSyncIntegration; diff --git a/packages/node/src/integrations/local-variables/local-variables-async.ts b/packages/node/src/integrations/local-variables/local-variables-async.ts index 76df88760bc9..b5f015b2b7db 100644 --- a/packages/node/src/integrations/local-variables/local-variables-async.ts +++ b/packages/node/src/integrations/local-variables/local-variables-async.ts @@ -1,5 +1,5 @@ import type { Session } from 'node:inspector/promises'; -import { convertIntegrationFnToClass } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration } from '@sentry/core'; import type { Event, Exception, Integration, IntegrationClass, IntegrationFn, StackParser } from '@sentry/types'; import { LRUMap, dynamicRequire, logger } from '@sentry/utils'; import type { Debugger, InspectorNotification, Runtime } from 'inspector'; @@ -76,7 +76,7 @@ const INTEGRATION_NAME = 'LocalVariablesAsync'; /** * Adds local variables to exception frames */ -const localVariablesAsyncIntegration = ((options: LocalVariablesIntegrationOptions = {}) => { +const _localVariablesAsyncIntegration = ((options: LocalVariablesIntegrationOptions = {}) => { const cachedFrames: LRUMap = new LRUMap(20); let rateLimiter: RateLimitIncrement | undefined; let shouldProcessEvent = false; @@ -253,11 +253,17 @@ const localVariablesAsyncIntegration = ((options: LocalVariablesIntegrationOptio }; }) satisfies IntegrationFn; +export const localVariablesAsyncIntegration = defineIntegration(_localVariablesAsyncIntegration); + /** - * Adds local variables to exception frames + * Adds local variables to exception frames. + * @deprecated Use `localVariablesAsyncIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const LocalVariablesAsync = convertIntegrationFnToClass( INTEGRATION_NAME, localVariablesAsyncIntegration, ) as IntegrationClass Event; setup: (client: NodeClient) => void }>; + +// eslint-disable-next-line deprecation/deprecation +export type LocalVariablesAsync = typeof LocalVariablesAsync; diff --git a/packages/node/src/integrations/local-variables/local-variables-sync.ts b/packages/node/src/integrations/local-variables/local-variables-sync.ts index f8d803a76c71..72f1add2748b 100644 --- a/packages/node/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node/src/integrations/local-variables/local-variables-sync.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import { convertIntegrationFnToClass, getClient } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration, getClient } from '@sentry/core'; import type { Event, Exception, Integration, IntegrationClass, IntegrationFn, StackParser } from '@sentry/types'; import { LRUMap, logger } from '@sentry/utils'; import type { Debugger, InspectorNotification, Runtime, Session } from 'inspector'; @@ -219,7 +219,7 @@ const INTEGRATION_NAME = 'LocalVariables'; /** * Adds local variables to exception frames */ -const localVariablesSyncIntegration = (( +const _localVariablesSyncIntegration = (( options: LocalVariablesIntegrationOptions = {}, session: DebugSession | undefined = tryNewAsyncSession(), ) => { @@ -392,8 +392,11 @@ const localVariablesSyncIntegration = (( }; }) satisfies IntegrationFn; +export const localVariablesSyncIntegration = defineIntegration(_localVariablesSyncIntegration); + /** - * Adds local variables to exception frames + * Adds local variables to exception frames. + * @deprecated Use `localVariablesSyncIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const LocalVariablesSync = convertIntegrationFnToClass( @@ -402,3 +405,6 @@ export const LocalVariablesSync = convertIntegrationFnToClass( ) as IntegrationClass Event; setup: (client: NodeClient) => void }> & { new (options?: LocalVariablesIntegrationOptions, session?: DebugSession): Integration; }; + +// eslint-disable-next-line deprecation/deprecation +export type LocalVariablesSync = typeof LocalVariablesSync; diff --git a/packages/node/src/integrations/modules.ts b/packages/node/src/integrations/modules.ts index e21a92e770d7..008376670724 100644 --- a/packages/node/src/integrations/modules.ts +++ b/packages/node/src/integrations/modules.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'fs'; import { dirname, join } from 'path'; -import { convertIntegrationFnToClass } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration } from '@sentry/core'; import type { Event, Integration, IntegrationClass, IntegrationFn } from '@sentry/types'; let moduleCache: { [key: string]: string }; @@ -76,7 +76,7 @@ function _getModules(): { [key: string]: string } { return moduleCache; } -const modulesIntegration = (() => { +const _modulesIntegration = (() => { return { name: INTEGRATION_NAME, // TODO v8: Remove this @@ -92,8 +92,16 @@ const modulesIntegration = (() => { }; }) satisfies IntegrationFn; -/** Add node modules / packages to the event */ +export const modulesIntegration = defineIntegration(_modulesIntegration); + +/** + * Add node modules / packages to the event. + * @deprecated Use `modulesIntegration()` instead. + */ // eslint-disable-next-line deprecation/deprecation export const Modules = convertIntegrationFnToClass(INTEGRATION_NAME, modulesIntegration) as IntegrationClass< Integration & { processEvent: (event: Event) => Event } >; + +// eslint-disable-next-line deprecation/deprecation +export type Modules = typeof Modules; diff --git a/packages/node/src/integrations/onuncaughtexception.ts b/packages/node/src/integrations/onuncaughtexception.ts index a3346f6153d5..0eb79833ddbf 100644 --- a/packages/node/src/integrations/onuncaughtexception.ts +++ b/packages/node/src/integrations/onuncaughtexception.ts @@ -1,4 +1,4 @@ -import { captureException, convertIntegrationFnToClass } from '@sentry/core'; +import { captureException, convertIntegrationFnToClass, defineIntegration } from '@sentry/core'; import { getClient } from '@sentry/core'; import type { Integration, IntegrationClass, IntegrationFn } from '@sentry/types'; import { logger } from '@sentry/utils'; @@ -40,7 +40,7 @@ interface OnUncaughtExceptionOptions { const INTEGRATION_NAME = 'OnUncaughtException'; -const onUncaughtExceptionIntegration = ((options: Partial = {}) => { +const _onUncaughtExceptionIntegration = ((options: Partial = {}) => { const _options = { exitEvenIfOtherHandlersAreRegistered: true, ...options, @@ -56,7 +56,12 @@ const onUncaughtExceptionIntegration = ((options: Partial void); /** Exported only for tests */ diff --git a/packages/node/src/integrations/onunhandledrejection.ts b/packages/node/src/integrations/onunhandledrejection.ts index 45fa5a61ca57..0c44c0e983c0 100644 --- a/packages/node/src/integrations/onunhandledrejection.ts +++ b/packages/node/src/integrations/onunhandledrejection.ts @@ -1,4 +1,4 @@ -import { captureException, convertIntegrationFnToClass, getClient } from '@sentry/core'; +import { captureException, convertIntegrationFnToClass, defineIntegration, getClient } from '@sentry/core'; import type { Client, Integration, IntegrationClass, IntegrationFn } from '@sentry/types'; import { consoleSandbox } from '@sentry/utils'; @@ -16,7 +16,7 @@ interface OnUnhandledRejectionOptions { const INTEGRATION_NAME = 'OnUnhandledRejection'; -const onUnhandledRejectionIntegration = ((options: Partial = {}) => { +const _onUnhandledRejectionIntegration = ((options: Partial = {}) => { const mode = options.mode || 'warn'; return { @@ -29,7 +29,12 @@ const onUnhandledRejectionIntegration = ((options: Partial): Integration; }; +// eslint-disable-next-line deprecation/deprecation +export type OnUnhandledRejection = typeof OnUnhandledRejection; + /** * Send an exception with reason * @param reason string diff --git a/packages/node/src/integrations/spotlight.ts b/packages/node/src/integrations/spotlight.ts index ab27f860c97b..52b8941daa71 100644 --- a/packages/node/src/integrations/spotlight.ts +++ b/packages/node/src/integrations/spotlight.ts @@ -1,6 +1,6 @@ import * as http from 'http'; import { URL } from 'url'; -import { convertIntegrationFnToClass } from '@sentry/core'; +import { convertIntegrationFnToClass, defineIntegration } from '@sentry/core'; import type { Client, Envelope, Integration, IntegrationClass, IntegrationFn } from '@sentry/types'; import { logger, serializeEnvelope } from '@sentry/utils'; @@ -14,7 +14,7 @@ type SpotlightConnectionOptions = { const INTEGRATION_NAME = 'Spotlight'; -const spotlightIntegration = ((options: Partial = {}) => { +const _spotlightIntegration = ((options: Partial = {}) => { const _options = { sidecarUrl: options.sidecarUrl || 'http://localhost:8969/stream', }; @@ -32,12 +32,16 @@ const spotlightIntegration = ((options: Partial = {} }; }) satisfies IntegrationFn; +export const spotlightIntegration = defineIntegration(_spotlightIntegration); + /** * Use this integration to send errors and transactions to Spotlight. * * Learn more about spotlight at https://spotlightjs.com * - * Important: This integration only works with Node 18 or newer + * Important: This integration only works with Node 18 or newer. + * + * @deprecated Use `spotlightIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const Spotlight = convertIntegrationFnToClass(INTEGRATION_NAME, spotlightIntegration) as IntegrationClass< @@ -50,6 +54,9 @@ export const Spotlight = convertIntegrationFnToClass(INTEGRATION_NAME, spotlight ): Integration; }; +// eslint-disable-next-line deprecation/deprecation +export type Spotlight = typeof Spotlight; + function connectToSpotlight(client: Client, options: Required): void { const spotlightUrl = parseSidecarUrl(options.sidecarUrl); if (!spotlightUrl) { diff --git a/packages/node/src/integrations/undici/index.ts b/packages/node/src/integrations/undici/index.ts index 31178d95aaa8..a2616deb920b 100644 --- a/packages/node/src/integrations/undici/index.ts +++ b/packages/node/src/integrations/undici/index.ts @@ -1,19 +1,20 @@ import { addBreadcrumb, + defineIntegration, getActiveSpan, getClient, getCurrentScope, getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan, getIsolationScope, + hasTracingEnabled, isSentryRequestUrl, setHttpStatus, spanToTraceHeader, } from '@sentry/core'; -import type { EventProcessor, Integration, Span } from '@sentry/types'; +import type { EventProcessor, Integration, IntegrationFn, IntegrationFnResult, Span } from '@sentry/types'; import { LRUMap, - dynamicRequire, dynamicSamplingContextToSentryBaggageHeader, generateSentryTraceHeader, getSanitizedUrlString, @@ -44,6 +45,13 @@ export interface UndiciOptions { * Defaults to true */ breadcrumbs: boolean; + + /** + * Whether tracing spans should be created for requests + * If not set, this will be enabled/disabled based on if tracing is enabled. + */ + tracing?: boolean; + /** * Function determining whether or not to create spans to track outgoing requests to the given URL. * By default, spans will be created for all outgoing requests. @@ -64,6 +72,13 @@ export interface UndiciOptions { // writeFileSync('log.out', `${format(...args)}\n`, { flag: 'a' }); // } +const _nativeNodeFetchintegration = ((options?: Partial) => { + // eslint-disable-next-line deprecation/deprecation + return new Undici(options) as unknown as IntegrationFnResult; +}) satisfies IntegrationFn; + +export const nativeNodeFetchintegration = defineIntegration(_nativeNodeFetchintegration); + /** * Instruments outgoing HTTP requests made with the `undici` package via * Node's `diagnostics_channel` API. @@ -71,6 +86,8 @@ export interface UndiciOptions { * Supports Undici 4.7.0 or higher. * * Requires Node 16.17.0 or higher. + * + * @deprecated Use `nativeNodeFetchintegration()` instead. */ export class Undici implements Integration { /** @@ -81,6 +98,7 @@ export class Undici implements Integration { /** * @inheritDoc */ + // eslint-disable-next-line deprecation/deprecation public name: string = Undici.id; private readonly _options: UndiciOptions; @@ -91,6 +109,7 @@ export class Undici implements Integration { public constructor(_options: Partial = {}) { this._options = { breadcrumbs: _options.breadcrumbs === undefined ? true : _options.breadcrumbs, + tracing: _options.tracing, shouldCreateSpanForRequest: _options.shouldCreateSpanForRequest, }; } @@ -107,7 +126,7 @@ export class Undici implements Integration { let ds: DiagnosticsChannel | undefined; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - ds = dynamicRequire(module, 'diagnostics_channel') as DiagnosticsChannel; + ds = require('diagnostics_channel') as DiagnosticsChannel; } catch (e) { // no-op } @@ -124,6 +143,10 @@ export class Undici implements Integration { /** Helper that wraps shouldCreateSpanForRequest option */ private _shouldCreateSpan(url: string): boolean { + if (this._options.tracing === false || (this._options.tracing === undefined && !hasTracingEnabled())) { + return false; + } + if (this._options.shouldCreateSpanForRequest === undefined) { return true; } diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index 9ef08c88aeb9..01825a404e20 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -1,15 +1,16 @@ /* eslint-disable max-lines */ import { - FunctionToString, - InboundFilters, - LinkedErrors, endSession, + functionToStringIntegration, getClient, getCurrentScope, getIntegrationsToSetup, getIsolationScope, getMainCarrier, + inboundFiltersIntegration, initAndBind, + linkedErrorsIntegration, + requestDataIntegration, startSession, } from '@sentry/core'; import type { Integration, Options, SessionStatus, StackParser } from '@sentry/types'; @@ -17,52 +18,45 @@ import { GLOBAL_OBJ, createStackParser, nodeStackLineParser, + propagationContextFromHeaders, stackParserFromStackParserOptions, - tracingContextFromHeaders, } from '@sentry/utils'; import { setNodeAsyncContextStrategy } from './async'; import { NodeClient } from './client'; -import { - Console, - Context, - ContextLines, - Http, - LocalVariables, - Modules, - OnUncaughtException, - OnUnhandledRejection, - RequestData, - Spotlight, - Undici, -} from './integrations'; +import { consoleIntegration } from './integrations/console'; +import { nodeContextIntegration } from './integrations/context'; +import { contextLinesIntegration } from './integrations/contextlines'; +import { httpIntegration } from './integrations/http'; +import { localVariablesIntegration } from './integrations/local-variables'; +import { modulesIntegration } from './integrations/modules'; +import { onUncaughtExceptionIntegration } from './integrations/onuncaughtexception'; +import { onUnhandledRejectionIntegration } from './integrations/onunhandledrejection'; +import { spotlightIntegration } from './integrations/spotlight'; +import { nativeNodeFetchintegration } from './integrations/undici'; import { createGetModuleFromFilename } from './module'; import { makeNodeTransport } from './transports'; import type { NodeClientOptions, NodeOptions } from './types'; /** @deprecated Use `getDefaultIntegrations(options)` instead. */ - export const defaultIntegrations = [ - /* eslint-disable deprecation/deprecation */ // Common - new InboundFilters(), - new FunctionToString(), - new LinkedErrors(), - /* eslint-enable deprecation/deprecation */ + inboundFiltersIntegration(), + functionToStringIntegration(), + linkedErrorsIntegration(), + requestDataIntegration(), // Native Wrappers - new Console(), - new Http(), - new Undici(), + consoleIntegration(), + httpIntegration(), + nativeNodeFetchintegration(), // Global Handlers - new OnUncaughtException(), - new OnUnhandledRejection(), + onUncaughtExceptionIntegration(), + onUnhandledRejectionIntegration(), // Event Info - new ContextLines(), - new LocalVariables(), - new Context(), - new Modules(), - // eslint-disable-next-line deprecation/deprecation - new RequestData(), + contextLinesIntegration(), + localVariablesIntegration(), + nodeContextIntegration(), + modulesIntegration(), ]; /** Get the default integrations for the Node SDK. */ @@ -201,7 +195,7 @@ export function init(options: NodeOptions = {}): void { client.addIntegration(integration); } client.addIntegration( - new Spotlight({ sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined }), + spotlightIntegration({ sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined }), ); } } @@ -291,7 +285,7 @@ function updateScopeFromEnvVariables(): void { if (!['false', 'n', 'no', 'off', '0'].includes(sentryUseEnvironment)) { const sentryTraceEnv = process.env.SENTRY_TRACE; const baggageEnv = process.env.SENTRY_BAGGAGE; - const { propagationContext } = tracingContextFromHeaders(sentryTraceEnv, baggageEnv); + const propagationContext = propagationContextFromHeaders(sentryTraceEnv, baggageEnv); getCurrentScope().setPropagationContext(propagationContext); } } diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 2312cf8ce981..b0b0000839f2 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -10,6 +10,7 @@ import type { EventHint, Integration } from '@sentry/types'; import { GLOBAL_OBJ } from '@sentry/utils'; import type { Event } from '../src'; +import { contextLinesIntegration } from '../src'; import { NodeClient, addBreadcrumb, @@ -193,7 +194,7 @@ describe('SentryNode', () => { stackParser: defaultStackParser, beforeSend, dsn, - integrations: [new ContextLines()], + integrations: [contextLinesIntegration()], }); const client = new NodeClient(options); setCurrentClient(client); diff --git a/packages/node/test/integrations/contextlines.test.ts b/packages/node/test/integrations/contextlines.test.ts index dda78689e711..dccc6c113b5f 100644 --- a/packages/node/test/integrations/contextlines.test.ts +++ b/packages/node/test/integrations/contextlines.test.ts @@ -16,6 +16,7 @@ describe('ContextLines', () => { beforeEach(() => { readFileSpy = jest.spyOn(fs, 'readFile'); + // eslint-disable-next-line deprecation/deprecation contextLines = new ContextLines(); resetFileContentCache(); }); @@ -98,6 +99,7 @@ describe('ContextLines', () => { }); test('parseStack with no context', async () => { + // eslint-disable-next-line deprecation/deprecation contextLines = new ContextLines({ frameContextLines: 0 }); expect.assertions(1); @@ -110,7 +112,8 @@ describe('ContextLines', () => { test('does not attempt to readfile multiple times if it fails', async () => { expect.assertions(1); - contextLines = new ContextLines({}); + // eslint-disable-next-line deprecation/deprecation + contextLines = new ContextLines(); readFileSpy.mockImplementation(() => { throw new Error("ENOENT: no such file or directory, open '/does/not/exist.js'"); diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index a1f4d38b3da5..0b1d81edd29c 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -11,7 +11,12 @@ import { HttpsProxyAgent } from '../../src/proxy'; import type { Breadcrumb } from '../../src'; import { NodeClient } from '../../src/client'; -import { Http as HttpIntegration } from '../../src/integrations/http'; +import { + Http as HttpIntegration, + _getShouldCreateSpanForRequest, + _shouldCreateSpans, + httpIntegration, +} from '../../src/integrations/http'; import { NODE_VERSION } from '../../src/nodeVersion'; import type { NodeClientOptions } from '../../src/types'; import { getDefaultNodeClientOptions } from '../helper/node-client-options'; @@ -55,15 +60,18 @@ describe('tracing', () => { const options = getDefaultNodeClientOptions({ dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', tracesSampleRate: 1.0, + // eslint-disable-next-line deprecation/deprecation integrations: [new HttpIntegration({ tracing: true })], release: '1.0.0', environment: 'production', ...customOptions, }); const client = new NodeClient(options); - const hub = new Hub(client); + const hub = new Hub(); // eslint-disable-next-line deprecation/deprecation makeMain(hub); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); } it("creates a span for each outgoing non-sentry request when there's a transaction on the scope", () => { @@ -256,6 +264,7 @@ describe('tracing', () => { const options = getDefaultNodeClientOptions({ dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', tracesSampleRate: 1.0, + // eslint-disable-next-line deprecation/deprecation integrations: [new HttpIntegration({ tracing: true })], release: '1.0.0', environment: 'production', @@ -263,6 +272,7 @@ describe('tracing', () => { }); const hub = new Hub(new NodeClient(options)); + // eslint-disable-next-line deprecation/deprecation const integration = new HttpIntegration(); integration.setupOnce( () => {}, @@ -381,6 +391,7 @@ describe('tracing', () => { const url = 'http://dogs.are.great/api/v1/index/'; nock(url).get(/.*/).reply(200); + // eslint-disable-next-line deprecation/deprecation const httpIntegration = new HttpIntegration({ tracing: true }); const hub = createHub({ shouldCreateSpanForRequest: () => false }); @@ -428,6 +439,7 @@ describe('tracing', () => { (url, tracePropagationTargets) => { nock(url).get(/.*/).reply(200); + // eslint-disable-next-line deprecation/deprecation const httpIntegration = new HttpIntegration({ tracing: true }); const hub = createHub({ tracePropagationTargets }); @@ -460,6 +472,7 @@ describe('tracing', () => { (url, tracePropagationTargets) => { nock(url).get(/.*/).reply(200); + // eslint-disable-next-line deprecation/deprecation const httpIntegration = new HttpIntegration({ tracing: true }); const hub = createHub({ tracePropagationTargets }); @@ -484,6 +497,7 @@ describe('tracing', () => { const url = 'http://dogs.are.great/api/v1/index/'; nock(url).get(/.*/).reply(200); + // eslint-disable-next-line deprecation/deprecation const httpIntegration = new HttpIntegration({ tracing: { shouldCreateSpanForRequest: () => false, @@ -535,6 +549,7 @@ describe('tracing', () => { (url, tracePropagationTargets) => { nock(url).get(/.*/).reply(200); + // eslint-disable-next-line deprecation/deprecation const httpIntegration = new HttpIntegration({ tracing: { tracePropagationTargets } }); const hub = createHub(); @@ -567,6 +582,7 @@ describe('tracing', () => { (url, tracePropagationTargets) => { nock(url).get(/.*/).reply(200); + // eslint-disable-next-line deprecation/deprecation const httpIntegration = new HttpIntegration({ tracing: { tracePropagationTargets } }); const hub = createHub(); @@ -596,6 +612,7 @@ describe('default protocols', () => { }); const options = getDefaultNodeClientOptions({ dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', + // eslint-disable-next-line deprecation/deprecation integrations: [new HttpIntegration({ breadcrumbs: true })], beforeBreadcrumb: (b: Breadcrumb) => { if ((b.data?.url as string).includes(key)) { @@ -688,3 +705,132 @@ describe('default protocols', () => { expect(b.data?.url).toEqual(expect.stringContaining('https://')); }); }); + +describe('httpIntegration', () => { + beforeEach(function () { + const options = getDefaultNodeClientOptions({ + dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', + tracesSampleRate: 1.0, + release: '1.0.0', + environment: 'production', + }); + const client = new NodeClient(options); + const hub = new Hub(client); + // eslint-disable-next-line deprecation/deprecation + makeMain(hub); + }); + + it('converts default options', () => { + // eslint-disable-next-line deprecation/deprecation + const integration = httpIntegration({}) as unknown as HttpIntegration; + + expect(integration['_breadcrumbs']).toBe(true); + expect(integration['_tracing']).toEqual({ enableIfHasTracingEnabled: true }); + }); + + it('respects `tracing=false`', () => { + // eslint-disable-next-line deprecation/deprecation + const integration = httpIntegration({ tracing: false }) as unknown as HttpIntegration; + + expect(integration['_tracing']).toEqual(undefined); + }); + + it('respects `breadcrumbs=false`', () => { + // eslint-disable-next-line deprecation/deprecation + const integration = httpIntegration({ breadcrumbs: false }) as unknown as HttpIntegration; + + expect(integration['_breadcrumbs']).toBe(false); + }); + + it('respects `tracing=true`', () => { + // eslint-disable-next-line deprecation/deprecation + const integration = httpIntegration({ tracing: true }) as unknown as HttpIntegration; + + expect(integration['_tracing']).toEqual({}); + }); + + it('respects `shouldCreateSpanForRequest`', () => { + const shouldCreateSpanForRequest = jest.fn(); + + // eslint-disable-next-line deprecation/deprecation + const integration = httpIntegration({ shouldCreateSpanForRequest }) as unknown as HttpIntegration; + + expect(integration['_tracing']).toEqual({ + shouldCreateSpanForRequest, + enableIfHasTracingEnabled: true, + }); + }); + + it('respects `shouldCreateSpanForRequest` & `tracing=true`', () => { + const shouldCreateSpanForRequest = jest.fn(); + + // eslint-disable-next-line deprecation/deprecation + const integration = httpIntegration({ shouldCreateSpanForRequest, tracing: true }) as unknown as HttpIntegration; + + expect(integration['_tracing']).toEqual({ + shouldCreateSpanForRequest, + }); + }); +}); + +describe('_shouldCreateSpans', () => { + beforeEach(function () { + const hub = new Hub(); + // eslint-disable-next-line deprecation/deprecation + makeMain(hub); + }); + + it.each([ + [undefined, undefined, false], + [{}, undefined, true], + [{ enableIfHasTracingEnabled: true }, undefined, false], + [{ enableIfHasTracingEnabled: false }, undefined, true], + [{ enableIfHasTracingEnabled: true }, { tracesSampleRate: 1 }, true], + [{ enableIfHasTracingEnabled: true }, { tracesSampleRate: 0 }, true], + [{}, {}, true], + ])('works with tracing=%p and clientOptions=%p', (tracing, clientOptions, expected) => { + const actual = _shouldCreateSpans(tracing, clientOptions); + expect(actual).toEqual(expected); + }); +}); + +describe('_getShouldCreateSpanForRequest', () => { + beforeEach(function () { + const hub = new Hub(); + // eslint-disable-next-line deprecation/deprecation + makeMain(hub); + }); + + it.each([ + [false, undefined, undefined, { a: false, b: false }], + [true, undefined, undefined, undefined], + // with tracing callback only + [true, { shouldCreateSpanForRequest: (url: string) => url === 'a' }, undefined, { a: true, b: false }], + // with client callback only + [true, undefined, { shouldCreateSpanForRequest: (url: string) => url === 'a' }, { a: true, b: false }], + // with both callbacks, tracing takes precedence + [ + true, + { shouldCreateSpanForRequest: (url: string) => url === 'a' }, + { shouldCreateSpanForRequest: (url: string) => url === 'b' }, + { a: true, b: false }, + ], + // If `shouldCreateSpans===false`, the callback is ignored + [false, { shouldCreateSpanForRequest: (url: string) => url === 'a' }, undefined, { a: false, b: false }], + ])( + 'works with shouldCreateSpans=%p, tracing=%p and clientOptions=%p', + (shouldCreateSpans, tracing, clientOptions, expected) => { + const actual = _getShouldCreateSpanForRequest(shouldCreateSpans, tracing, clientOptions); + + if (typeof expected === 'object') { + expect(typeof actual).toBe('function'); + + for (const [url, shouldBe] of Object.entries(expected)) { + expect(actual!(url)).toEqual(shouldBe); + } + } else { + expect(actual).toEqual(expected); + } + }, + ); +}); diff --git a/packages/node/test/integrations/spotlight.test.ts b/packages/node/test/integrations/spotlight.test.ts index 266d64b5710a..756170d89518 100644 --- a/packages/node/test/integrations/spotlight.test.ts +++ b/packages/node/test/integrations/spotlight.test.ts @@ -2,7 +2,7 @@ import * as http from 'http'; import type { Envelope, EventEnvelope } from '@sentry/types'; import { createEnvelope, logger } from '@sentry/utils'; -import { NodeClient } from '../../src'; +import { NodeClient, spotlightIntegration } from '../../src'; import { Spotlight } from '../../src/integrations'; import { getDefaultNodeClientOptions } from '../helper/node-client-options'; @@ -18,8 +18,10 @@ describe('Spotlight', () => { const client = new NodeClient(options); it('has a name and id', () => { + // eslint-disable-next-line deprecation/deprecation const integration = new Spotlight(); expect(integration.name).toEqual('Spotlight'); + // eslint-disable-next-line deprecation/deprecation expect(Spotlight.id).toEqual('Spotlight'); }); @@ -28,7 +30,7 @@ describe('Spotlight', () => { ...client, on: jest.fn(), }; - const integration = new Spotlight(); + const integration = spotlightIntegration(); // @ts-expect-error - this is fine in tests integration.setup(clientWithSpy); expect(clientWithSpy.on).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function)); @@ -49,7 +51,7 @@ describe('Spotlight', () => { on: jest.fn().mockImplementationOnce((_, cb) => (callback = cb)), }; - const integration = new Spotlight(); + const integration = spotlightIntegration(); // @ts-expect-error - this is fine in tests integration.setup(clientWithSpy); @@ -88,7 +90,7 @@ describe('Spotlight', () => { on: jest.fn().mockImplementationOnce((_, cb) => (callback = cb)), }; - const integration = new Spotlight({ sidecarUrl: 'http://mylocalhost:8888/abcd' }); + const integration = spotlightIntegration({ sidecarUrl: 'http://mylocalhost:8888/abcd' }); // @ts-expect-error - this is fine in tests integration.setup(clientWithSpy); @@ -114,13 +116,13 @@ describe('Spotlight', () => { describe('no-ops if', () => { it('an invalid URL is passed', () => { - const integration = new Spotlight({ sidecarUrl: 'invalid-url' }); - integration.setup(client); + const integration = spotlightIntegration({ sidecarUrl: 'invalid-url' }); + integration.setup!(client); expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid sidecar URL: invalid-url')); }); it("the client doesn't support life cycle hooks", () => { - const integration = new Spotlight({ sidecarUrl: 'http://mylocalhost:8969' }); + const integration = spotlightIntegration({ sidecarUrl: 'http://mylocalhost:8969' }); const clientWithoutHooks = { ...client }; // @ts-expect-error - this is fine in tests delete client.on; @@ -134,8 +136,8 @@ describe('Spotlight', () => { const oldEnvValue = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; - const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); - integration.setup(client); + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); expect(loggerSpy).toHaveBeenCalledWith( expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), @@ -148,8 +150,8 @@ describe('Spotlight', () => { const oldEnvValue = process.env.NODE_ENV; process.env.NODE_ENV = 'development'; - const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); - integration.setup(client); + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); expect(loggerSpy).not.toHaveBeenCalledWith( expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), @@ -164,8 +166,8 @@ describe('Spotlight', () => { // @ts-expect-error - TS complains but we explicitly wanna test this delete global.process; - const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); - integration.setup(client); + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); expect(loggerSpy).not.toHaveBeenCalledWith( expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), @@ -180,8 +182,8 @@ describe('Spotlight', () => { // @ts-expect-error - TS complains but we explicitly wanna test this delete process.env; - const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); - integration.setup(client); + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); expect(loggerSpy).not.toHaveBeenCalledWith( expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), diff --git a/packages/node/test/integrations/undici.test.ts b/packages/node/test/integrations/undici.test.ts index abda44026fcb..f280b3d4018a 100644 --- a/packages/node/test/integrations/undici.test.ts +++ b/packages/node/test/integrations/undici.test.ts @@ -5,8 +5,8 @@ import { Hub, makeMain, runWithAsyncContext } from '@sentry/core'; import type { fetch as FetchType } from 'undici'; import { NodeClient } from '../../src/client'; -import type { UndiciOptions } from '../../src/integrations/undici'; -import { Undici } from '../../src/integrations/undici'; +import type { Undici, UndiciOptions } from '../../src/integrations/undici'; +import { nativeNodeFetchintegration } from '../../src/integrations/undici'; import { getDefaultNodeClientOptions } from '../helper/node-client-options'; import { conditionalTest } from '../utils'; @@ -30,7 +30,7 @@ beforeAll(async () => { const DEFAULT_OPTIONS = getDefaultNodeClientOptions({ dsn: SENTRY_DSN, tracesSampler: () => true, - integrations: [new Undici()], + integrations: [nativeNodeFetchintegration()], }); beforeEach(() => { @@ -377,6 +377,76 @@ conditionalTest({ min: 16 })('Undici integration', () => { undoPatch(); }); + + describe('nativeNodeFetchIntegration', () => { + beforeEach(function () { + const options = getDefaultNodeClientOptions({ + dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', + tracesSampleRate: 1.0, + release: '1.0.0', + environment: 'production', + }); + const client = new NodeClient(options); + const hub = new Hub(client); + // eslint-disable-next-line deprecation/deprecation + makeMain(hub); + }); + + it.each([ + [undefined, { a: true, b: true }], + [{}, { a: true, b: true }], + [{ tracing: true }, { a: true, b: true }], + [{ tracing: false }, { a: false, b: false }], + [ + { tracing: false, shouldCreateSpanForRequest: () => true }, + { a: false, b: false }, + ], + [ + { tracing: true, shouldCreateSpanForRequest: (url: string) => url === 'a' }, + { a: true, b: false }, + ], + ])('sets correct _shouldCreateSpan filter with options=%p', (options, expected) => { + // eslint-disable-next-line deprecation/deprecation + const actual = nativeNodeFetchintegration(options) as unknown as Undici; + + for (const [url, shouldBe] of Object.entries(expected)) { + expect(actual['_shouldCreateSpan'](url)).toEqual(shouldBe); + } + }); + + it('disables tracing spans if tracing is disabled in client', () => { + const client = new NodeClient( + getDefaultNodeClientOptions({ + dsn: SENTRY_DSN, + integrations: [nativeNodeFetchintegration()], + }), + ); + setCurrentClient(client); + + // eslint-disable-next-line deprecation/deprecation + const actual = nativeNodeFetchintegration() as unknown as Undici; + + expect(actual['_shouldCreateSpan']('a')).toEqual(false); + expect(actual['_shouldCreateSpan']('b')).toEqual(false); + }); + + it('enabled tracing spans if tracing is enabled in client', () => { + const client = new NodeClient( + getDefaultNodeClientOptions({ + dsn: SENTRY_DSN, + integrations: [nativeNodeFetchintegration()], + enableTracing: true, + }), + ); + setCurrentClient(client); + + // eslint-disable-next-line deprecation/deprecation + const actual = nativeNodeFetchintegration() as unknown as Undici; + + expect(actual['_shouldCreateSpan']('a')).toEqual(true); + expect(actual['_shouldCreateSpan']('b')).toEqual(true); + }); + }); }); interface TestServerOptions { diff --git a/packages/node/test/manual/webpack-async-context/npm-build.js b/packages/node/test/manual/webpack-async-context/npm-build.js index eac357b10f36..9d9c687981bb 100644 --- a/packages/node/test/manual/webpack-async-context/npm-build.js +++ b/packages/node/test/manual/webpack-async-context/npm-build.js @@ -7,6 +7,11 @@ if (Number(process.versions.node.split('.')[0]) >= 18) { process.exit(0); } +// Webpack test does not work in Node 8 and below. +if (Number(process.versions.node.split('.')[0]) <= 8) { + process.exit(0); +} + // biome-ignore format: Follow-up for prettier webpack( { diff --git a/packages/node/test/manual/webpack-async-context/package.json b/packages/node/test/manual/webpack-async-context/package.json index ff8f85afdafa..666406416c06 100644 --- a/packages/node/test/manual/webpack-async-context/package.json +++ b/packages/node/test/manual/webpack-async-context/package.json @@ -4,9 +4,9 @@ "main": "index.js", "license": "MIT", "dependencies": { - "webpack": "^4.42.1" + "webpack": "^5.90.0" }, - "devDependencies": { - "webpack-cli": "^3.3.11" + "volta": { + "extends": "../../../../../package.json" } } diff --git a/packages/node/test/manual/webpack-async-context/yarn.lock b/packages/node/test/manual/webpack-async-context/yarn.lock index 5c67ad308da3..5ae121f60447 100644 --- a/packages/node/test/manual/webpack-async-context/yarn.lock +++ b/packages/node/test/manual/webpack-async-context/yarn.lock @@ -2,149 +2,198 @@ # yarn lockfile v1 -"@webassemblyjs/ast@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" - integrity sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA== +"@jridgewell/gen-mapping@^0.3.0": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" + integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ== dependencies: - "@webassemblyjs/helper-module-context" "1.9.0" - "@webassemblyjs/helper-wasm-bytecode" "1.9.0" - "@webassemblyjs/wast-parser" "1.9.0" - -"@webassemblyjs/floating-point-hex-parser@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz#3c3d3b271bddfc84deb00f71344438311d52ffb4" - integrity sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA== + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" -"@webassemblyjs/helper-api-error@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz#203f676e333b96c9da2eeab3ccef33c45928b6a2" - integrity sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw== +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" + integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== -"@webassemblyjs/helper-buffer@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz#a1442d269c5feb23fcbc9ef759dac3547f29de00" - integrity sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA== +"@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== -"@webassemblyjs/helper-code-frame@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz#647f8892cd2043a82ac0c8c5e75c36f1d9159f27" - integrity sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA== +"@jridgewell/source-map@^0.3.3": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.5.tgz#a3bb4d5c6825aab0d281268f47f6ad5853431e91" + integrity sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ== dependencies: - "@webassemblyjs/wast-printer" "1.9.0" + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" -"@webassemblyjs/helper-fsm@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz#c05256b71244214671f4b08ec108ad63b70eddb8" - integrity sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw== +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== -"@webassemblyjs/helper-module-context@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz#25d8884b76839871a08a6c6f806c3979ef712f07" - integrity sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g== +"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.9": + version "0.3.22" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz#72a621e5de59f5f1ef792d0793a82ee20f645e4c" + integrity sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw== dependencies: - "@webassemblyjs/ast" "1.9.0" - -"@webassemblyjs/helper-wasm-bytecode@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz#4fed8beac9b8c14f8c58b70d124d549dd1fe5790" - integrity sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw== + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" -"@webassemblyjs/helper-wasm-section@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz#5a4138d5a6292ba18b04c5ae49717e4167965346" - integrity sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw== +"@types/eslint-scope@^3.7.3": + version "3.7.7" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" + integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/helper-buffer" "1.9.0" - "@webassemblyjs/helper-wasm-bytecode" "1.9.0" - "@webassemblyjs/wasm-gen" "1.9.0" + "@types/eslint" "*" + "@types/estree" "*" -"@webassemblyjs/ieee754@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz#15c7a0fbaae83fb26143bbacf6d6df1702ad39e4" - integrity sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg== +"@types/eslint@*": + version "8.56.2" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.2.tgz#1c72a9b794aa26a8b94ad26d5b9aa51c8a6384bb" + integrity sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw== dependencies: - "@xtuc/ieee754" "^1.2.0" + "@types/estree" "*" + "@types/json-schema" "*" -"@webassemblyjs/leb128@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.9.0.tgz#f19ca0b76a6dc55623a09cffa769e838fa1e1c95" - integrity sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw== - dependencies: +"@types/estree@*", "@types/estree@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +"@types/json-schema@*", "@types/json-schema@^7.0.8": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@*": + version "20.11.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.10.tgz#6c3de8974d65c362f82ee29db6b5adf4205462f9" + integrity sha512-rZEfe/hJSGYmdfX9tvcPMYeYPW2sNl50nsw4jZmRcaG0HIAb0WYEpsB05GOb53vjqpyE9GUhlDQ4jLSoB5q9kg== + dependencies: + undici-types "~5.26.4" + +"@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" + integrity sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q== + dependencies: + "@webassemblyjs/helper-numbers" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + +"@webassemblyjs/floating-point-hex-parser@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" + integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== + +"@webassemblyjs/helper-api-error@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" + integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== + +"@webassemblyjs/helper-buffer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz#b66d73c43e296fd5e88006f18524feb0f2c7c093" + integrity sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA== + +"@webassemblyjs/helper-numbers@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" + integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.9.0.tgz#04d33b636f78e6a6813227e82402f7637b6229ab" - integrity sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w== +"@webassemblyjs/helper-wasm-bytecode@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" + integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== -"@webassemblyjs/wasm-edit@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz#3fe6d79d3f0f922183aa86002c42dd256cfee9cf" - integrity sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw== +"@webassemblyjs/helper-wasm-section@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz#ff97f3863c55ee7f580fd5c41a381e9def4aa577" + integrity sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g== dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/helper-buffer" "1.9.0" - "@webassemblyjs/helper-wasm-bytecode" "1.9.0" - "@webassemblyjs/helper-wasm-section" "1.9.0" - "@webassemblyjs/wasm-gen" "1.9.0" - "@webassemblyjs/wasm-opt" "1.9.0" - "@webassemblyjs/wasm-parser" "1.9.0" - "@webassemblyjs/wast-printer" "1.9.0" - -"@webassemblyjs/wasm-gen@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz#50bc70ec68ded8e2763b01a1418bf43491a7a49c" - integrity sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA== - dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/helper-wasm-bytecode" "1.9.0" - "@webassemblyjs/ieee754" "1.9.0" - "@webassemblyjs/leb128" "1.9.0" - "@webassemblyjs/utf8" "1.9.0" + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" -"@webassemblyjs/wasm-opt@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz#2211181e5b31326443cc8112eb9f0b9028721a61" - integrity sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A== +"@webassemblyjs/ieee754@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" + integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/helper-buffer" "1.9.0" - "@webassemblyjs/wasm-gen" "1.9.0" - "@webassemblyjs/wasm-parser" "1.9.0" + "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/wasm-parser@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz#9d48e44826df4a6598294aa6c87469d642fff65e" - integrity sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA== - dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/helper-api-error" "1.9.0" - "@webassemblyjs/helper-wasm-bytecode" "1.9.0" - "@webassemblyjs/ieee754" "1.9.0" - "@webassemblyjs/leb128" "1.9.0" - "@webassemblyjs/utf8" "1.9.0" - -"@webassemblyjs/wast-parser@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz#3031115d79ac5bd261556cecc3fa90a3ef451914" - integrity sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw== +"@webassemblyjs/leb128@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" + integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/floating-point-hex-parser" "1.9.0" - "@webassemblyjs/helper-api-error" "1.9.0" - "@webassemblyjs/helper-code-frame" "1.9.0" - "@webassemblyjs/helper-fsm" "1.9.0" "@xtuc/long" "4.2.2" -"@webassemblyjs/wast-printer@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz#4935d54c85fef637b00ce9f52377451d00d47899" - integrity sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA== - dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/wast-parser" "1.9.0" +"@webassemblyjs/utf8@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" + integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== + +"@webassemblyjs/wasm-edit@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz#c72fa8220524c9b416249f3d94c2958dfe70ceab" + integrity sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-wasm-section" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-opt" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" + "@webassemblyjs/wast-printer" "1.11.6" + +"@webassemblyjs/wasm-gen@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz#fb5283e0e8b4551cc4e9c3c0d7184a65faf7c268" + integrity sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wasm-opt@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz#d9a22d651248422ca498b09aa3232a81041487c2" + integrity sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" + +"@webassemblyjs/wasm-parser@1.11.6", "@webassemblyjs/wasm-parser@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz#bb85378c527df824004812bbdb784eea539174a1" + integrity sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wast-printer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz#a7bf8dd7e362aeb1668ff43f35cb849f188eff20" + integrity sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A== + dependencies: + "@webassemblyjs/ast" "1.11.6" "@xtuc/long" "4.2.2" "@xtuc/ieee754@^1.2.0": @@ -157,22 +206,22 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -acorn@^6.2.1: - version "6.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" - integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== +acorn-import-assertions@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" + integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA== -ajv-errors@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" - integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== +acorn@^8.7.1, acorn@^8.8.2: + version "8.11.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" + integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== -ajv-keywords@^3.1.0, ajv-keywords@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" - integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== +ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.1.0, ajv@^6.10.2: +ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -182,343 +231,25 @@ ajv@^6.1.0, ajv@^6.10.2: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-regex@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" - integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== - -ansi-styles@^3.2.0, ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -anymatch@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" - integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== - dependencies: - micromatch "^3.1.4" - normalize-path "^2.1.1" - -aproba@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" - integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== - -arr-diff@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" - integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= - -arr-flatten@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" - integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== - -arr-union@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" - integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= - -array-unique@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" - integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= - -asn1.js@^4.0.0: - version "4.10.1" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" - integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== - dependencies: - bn.js "^4.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -asn1.js@^5.2.0: - version "5.4.1" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" - integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== - dependencies: - bn.js "^4.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - safer-buffer "^2.1.0" - -assert@^1.1.1: - version "1.5.0" - resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb" - integrity sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA== - dependencies: - object-assign "^4.1.1" - util "0.10.3" - -assign-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" - integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= - -async-each@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" - integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== - -atob@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" - integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== - -balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= - -base64-js@^1.0.2: - version "1.3.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== - -base@^0.11.1: - version "0.11.2" - resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" - integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== - dependencies: - cache-base "^1.0.1" - class-utils "^0.3.5" - component-emitter "^1.2.1" - define-property "^1.0.0" - isobject "^3.0.1" - mixin-deep "^1.2.0" - pascalcase "^0.1.1" - -big.js@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" - integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== - -binary-extensions@^1.0.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" - integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== - -bindings@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" - integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== - dependencies: - file-uri-to-path "1.0.0" - -bluebird@^3.5.5: - version "3.7.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: - version "4.12.0" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" - integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== - -bn.js@^5.0.0, bn.js@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" - integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^2.3.1, braces@^2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" - integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== - dependencies: - arr-flatten "^1.1.0" - array-unique "^0.3.2" - extend-shallow "^2.0.1" - fill-range "^4.0.0" - isobject "^3.0.1" - repeat-element "^1.1.2" - snapdragon "^0.8.1" - snapdragon-node "^2.0.1" - split-string "^3.0.2" - to-regex "^3.0.1" - -brorand@^1.0.1, brorand@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" - integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= - -browserify-aes@^1.0.0, browserify-aes@^1.0.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" - integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== - dependencies: - buffer-xor "^1.0.3" - cipher-base "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.3" - inherits "^2.0.1" - safe-buffer "^5.0.1" - -browserify-cipher@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" - integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== - dependencies: - browserify-aes "^1.0.4" - browserify-des "^1.0.0" - evp_bytestokey "^1.0.0" - -browserify-des@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" - integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== - dependencies: - cipher-base "^1.0.1" - des.js "^1.0.0" - inherits "^2.0.1" - safe-buffer "^5.1.2" - -browserify-rsa@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" - integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= - dependencies: - bn.js "^4.1.0" - randombytes "^2.0.1" - -browserify-rsa@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" - integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== - dependencies: - bn.js "^5.0.0" - randombytes "^2.0.1" - -browserify-sign@^4.0.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.2.tgz#e78d4b69816d6e3dd1c747e64e9947f9ad79bc7e" - integrity sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg== +browserslist@^4.21.10: + version "4.22.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.3.tgz#299d11b7e947a6b843981392721169e27d60c5a6" + integrity sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A== dependencies: - bn.js "^5.2.1" - browserify-rsa "^4.1.0" - create-hash "^1.2.0" - create-hmac "^1.1.7" - elliptic "^6.5.4" - inherits "^2.0.4" - parse-asn1 "^5.1.6" - readable-stream "^3.6.2" - safe-buffer "^5.2.1" - -browserify-zlib@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" - integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== - dependencies: - pako "~1.0.5" + caniuse-lite "^1.0.30001580" + electron-to-chromium "^1.4.648" + node-releases "^2.0.14" + update-browserslist-db "^1.0.13" buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer-xor@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" - integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= - -buffer@^4.3.0: - version "4.9.2" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" - integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== - dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" - isarray "^1.0.0" - -builtin-status-codes@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" - integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= - -cacache@^12.0.2: - version "12.0.4" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c" - integrity sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ== - dependencies: - bluebird "^3.5.5" - chownr "^1.1.1" - figgy-pudding "^3.5.1" - glob "^7.1.4" - graceful-fs "^4.1.15" - infer-owner "^1.0.3" - lru-cache "^5.1.1" - mississippi "^3.0.0" - mkdirp "^0.5.1" - move-concurrently "^1.0.1" - promise-inflight "^1.0.1" - rimraf "^2.6.3" - ssri "^6.0.1" - unique-filename "^1.1.1" - y18n "^4.0.0" - -cache-base@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" - integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== - dependencies: - collection-visit "^1.0.0" - component-emitter "^1.2.1" - get-value "^2.0.6" - has-value "^1.0.0" - isobject "^3.0.1" - set-value "^2.0.0" - to-object-path "^0.3.0" - union-value "^1.0.0" - unset-value "^1.0.0" - -camelcase@^5.0.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -chalk@2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chokidar@^2.0.2: - version "2.1.8" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" - integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.1" - braces "^2.3.2" - glob-parent "^3.1.0" - inherits "^2.0.3" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - normalize-path "^3.0.0" - path-is-absolute "^1.0.0" - readdirp "^2.2.1" - upath "^1.1.1" - optionalDependencies: - fsevents "^1.2.7" - -chownr@^1.1.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" - integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +caniuse-lite@^1.0.30001580: + version "1.0.30001581" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz#0dfd4db9e94edbdca67d57348ebc070dece279f4" + integrity sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ== chrome-trace-event@^1.0.2: version "1.0.2" @@ -527,414 +258,63 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" - integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -class-utils@^0.3.5: - version "0.3.6" - resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" - integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== - dependencies: - arr-union "^3.1.0" - define-property "^0.2.5" - isobject "^3.0.0" - static-extend "^0.1.1" - -cliui@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" - integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== - dependencies: - string-width "^3.1.0" - strip-ansi "^5.2.0" - wrap-ansi "^5.1.0" - -collection-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" - integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= - dependencies: - map-visit "^1.0.0" - object-visit "^1.0.0" - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= - commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= - -component-emitter@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" - integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -concat-stream@^1.5.0: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - -console-browserify@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" - integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== - -constants-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" - integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= - -copy-concurrently@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" - integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A== - dependencies: - aproba "^1.1.1" - fs-write-stream-atomic "^1.0.8" - iferr "^0.1.5" - mkdirp "^0.5.1" - rimraf "^2.5.4" - run-queue "^1.0.0" - -copy-descriptor@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" - integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= - -core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +electron-to-chromium@^1.4.648: + version "1.4.648" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.648.tgz#c7b46c9010752c37bb4322739d6d2dd82354fbe4" + integrity sha512-EmFMarXeqJp9cUKu/QEciEApn0S/xRcpZWuAm32U7NgoZCimjsilKXHRO9saeEW55eHZagIDg6XTUOv32w9pjg== -create-ecdh@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" - integrity sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw== +enhanced-resolve@^5.15.0: + version "5.15.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz#1af946c7d93603eb88e9896cee4904dc012e9c35" + integrity sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg== dependencies: - bn.js "^4.1.0" - elliptic "^6.0.0" + graceful-fs "^4.2.4" + tapable "^2.2.0" -create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" - integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== - dependencies: - cipher-base "^1.0.1" - inherits "^2.0.1" - md5.js "^1.3.4" - ripemd160 "^2.0.1" - sha.js "^2.4.0" - -create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: - version "1.1.7" - resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" - integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== - dependencies: - cipher-base "^1.0.3" - create-hash "^1.1.0" - inherits "^2.0.1" - ripemd160 "^2.0.0" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -cross-spawn@6.0.5, cross-spawn@^6.0.0: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -crypto-browserify@^3.11.0: - version "3.12.0" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" - integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== - dependencies: - browserify-cipher "^1.0.0" - browserify-sign "^4.0.0" - create-ecdh "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.0" - diffie-hellman "^5.0.0" - inherits "^2.0.1" - pbkdf2 "^3.0.3" - public-encrypt "^4.0.0" - randombytes "^2.0.0" - randomfill "^1.0.3" - -cyclist@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" - integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= - -debug@^2.2.0, debug@^2.3.3: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= - -decode-uri-component@^0.2.0: - version "0.2.2" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" - integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== - -define-property@^0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" - integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= - dependencies: - is-descriptor "^0.1.0" - -define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" - integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= - dependencies: - is-descriptor "^1.0.0" - -define-property@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" - integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== - dependencies: - is-descriptor "^1.0.2" - isobject "^3.0.1" - -des.js@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" - integrity sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA== - dependencies: - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -detect-file@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" - integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc= - -diffie-hellman@^5.0.0: - version "5.0.3" - resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" - integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== - dependencies: - bn.js "^4.1.0" - miller-rabin "^4.0.0" - randombytes "^2.0.0" - -domain-browser@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" - integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== - -duplexify@^3.4.2, duplexify@^3.6.0: - version "3.7.1" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" - integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== - dependencies: - end-of-stream "^1.0.0" - inherits "^2.0.1" - readable-stream "^2.0.0" - stream-shift "^1.0.0" - -elliptic@^6.0.0, elliptic@^6.5.4: - version "6.5.4" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" - integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== - dependencies: - bn.js "^4.11.9" - brorand "^1.1.0" - hash.js "^1.0.0" - hmac-drbg "^1.0.1" - inherits "^2.0.4" - minimalistic-assert "^1.0.1" - minimalistic-crypto-utils "^1.0.1" - -emoji-regex@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - -emojis-list@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" - integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= - -emojis-list@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" - integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== - -end-of-stream@^1.0.0, end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -enhanced-resolve@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" - integrity sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng== - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.4.0" - tapable "^1.0.0" - -enhanced-resolve@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.1.tgz#2937e2b8066cd0fe7ce0990a98f0d71a35189f66" - integrity sha512-98p2zE+rL7/g/DzMHMTF4zZlCgeVdJ7yr6xzEpJRYwFYrGi9ANdn5DnJURg6RpBkyk60XYDnWIv51VfIhfNGuA== - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.5.0" - tapable "^1.0.0" - -errno@^0.1.3, errno@~0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" - integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== - dependencies: - prr "~1.0.1" +es-module-lexer@^1.2.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5" + integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w== -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -eslint-scope@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" - integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== +eslint-scope@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== dependencies: - esrecurse "^4.1.0" + esrecurse "^4.3.0" estraverse "^4.1.1" -esrecurse@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf" - integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== dependencies: - estraverse "^4.1.0" + estraverse "^5.2.0" -estraverse@^4.1.0, estraverse@^4.1.1: +estraverse@^4.1.1: version "4.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== -events@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59" - integrity sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg== - -evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" - integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== - dependencies: - md5.js "^1.3.4" - safe-buffer "^5.1.1" - -execa@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" - integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== - dependencies: - cross-spawn "^6.0.0" - get-stream "^4.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -expand-brackets@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" - integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= - dependencies: - debug "^2.3.3" - define-property "^0.2.5" - extend-shallow "^2.0.1" - posix-character-classes "^0.1.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -expand-tilde@^2.0.0, expand-tilde@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" - integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI= - dependencies: - homedir-polyfill "^1.0.1" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= - dependencies: - is-extendable "^0.1.0" - -extend-shallow@^3.0.0, extend-shallow@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" - integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= - dependencies: - assign-symbols "^1.0.0" - is-extendable "^1.0.1" +estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== -extglob@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" - integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== - dependencies: - array-unique "^0.3.2" - define-property "^1.0.0" - expand-brackets "^2.1.4" - extend-shallow "^2.0.1" - fragment-cache "^0.2.1" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" +events@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== fast-deep-equal@^3.1.1: version "3.1.3" @@ -946,1328 +326,116 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -figgy-pudding@^3.5.1: - version "3.5.2" - resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" - integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== - -file-uri-to-path@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" - integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== - -fill-range@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" - integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= - dependencies: - extend-shallow "^2.0.1" - is-number "^3.0.0" - repeat-string "^1.6.1" - to-regex-range "^2.1.0" - -find-cache-dir@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" - integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== - dependencies: - commondir "^1.0.1" - make-dir "^2.0.0" - pkg-dir "^3.0.0" - -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - -findup-sync@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1" - integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg== - dependencies: - detect-file "^1.0.0" - is-glob "^4.0.0" - micromatch "^3.0.4" - resolve-dir "^1.0.1" - -flush-write-stream@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" - integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== - dependencies: - inherits "^2.0.3" - readable-stream "^2.3.6" - -for-in@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= - -fragment-cache@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" - integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= - dependencies: - map-cache "^0.2.2" - -from2@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" - integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= - dependencies: - inherits "^2.0.1" - readable-stream "^2.0.0" - -fs-write-stream-atomic@^1.0.8: - version "1.0.10" - resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" - integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= - dependencies: - graceful-fs "^4.1.2" - iferr "^0.1.5" - imurmurhash "^0.1.4" - readable-stream "1 || 2" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= - -fsevents@^1.2.7: - version "1.2.12" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.12.tgz#db7e0d8ec3b0b45724fd4d83d43554a8f1f0de5c" - integrity sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q== - dependencies: - bindings "^1.5.0" - nan "^2.12.1" - -get-caller-file@^2.0.1: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-stream@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" - integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== - dependencies: - pump "^3.0.0" - -get-value@^2.0.3, get-value@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" - integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= - -glob-parent@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" - integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= - dependencies: - is-glob "^3.1.0" - path-dirname "^1.0.0" - -glob@^7.1.3, glob@^7.1.4: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -global-modules@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" - integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== - dependencies: - global-prefix "^3.0.0" - -global-modules@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" - integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg== - dependencies: - global-prefix "^1.0.1" - is-windows "^1.0.1" - resolve-dir "^1.0.0" - -global-prefix@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe" - integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4= - dependencies: - expand-tilde "^2.0.2" - homedir-polyfill "^1.0.1" - ini "^1.3.4" - is-windows "^1.0.1" - which "^1.2.14" - -global-prefix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" - integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== - dependencies: - ini "^1.3.5" - kind-of "^6.0.2" - which "^1.3.1" +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: +graceful-fs@^4.1.2: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= - -has-value@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" - integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= - dependencies: - get-value "^2.0.3" - has-values "^0.1.4" - isobject "^2.0.0" - -has-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" - integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= - dependencies: - get-value "^2.0.6" - has-values "^1.0.0" - isobject "^3.0.0" - -has-values@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" - integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= - -has-values@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" - integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= - dependencies: - is-number "^3.0.0" - kind-of "^4.0.0" - -hash-base@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" - integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg= - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -hash.js@^1.0.0, hash.js@^1.0.3: - version "1.1.7" - resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" - integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== - dependencies: - inherits "^2.0.3" - minimalistic-assert "^1.0.1" - -hmac-drbg@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" - integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= - dependencies: - hash.js "^1.0.3" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.1" - -homedir-polyfill@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" - integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== - dependencies: - parse-passwd "^1.0.0" - -https-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" - integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= - -ieee754@^1.1.4: - version "1.1.13" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" - integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== - -iferr@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" - integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= - -import-local@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" - integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== - dependencies: - pkg-dir "^3.0.0" - resolve-cwd "^2.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= - -infer-owner@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" - integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -inherits@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= - -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= - -ini@^1.3.4, ini@^1.3.5: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -interpret@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" - integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== - -invert-kv@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" - integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== - -is-accessor-descriptor@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" - integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= - dependencies: - kind-of "^3.0.2" - -is-accessor-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" - integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== - dependencies: - kind-of "^6.0.0" - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= - dependencies: - binary-extensions "^1.0.0" - -is-buffer@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - -is-data-descriptor@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" - integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= - dependencies: - kind-of "^3.0.2" - -is-data-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" - integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== - dependencies: - kind-of "^6.0.0" - -is-descriptor@^0.1.0: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" - integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== - dependencies: - is-accessor-descriptor "^0.1.6" - is-data-descriptor "^0.1.4" - kind-of "^5.0.0" - -is-descriptor@^1.0.0, is-descriptor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" - integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== - dependencies: - is-accessor-descriptor "^1.0.0" - is-data-descriptor "^1.0.0" - kind-of "^6.0.2" - -is-extendable@^0.1.0, is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= - -is-extendable@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" - integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== - dependencies: - is-plain-object "^2.0.4" - -is-extglob@^2.1.0, is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= - -is-glob@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" - integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= - dependencies: - is-extglob "^2.1.0" - -is-glob@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - -is-number@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" - integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= - dependencies: - kind-of "^3.0.2" - -is-plain-object@^2.0.3, is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= - -is-windows@^1.0.1, is-windows@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" - integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== - -is-wsl@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" - integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= - -isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= +graceful-fs@^4.2.4, graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -isobject@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= +jest-worker@^27.4.5: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" + integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== dependencies: - isarray "1.0.0" - -isobject@^3.0.0, isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" -json-parse-better-errors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== +json-parse-even-better-errors@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json5@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" - integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== - dependencies: - minimist "^1.2.0" - -kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= - dependencies: - is-buffer "^1.1.5" - -kind-of@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" - integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= - dependencies: - is-buffer "^1.1.5" - -kind-of@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" - integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== - -kind-of@^6.0.0, kind-of@^6.0.2: - version "6.0.3" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" - integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== - -lcid@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" - integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== - dependencies: - invert-kv "^2.0.0" - -loader-runner@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" - integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== - -loader-utils@1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" - integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA== - dependencies: - big.js "^5.2.2" - emojis-list "^2.0.0" - json5 "^1.0.1" - -loader-utils@^1.2.3: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" - integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== - dependencies: - big.js "^5.2.2" - emojis-list "^3.0.0" - json5 "^1.0.1" - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -lru-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" - integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== - dependencies: - yallist "^3.0.2" - -make-dir@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" - integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== - dependencies: - pify "^4.0.1" - semver "^5.6.0" - -map-age-cleaner@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" - integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== - dependencies: - p-defer "^1.0.0" - -map-cache@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" - integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= - -map-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" - integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= - dependencies: - object-visit "^1.0.0" - -md5.js@^1.3.4: - version "1.3.5" - resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" - integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - safe-buffer "^5.1.2" - -mem@^4.0.0: +loader-runner@^4.2.0: version "4.3.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" - integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== - dependencies: - map-age-cleaner "^0.1.1" - mimic-fn "^2.0.0" - p-is-promise "^2.0.0" - -memory-fs@^0.4.0, memory-fs@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" - integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= - dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" - -memory-fs@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c" - integrity sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA== - dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" + integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== -micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: - version "3.1.10" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" - integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - braces "^2.3.1" - define-property "^2.0.2" - extend-shallow "^3.0.2" - extglob "^2.0.4" - fragment-cache "^0.2.1" - kind-of "^6.0.2" - nanomatch "^1.2.9" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.2" - -miller-rabin@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" - integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== - dependencies: - bn.js "^4.0.0" - brorand "^1.0.1" - -mimic-fn@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" - integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== - -minimalistic-crypto-utils@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" - integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= - -minimatch@^3.0.4: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimist@^1.2.0, minimist@^1.2.5: - version "1.2.7" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" - integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== - -mississippi@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" - integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA== - dependencies: - concat-stream "^1.5.0" - duplexify "^3.4.2" - end-of-stream "^1.1.0" - flush-write-stream "^1.0.0" - from2 "^2.1.0" - parallel-transform "^1.1.0" - pump "^3.0.0" - pumpify "^1.3.3" - stream-each "^1.1.0" - through2 "^2.0.0" - -mixin-deep@^1.2.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" - integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== - dependencies: - for-in "^1.0.2" - is-extendable "^1.0.1" - -mkdirp@^0.5.1, mkdirp@^0.5.3: - version "0.5.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.4.tgz#fd01504a6797ec5c9be81ff43d204961ed64a512" - integrity sha512-iG9AK/dJLtJ0XNgTuDbSyNS3zECqDlAhnQW4CsNxBG3LQJBbHmRX1egw39DmtOdCAqY+dKXV+sgPgilNWUKMVw== - dependencies: - minimist "^1.2.5" - -move-concurrently@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" - integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= - dependencies: - aproba "^1.1.1" - copy-concurrently "^1.0.0" - fs-write-stream-atomic "^1.0.8" - mkdirp "^0.5.1" - rimraf "^2.5.4" - run-queue "^1.0.3" - -ms@2.0.0: +merge-stream@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -nan@^2.12.1: - version "2.14.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" - integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== - -nanomatch@^1.2.9: - version "1.2.13" - resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" - integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - define-property "^2.0.2" - extend-shallow "^3.0.2" - fragment-cache "^0.2.1" - is-windows "^1.0.2" - kind-of "^6.0.2" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -neo-async@^2.5.0, neo-async@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" - integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== - -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - -node-libs-browser@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" - integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== - dependencies: - assert "^1.1.1" - browserify-zlib "^0.2.0" - buffer "^4.3.0" - console-browserify "^1.1.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.11.0" - domain-browser "^1.1.1" - events "^3.0.0" - https-browserify "^1.0.0" - os-browserify "^0.3.0" - path-browserify "0.0.1" - process "^0.11.10" - punycode "^1.2.4" - querystring-es3 "^0.2.0" - readable-stream "^2.3.3" - stream-browserify "^2.0.1" - stream-http "^2.7.2" - string_decoder "^1.0.0" - timers-browserify "^2.0.4" - tty-browserify "0.0.0" - url "^0.11.0" - util "^0.11.0" - vm-browserify "^1.0.1" - -normalize-path@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= - dependencies: - remove-trailing-separator "^1.0.1" - -normalize-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" - integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= - dependencies: - path-key "^2.0.0" - -object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= - -object-copy@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" - integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= - dependencies: - copy-descriptor "^0.1.0" - define-property "^0.2.5" - kind-of "^3.0.3" - -object-visit@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" - integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= - dependencies: - isobject "^3.0.0" - -object.pick@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" - integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= - dependencies: - isobject "^3.0.1" - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" - -os-browserify@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" - integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= - -os-locale@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" - integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== - dependencies: - execa "^1.0.0" - lcid "^2.0.0" - mem "^4.0.0" - -p-defer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" - integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= - -p-is-promise@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" - integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -p-limit@^2.0.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e" - integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ== - dependencies: - p-try "^2.0.0" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== +mime-types@^2.1.27: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: - p-limit "^2.0.0" + mime-db "1.52.0" -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -pako@~1.0.5: - version "1.0.11" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" - integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== - -parallel-transform@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc" - integrity sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg== - dependencies: - cyclist "^1.0.1" - inherits "^2.0.3" - readable-stream "^2.1.5" - -parse-asn1@^5.0.0: - version "5.1.5" - resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.5.tgz#003271343da58dc94cace494faef3d2147ecea0e" - integrity sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ== - dependencies: - asn1.js "^4.0.0" - browserify-aes "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.0" - pbkdf2 "^3.0.3" - safe-buffer "^5.1.1" - -parse-asn1@^5.1.6: - version "5.1.6" - resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4" - integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw== - dependencies: - asn1.js "^5.2.0" - browserify-aes "^1.0.0" - evp_bytestokey "^1.0.0" - pbkdf2 "^3.0.3" - safe-buffer "^5.1.1" +node-releases@^2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== -parse-passwd@^1.0.0: +picocolors@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" - integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= - -pascalcase@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" - integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= - -path-browserify@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" - integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== - -path-dirname@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" - integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-key@^2.0.0, path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - -pbkdf2@^3.0.3: - version "3.0.17" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6" - integrity sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA== - dependencies: - create-hash "^1.1.2" - create-hmac "^1.1.4" - ripemd160 "^2.0.1" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -pify@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" - integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== - -pkg-dir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" - integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== - dependencies: - find-up "^3.0.0" - -posix-character-classes@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" - integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= - -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - -process@^0.11.10: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= - -promise-inflight@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" - integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= - -prr@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" - integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= - -public-encrypt@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" - integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== - dependencies: - bn.js "^4.1.0" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - parse-asn1 "^5.0.0" - randombytes "^2.0.1" - safe-buffer "^5.1.2" - -pump@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" - integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -pumpify@^1.3.3: - version "1.5.1" - resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" - integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== - dependencies: - duplexify "^3.6.0" - inherits "^2.0.3" - pump "^2.0.0" - -punycode@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" - integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= - -punycode@^1.2.4: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== punycode@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -querystring-es3@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" - integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= - -querystring@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" - integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= - -randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: +randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== dependencies: safe-buffer "^5.1.0" -randomfill@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" - integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== - dependencies: - randombytes "^2.0.5" - safe-buffer "^5.1.0" - -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: - version "2.3.7" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" - integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.6.2: - version "3.6.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readdirp@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" - integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== - dependencies: - graceful-fs "^4.1.11" - micromatch "^3.1.10" - readable-stream "^2.0.2" - -regex-not@^1.0.0, regex-not@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" - integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== - dependencies: - extend-shallow "^3.0.2" - safe-regex "^1.1.0" - -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - -repeat-element@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" - integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== - -repeat-string@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= - -require-main-filename@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== - -resolve-cwd@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" - integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= - dependencies: - resolve-from "^3.0.0" - -resolve-dir@^1.0.0, resolve-dir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43" - integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M= - dependencies: - expand-tilde "^2.0.0" - global-modules "^1.0.0" - -resolve-from@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" - integrity sha1-six699nWiBvItuZTM17rywoYh0g= - -resolve-url@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" - integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= - -ret@~0.1.10: - version "0.1.15" - resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" - integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== - -rimraf@^2.5.4, rimraf@^2.6.3: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - -ripemd160@^2.0.0, ripemd160@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" - integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - -run-queue@^1.0.0, run-queue@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" - integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= - dependencies: - aproba "^1.1.1" - -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== -safe-buffer@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" - integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= - dependencies: - ret "~0.1.10" - -safer-buffer@^2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -schema-utils@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" - integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== - dependencies: - ajv "^6.1.0" - ajv-errors "^1.0.0" - ajv-keywords "^3.1.0" - -semver@^5.5.0, semver@^5.6.0: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - -serialize-javascript@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" - integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - -set-value@^2.0.0, set-value@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" - integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.3" - split-string "^3.0.1" - -setimmediate@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= - -sha.js@^2.4.0, sha.js@^2.4.8: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= +schema-utils@^3.1.1, schema-utils@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" + integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - -signal-exit@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= + "@types/json-schema" "^7.0.8" + ajv "^6.12.5" + ajv-keywords "^3.5.2" -snapdragon-node@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" - integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== - dependencies: - define-property "^1.0.0" - isobject "^3.0.0" - snapdragon-util "^3.0.1" - -snapdragon-util@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" - integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== - dependencies: - kind-of "^3.2.0" - -snapdragon@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" - integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== - dependencies: - base "^0.11.1" - debug "^2.2.0" - define-property "^0.2.5" - extend-shallow "^2.0.1" - map-cache "^0.2.2" - source-map "^0.5.6" - source-map-resolve "^0.5.0" - use "^3.1.0" - -source-list-map@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" - integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== - -source-map-resolve@^0.5.0: - version "0.5.3" - resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" - integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== +serialize-javascript@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== dependencies: - atob "^2.1.2" - decode-uri-component "^0.2.0" - resolve-url "^0.2.1" - source-map-url "^0.4.0" - urix "^0.1.0" + randombytes "^2.1.0" -source-map-support@~0.5.12: +source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -2275,249 +443,61 @@ source-map-support@~0.5.12: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-url@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" - integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= - -source-map@^0.5.6: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: +source-map@^0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -split-string@^3.0.1, split-string@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" - integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== dependencies: - extend-shallow "^3.0.0" + has-flag "^4.0.0" -ssri@^6.0.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5" - integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q== - dependencies: - figgy-pudding "^3.5.1" - -static-extend@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" - integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= - dependencies: - define-property "^0.2.5" - object-copy "^0.1.0" - -stream-browserify@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" - integrity sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg== - dependencies: - inherits "~2.0.1" - readable-stream "^2.0.2" - -stream-each@^1.1.0: - version "1.2.3" - resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" - integrity sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw== - dependencies: - end-of-stream "^1.1.0" - stream-shift "^1.0.0" - -stream-http@^2.7.2: - version "2.8.3" - resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" - integrity sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw== - dependencies: - builtin-status-codes "^3.0.0" - inherits "^2.0.1" - readable-stream "^2.3.6" - to-arraybuffer "^1.0.0" - xtend "^4.0.0" - -stream-shift@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" - integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== - -string-width@^3.0.0, string-width@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - -string_decoder@^1.0.0, string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" - integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== - dependencies: - ansi-regex "^4.1.0" - -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" - integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= - -supports-color@6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -tapable@^1.0.0, tapable@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" - integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== - -terser-webpack-plugin@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" - integrity sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA== - dependencies: - cacache "^12.0.2" - find-cache-dir "^2.1.0" - is-wsl "^1.1.0" - schema-utils "^1.0.0" - serialize-javascript "^2.1.2" - source-map "^0.6.1" - terser "^4.1.2" - webpack-sources "^1.4.0" - worker-farm "^1.7.0" - -terser@^4.1.2: - version "4.8.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f" - integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw== - dependencies: +tapable@^2.1.1, tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + +terser-webpack-plugin@^5.3.10: + version "5.3.10" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" + integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== + dependencies: + "@jridgewell/trace-mapping" "^0.3.20" + jest-worker "^27.4.5" + schema-utils "^3.1.1" + serialize-javascript "^6.0.1" + terser "^5.26.0" + +terser@^5.26.0: + version "5.27.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.27.0.tgz#70108689d9ab25fef61c4e93e808e9fd092bf20c" + integrity sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.8.2" commander "^2.20.0" - source-map "~0.6.1" - source-map-support "~0.5.12" - -through2@^2.0.0: - version "2.0.5" - resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" - integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== - dependencies: - readable-stream "~2.3.6" - xtend "~4.0.1" - -timers-browserify@^2.0.4: - version "2.0.11" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.11.tgz#800b1f3eee272e5bc53ee465a04d0e804c31211f" - integrity sha512-60aV6sgJ5YEbzUdn9c8kYGIqOubPoUdqQCul3SBAsRCZ40s6Y5cMcrW4dt3/k/EsbLVJNl9n6Vz3fTc+k2GeKQ== - dependencies: - setimmediate "^1.0.4" - -to-arraybuffer@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" - integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= - -to-object-path@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" - integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= - dependencies: - kind-of "^3.0.2" - -to-regex-range@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" - integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= - dependencies: - is-number "^3.0.0" - repeat-string "^1.6.1" - -to-regex@^3.0.1, to-regex@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" - integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== - dependencies: - define-property "^2.0.2" - extend-shallow "^3.0.2" - regex-not "^1.0.2" - safe-regex "^1.1.0" + source-map-support "~0.5.20" tslib@^1.9.0: version "1.11.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== -tty-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" - integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= - -typedarray@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= - -union-value@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" - integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== - dependencies: - arr-union "^3.1.0" - get-value "^2.0.6" - is-extendable "^0.1.1" - set-value "^2.0.1" - -unique-filename@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" - integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== - dependencies: - unique-slug "^2.0.0" - -unique-slug@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" - integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== - dependencies: - imurmurhash "^0.1.4" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -unset-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" - integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= +update-browserslist-db@^1.0.13: + version "1.0.13" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" + integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== dependencies: - has-value "^0.3.1" - isobject "^3.0.0" - -upath@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" - integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== + escalade "^3.1.1" + picocolors "^1.0.0" uri-js@^4.2.2: version "4.4.1" @@ -2526,185 +506,45 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -urix@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" - integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= - -url@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" - integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= - dependencies: - punycode "1.3.2" - querystring "0.2.0" - -use@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" - integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== - -util-deprecate@^1.0.1, util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= - -util@0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" - integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= - dependencies: - inherits "2.0.1" - -util@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" - integrity sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ== - dependencies: - inherits "2.0.3" - -v8-compile-cache@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe" - integrity sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w== - -vm-browserify@^1.0.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" - integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== - -watchpack@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" - integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== dependencies: - chokidar "^2.0.2" + glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" - neo-async "^2.5.0" - -webpack-cli@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.11.tgz#3bf21889bf597b5d82c38f215135a411edfdc631" - integrity sha512-dXlfuml7xvAFwYUPsrtQAA9e4DOe58gnzSxhgrO/ZM/gyXTBowrsYeubyN4mqGhYdpXMFNyQ6emjJS9M7OBd4g== - dependencies: - chalk "2.4.2" - cross-spawn "6.0.5" - enhanced-resolve "4.1.0" - findup-sync "3.0.0" - global-modules "2.0.0" - import-local "2.0.0" - interpret "1.2.0" - loader-utils "1.2.3" - supports-color "6.1.0" - v8-compile-cache "2.0.3" - yargs "13.2.4" - -webpack-sources@^1.4.0, webpack-sources@^1.4.1: - version "1.4.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" - integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== - dependencies: - source-list-map "^2.0.0" - source-map "~0.6.1" -webpack@^4.42.1: - version "4.42.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.42.1.tgz#ae707baf091f5ca3ef9c38b884287cfe8f1983ef" - integrity sha512-SGfYMigqEfdGchGhFFJ9KyRpQKnipvEvjc1TwrXEPCM6H5Wywu10ka8o3KGrMzSMxMQKt8aCHUFh5DaQ9UmyRg== - dependencies: - "@webassemblyjs/ast" "1.9.0" - "@webassemblyjs/helper-module-context" "1.9.0" - "@webassemblyjs/wasm-edit" "1.9.0" - "@webassemblyjs/wasm-parser" "1.9.0" - acorn "^6.2.1" - ajv "^6.10.2" - ajv-keywords "^3.4.1" +webpack-sources@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" + integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== + +webpack@^5.90.0: + version "5.90.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.90.0.tgz#313bfe16080d8b2fee6e29b6c986c0714ad4290e" + integrity sha512-bdmyXRCXeeNIePv6R6tGPyy20aUobw4Zy8r0LUS2EWO+U+Ke/gYDgsCh7bl5rB6jPpr4r0SZa6dPxBxLooDT3w== + dependencies: + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^1.0.5" + "@webassemblyjs/ast" "^1.11.5" + "@webassemblyjs/wasm-edit" "^1.11.5" + "@webassemblyjs/wasm-parser" "^1.11.5" + acorn "^8.7.1" + acorn-import-assertions "^1.9.0" + browserslist "^4.21.10" chrome-trace-event "^1.0.2" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.3" - json-parse-better-errors "^1.0.2" - loader-runner "^2.4.0" - loader-utils "^1.2.3" - memory-fs "^0.4.1" - micromatch "^3.1.10" - mkdirp "^0.5.3" - neo-async "^2.6.1" - node-libs-browser "^2.2.1" - schema-utils "^1.0.0" - tapable "^1.1.3" - terser-webpack-plugin "^1.4.3" - watchpack "^1.6.0" - webpack-sources "^1.4.1" - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= - -which@^1.2.14, which@^1.2.9, which@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - -worker-farm@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" - integrity sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw== - dependencies: - errno "~0.1.7" - -wrap-ansi@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" - integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== - dependencies: - ansi-styles "^3.2.0" - string-width "^3.0.0" - strip-ansi "^5.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= - -xtend@^4.0.0, xtend@~4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -y18n@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" - integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== - -yallist@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - -yargs-parser@^13.1.0: - version "13.1.2" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" - integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs@13.2.4: - version "13.2.4" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.2.4.tgz#0b562b794016eb9651b98bd37acf364aa5d6dc83" - integrity sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg== - dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - os-locale "^3.1.0" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.0" + enhanced-resolve "^5.15.0" + es-module-lexer "^1.2.1" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.9" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.2.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.3.10" + watchpack "^2.4.0" + webpack-sources "^3.2.3" diff --git a/packages/node/test/onuncaughtexception.test.ts b/packages/node/test/onuncaughtexception.test.ts index 7d2544e63f91..c06a3cb43a69 100644 --- a/packages/node/test/onuncaughtexception.test.ts +++ b/packages/node/test/onuncaughtexception.test.ts @@ -1,7 +1,7 @@ import * as SentryCore from '@sentry/core'; import type { NodeClient } from '../src/client'; -import { OnUncaughtException, makeErrorHandler } from '../src/integrations/onuncaughtexception'; +import { makeErrorHandler, onUncaughtExceptionIntegration } from '../src/integrations/onuncaughtexception'; const client = { getOptions: () => ({}), @@ -19,8 +19,8 @@ jest.mock('@sentry/core', () => { describe('uncaught exceptions', () => { test('install global listener', () => { - const integration = new OnUncaughtException(); - integration.setup(client); + const integration = onUncaughtExceptionIntegration(); + integration.setup!(client); expect(process.listeners('uncaughtException')).toHaveLength(1); }); diff --git a/packages/node/test/onunhandledrejection.test.ts b/packages/node/test/onunhandledrejection.test.ts index 0667cd9570b2..b62da7c02fe0 100644 --- a/packages/node/test/onunhandledrejection.test.ts +++ b/packages/node/test/onunhandledrejection.test.ts @@ -1,7 +1,7 @@ import { Hub } from '@sentry/core'; import type { NodeClient } from '../src/client'; -import { OnUnhandledRejection, makeUnhandledPromiseHandler } from '../src/integrations/onunhandledrejection'; +import { makeUnhandledPromiseHandler, onUnhandledRejectionIntegration } from '../src/integrations/onunhandledrejection'; // don't log the test errors we're going to throw, so at a quick glance it doesn't look like the test itself has failed global.console.warn = () => null; @@ -20,8 +20,8 @@ jest.mock('@sentry/core', () => { describe('unhandled promises', () => { test('install global listener', () => { - const integration = new OnUnhandledRejection(); - integration.setup(client); + const integration = onUnhandledRejectionIntegration(); + integration.setup!(client); expect(process.listeners('unhandledRejection')).toHaveLength(1); }); diff --git a/packages/node/test/performance.test.ts b/packages/node/test/performance.test.ts index 0f57dd4166e6..513a3e95a7c0 100644 --- a/packages/node/test/performance.test.ts +++ b/packages/node/test/performance.test.ts @@ -1,5 +1,13 @@ -import { setAsyncContextStrategy, setCurrentClient, startSpan, startSpanManual } from '@sentry/core'; -import type { TransactionEvent } from '@sentry/types'; +import { + setAsyncContextStrategy, + setCurrentClient, + startInactiveSpan, + startSpan, + startSpanManual, + withIsolationScope, + withScope, +} from '@sentry/core'; +import type { Span, TransactionEvent } from '@sentry/types'; import { NodeClient, defaultStackParser } from '../src'; import { setNodeAsyncContextStrategy } from '../src/async'; import { getDefaultNodeClientOptions } from './helper/node-client-options'; @@ -147,4 +155,90 @@ describe('startSpanManual()', () => { expect(transactionEvent.spans).toContainEqual(expect.objectContaining({ description: 'second' })); }); + + it('should use the scopes at time of creation instead of the scopes at time of termination', async () => { + const transactionEventPromise = new Promise(resolve => { + setCurrentClient( + new NodeClient( + getDefaultNodeClientOptions({ + stackParser: defaultStackParser, + tracesSampleRate: 1, + beforeSendTransaction: event => { + resolve(event); + return null; + }, + dsn, + }), + ), + ); + }); + + withIsolationScope(isolationScope1 => { + isolationScope1.setTag('isolationScope', 1); + withScope(scope1 => { + scope1.setTag('scope', 1); + startSpanManual({ name: 'my-span' }, span => { + withIsolationScope(isolationScope2 => { + isolationScope2.setTag('isolationScope', 2); + withScope(scope2 => { + scope2.setTag('scope', 2); + span?.end(); + }); + }); + }); + }); + }); + + expect(await transactionEventPromise).toMatchObject({ + tags: { + scope: 1, + isolationScope: 1, + }, + }); + }); +}); + +describe('startInactiveSpan()', () => { + it('should use the scopes at time of creation instead of the scopes at time of termination', async () => { + const transactionEventPromise = new Promise(resolve => { + setCurrentClient( + new NodeClient( + getDefaultNodeClientOptions({ + stackParser: defaultStackParser, + tracesSampleRate: 1, + beforeSendTransaction: event => { + resolve(event); + return null; + }, + dsn, + }), + ), + ); + }); + + let span: Span | undefined; + + withIsolationScope(isolationScope => { + isolationScope.setTag('isolationScope', 1); + withScope(scope => { + scope.setTag('scope', 1); + span = startInactiveSpan({ name: 'my-span' }); + }); + }); + + withIsolationScope(isolationScope => { + isolationScope.setTag('isolationScope', 2); + withScope(scope => { + scope.setTag('scope', 2); + span?.end(); + }); + }); + + expect(await transactionEventPromise).toMatchObject({ + tags: { + scope: 1, + isolationScope: 1, + }, + }); + }); }); diff --git a/packages/opentelemetry-node/src/spanprocessor.ts b/packages/opentelemetry-node/src/spanprocessor.ts index 1ca4e3584b1f..d14d77010422 100644 --- a/packages/opentelemetry-node/src/spanprocessor.ts +++ b/packages/opentelemetry-node/src/spanprocessor.ts @@ -79,6 +79,7 @@ export class SentrySpanProcessor implements OtelSpanProcessor { const transaction = getCurrentHub().startTransaction({ name: otelSpan.name, ...traceCtx, + attributes: otelSpan.attributes, instrumenter: 'otel', startTimestamp: convertOtelTimeToSeconds(otelSpan.startTime), spanId: otelSpanId, diff --git a/packages/opentelemetry-node/test/spanprocessor.test.ts b/packages/opentelemetry-node/test/spanprocessor.test.ts index 0bd7d852f7a5..940f5c38bdab 100644 --- a/packages/opentelemetry-node/test/spanprocessor.test.ts +++ b/packages/opentelemetry-node/test/spanprocessor.test.ts @@ -301,7 +301,7 @@ describe('SentrySpanProcessor', () => { 'service.name': 'test-service', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': 'opentelemetry', - 'telemetry.sdk.version': '1.17.1', + 'telemetry.sdk.version': '1.21.0', }, }, }); @@ -326,7 +326,7 @@ describe('SentrySpanProcessor', () => { 'service.name': 'test-service', 'telemetry.sdk.language': 'nodejs', 'telemetry.sdk.name': 'opentelemetry', - 'telemetry.sdk.version': '1.17.1', + 'telemetry.sdk.version': '1.21.0', }, }, }); diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index bbeb2744e501..0f53876f7240 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -3,7 +3,7 @@ import { TraceFlags, propagation, trace } from '@opentelemetry/api'; import { W3CBaggagePropagator, isTracingSuppressed } from '@opentelemetry/core'; import { getDynamicSamplingContextFromClient } from '@sentry/core'; import type { DynamicSamplingContext, PropagationContext } from '@sentry/types'; -import { SENTRY_BAGGAGE_KEY_PREFIX, generateSentryTraceHeader, tracingContextFromHeaders } from '@sentry/utils'; +import { SENTRY_BAGGAGE_KEY_PREFIX, generateSentryTraceHeader, propagationContextFromHeaders } from '@sentry/utils'; import { SENTRY_BAGGAGE_HEADER, SENTRY_TRACE_HEADER } from './constants'; import { getClient } from './custom/hub'; @@ -55,7 +55,7 @@ export class SentryPropagator extends W3CBaggagePropagator { : maybeSentryTraceHeader : undefined; - const { propagationContext } = tracingContextFromHeaders(sentryTraceHeader, maybeBaggageHeader); + const propagationContext = propagationContextFromHeaders(sentryTraceHeader, maybeBaggageHeader); // Add propagation context to context const contextWithPropagationContext = setPropagationContextOnContext(context, propagationContext); diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index a3f2d07eddd4..0cd15ebb2daf 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -3,7 +3,7 @@ import type { Attributes, Context, SpanContext } from '@opentelemetry/api'; import { TraceFlags, isSpanContextValid, trace } from '@opentelemetry/api'; import type { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-base'; import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; -import { hasTracingEnabled } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, hasTracingEnabled } from '@sentry/core'; import type { Client, ClientOptions, SamplingContext } from '@sentry/types'; import { isNaN, logger } from '@sentry/utils'; @@ -27,7 +27,7 @@ export class SentrySampler implements Sampler { traceId: string, spanName: string, _spanKind: unknown, - _attributes: unknown, + spanAttributes: unknown, _links: unknown, ): SamplingResult { const options = this._client.getOptions(); @@ -54,6 +54,8 @@ export class SentrySampler implements Sampler { } const sampleRate = getSampleRate(options, { + name: spanName, + attributes: spanAttributes, transactionContext: { name: spanName, parentSampled, @@ -62,7 +64,7 @@ export class SentrySampler implements Sampler { }); const attributes: Attributes = { - [InternalSentrySemanticAttributes.SAMPLE_RATE]: Number(sampleRate), + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: Number(sampleRate), }; if (typeof parentSampled === 'boolean') { diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 0d57d1009e31..f4bf4bc3aec4 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -3,7 +3,7 @@ import type { ExportResult } from '@opentelemetry/core'; import { ExportResultCode } from '@opentelemetry/core'; import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { flush, getCurrentScope } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, flush, getCurrentScope } from '@sentry/core'; import type { Scope, Span as SentrySpan, SpanOrigin, TransactionSource } from '@sentry/types'; import { logger } from '@sentry/utils'; @@ -176,7 +176,7 @@ function createTransactionForOtelSpan(span: ReadableSpan): OpenTelemetryTransact metadata: { dynamicSamplingContext, source, - sampleRate: span.attributes[InternalSentrySemanticAttributes.SAMPLE_RATE] as number | undefined, + sampleRate: span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE] as number | undefined, ...metadata, }, data: removeSentryAttributes(data), @@ -267,7 +267,7 @@ function removeSentryAttributes(data: Record): Record { span => { expect(span).toBeDefined(); expect(getSpanAttributes(span)).toEqual({ - [InternalSentrySemanticAttributes.SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, }); expect(getSpanMetadata(span)).toEqual(undefined); @@ -227,7 +228,7 @@ describe('trace', () => { [InternalSentrySemanticAttributes.SOURCE]: 'task', [InternalSentrySemanticAttributes.ORIGIN]: 'auto.test.origin', [InternalSentrySemanticAttributes.OP]: 'my-op', - [InternalSentrySemanticAttributes.SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, }); expect(getSpanMetadata(span)).toEqual({ requestPath: 'test-path' }); @@ -253,7 +254,7 @@ describe('trace', () => { expect(span).toBeDefined(); expect(getSpanName(span)).toEqual('outer'); expect(getSpanAttributes(span)).toEqual({ - [InternalSentrySemanticAttributes.SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, test1: 'test 1', test2: 2, }); @@ -326,7 +327,7 @@ describe('trace', () => { expect(span).toBeDefined(); expect(getSpanAttributes(span)).toEqual({ - [InternalSentrySemanticAttributes.SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, }); expect(getSpanMetadata(span)).toEqual(undefined); @@ -341,7 +342,7 @@ describe('trace', () => { expect(span2).toBeDefined(); expect(getSpanAttributes(span2)).toEqual({ - [InternalSentrySemanticAttributes.SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [InternalSentrySemanticAttributes.SOURCE]: 'task', [InternalSentrySemanticAttributes.ORIGIN]: 'auto.test.origin', [InternalSentrySemanticAttributes.OP]: 'my-op', @@ -366,7 +367,7 @@ describe('trace', () => { expect(span).toBeDefined(); expect(getSpanName(span)).toEqual('outer'); expect(getSpanAttributes(span)).toEqual({ - [InternalSentrySemanticAttributes.SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, test1: 'test 1', test2: 2, }); @@ -451,7 +452,7 @@ describe('trace', () => { expect(span).toBeDefined(); expect(getSpanName(span)).toEqual('outer'); expect(getSpanAttributes(span)).toEqual({ - [InternalSentrySemanticAttributes.SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, test1: 'test 1', test2: 2, }); @@ -688,6 +689,10 @@ describe('trace (sampling)', () => { expect(tracesSampler).toBeCalledTimes(1); expect(tracesSampler).toHaveBeenLastCalledWith({ parentSampled: undefined, + name: 'outer', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, transactionContext: { name: 'outer', parentSampled: undefined }, }); @@ -705,6 +710,8 @@ describe('trace (sampling)', () => { expect(tracesSampler).toHaveBeenCalledTimes(3); expect(tracesSampler).toHaveBeenLastCalledWith({ parentSampled: false, + name: 'inner2', + attributes: {}, transactionContext: { name: 'inner2', parentSampled: false }, }); }); @@ -727,6 +734,10 @@ describe('trace (sampling)', () => { expect(tracesSampler).toBeCalledTimes(1); expect(tracesSampler).toHaveBeenLastCalledWith({ parentSampled: undefined, + name: 'outer', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, transactionContext: { name: 'outer', parentSampled: undefined }, }); @@ -744,6 +755,8 @@ describe('trace (sampling)', () => { expect(tracesSampler).toHaveBeenCalledTimes(3); expect(tracesSampler).toHaveBeenLastCalledWith({ parentSampled: false, + name: 'inner2', + attributes: {}, transactionContext: { name: 'inner2', parentSampled: false }, }); @@ -757,6 +770,8 @@ describe('trace (sampling)', () => { expect(tracesSampler).toHaveBeenCalledTimes(4); expect(tracesSampler).toHaveBeenLastCalledWith({ parentSampled: undefined, + name: 'outer3', + attributes: {}, transactionContext: { name: 'outer3', parentSampled: undefined }, }); }); @@ -799,6 +814,8 @@ describe('trace (sampling)', () => { expect(tracesSampler).toBeCalledTimes(1); expect(tracesSampler).toHaveBeenLastCalledWith({ parentSampled: true, + name: 'outer', + attributes: {}, transactionContext: { name: 'outer', parentSampled: true, diff --git a/packages/profiling-node/.eslintignore b/packages/profiling-node/.eslintignore new file mode 100644 index 000000000000..0deb19641d74 --- /dev/null +++ b/packages/profiling-node/.eslintignore @@ -0,0 +1,4 @@ +node_modules/ +build/ +lib/ +coverage/ diff --git a/packages/profiling-node/.eslintrc.js b/packages/profiling-node/.eslintrc.js new file mode 100644 index 000000000000..84ad1f9e91b7 --- /dev/null +++ b/packages/profiling-node/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], + + ignorePatterns: ['lib/**/*', 'examples/**/*', 'jest.co'], + rules: { + '@sentry-internal/sdk/no-optional-chaining': 'off', + '@sentry-internal/sdk/no-nullish-coalescing': 'off', + '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', + '@sentry-internal/sdk/no-class-field-initializers': 'off', + }, +}; diff --git a/packages/profiling-node/.gitignore b/packages/profiling-node/.gitignore new file mode 100644 index 000000000000..7a329e70a46c --- /dev/null +++ b/packages/profiling-node/.gitignore @@ -0,0 +1,6 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/node_modules/ +/lib/ + diff --git a/packages/profiling-node/CHANGES b/packages/profiling-node/CHANGES new file mode 100644 index 000000000000..012426c29a2f --- /dev/null +++ b/packages/profiling-node/CHANGES @@ -0,0 +1,505 @@ +## This is an old changelog. + +The profiling-node package has since been migrated to sentry-javascript monorepo. Any changes to this package made after +the migration are now tracked in the root CHANGELOG.md. + +## 1.3.3 + +### Various fixes & improvements + +- ci: make clang-format error when formatting errors are raised. (#230) by @JonasBa +- fix: cleanup timer and reuse exports (#229) by @JonasBa + +## 1.3.2 + +### Various fixes & improvements + +- deps(detect-libc): detect-libc is required for install scripts (#226) by @JonasBa + +## 1.3.1 + +### Various fixes & improvements + +- fix(profiling): add node-abi (d3b3ea9c) by @JonasBa + +## 1.3.0 + +### Various fixes & improvements + +- fix(profiling): node-gyp missing python (#223) by @JonasBa +- fix: change package.json keys (#222) by @anonrig +- test: move tests to dedicated test folder (#221) by @JonasBa +- chore: remove prettier (#220) by @anonrig +- format: add clang format (#214) by @JonasBa +- ref(deps) move all deps to dev deps (#218) by @JonasBa +- docs: add rollup external config section (#216) by @JonasBa +- deps: update to 7.85 (#215) by @JonasBa +- perf: avoid deep string copy (#213) by @anonrig +- chore: Add vite external to profiling node instructions (#209) by @AbhiPrasad + +## 1.2.6 + +### Various fixes & improvements + +- fix: check inf and remove rounding (#208) by @JonasBa +- fix(isnan): set rate to 0 if its nan (#207) by @JonasBa + +## 1.2.5 + +### Various fixes & improvements + +- fix(profiling): cap double precision (#206) by @JonasBa + +## 1.2.4 + +### Various fixes & improvements + +- fix(measurements): guard from negative cpu usage (c7ebac41) by @JonasBa + +## 1.2.3 + +### Various fixes & improvements + +- fix(profiling): if count is 0 dont serialize measurements (#205) by @JonasBa + +## 1.2.2 + +### Various fixes & improvements + +- deps(sentry): bump sentry deps (#203) by @sanjaytwisk +- build(deps-dev): bump @babel/traverse from 7.22.20 to 7.23.2 (#202) by @dependabot +- feat(preprocessEvent): emit preprocessEvent for profiles (#198) by @JonasBa +- Update README.md to include Next.js 13+ bundling (#200) by @Negan1911 + +## 1.2.1 + +### Various fixes & improvements + +- fix: dont throw if profiler returns nulptr (#197) by @JonasBa + +## 1.2.0 + +### Various fixes & improvements + +- fix(build): catch spawn err (#193) by @JonasBa +- fix(build): cross compile from x64 to arm (#194) by @JonasBa +- feat(measurements): collect heap usage (#187) by @JonasBa + +## 1.1.3 + +### Various fixes & improvements + +- deps(sentry): bump sentry deps (#191) by @JonasBa +- ci: test app build and run before publish (#183) by @JonasBa +- build(deps-dev): bump word-wrap from 1.2.3 to 1.2.4 (#185) by @dependabot +- fix(types): correct frame type (cc74b6e1) by @JonasBa + +## 1.1.2 + +### Various fixes & improvements + +- fix: revert output of types to single file (#182) by @JonasBa + +## 1.1.1 + +### Various fixes & improvements + +- fix: setup musl (34874a63) by @JonasBa +- fix: attempt to recompile on all errors (#180) by @JonasBa +- fix:rebuild binary on any error (#179) by @JonasBa + +## 1.1.0 + +### Various fixes & improvements + +- bindings: prebuild more binaries for node 20 (#177) by @JonasBa +- build(deps): bump semver from 6.3.0 to 6.3.1 (#175) by @dependabot +- ref(profiling): change import so contextReplacementPlugin can ignore warning and attempt to provide darwin binaries + (#176) by @JonasBa + +## 1.0.9 + +### Various fixes & improvements + +- fix(require): require is no longer async (#174) by @JonasBa + +## 1.0.8 + +### Various fixes & improvements + +- fix: use gnu aarch compiler for arm64 binary (#172) by @bohdanw2 +- fix: Issue fixed for building binary with recompileFromSource (#173) by @whaagmans + +## 1.0.7 + +### Various fixes & improvements + +- fix(build): overwrited dest target (b741891d) by @JonasBa +- fix(build): run as single spawn cmd and fail if target already exists (#169) by @JonasBa +- build: stop error handling and just propagate all errors (#167) by @JonasBa +- build: fix typo (9dbc32fb) by @JonasBa +- build: just dont handle errors (ee0f9b95) by @JonasBa +- build: improve recompile error handling (07f2fd4d) by @JonasBa + +## 1.0.6 + +### Various fixes & improvements + +- build: drop exports entirely (28db74c6) by @JonasBa + +## 1.0.5 + +### Various fixes & improvements + +- build: drop esm support (#166) by @JonasBa + +## 1.0.4 + +### Various fixes & improvements + +- fix: check compile on install instead of postinstall (#163) by @JonasBa + +## 1.0.3 + +### Various fixes & improvements + +- Revert "fix: require instead of import in esm (#162)" (3b2f77fb) by @JonasBa + +## 1.0.2 + +### Various fixes & improvements + +- fix: require instead of import in esm (#162) by @JonasBa + +## 1.0.1 + +### Various fixes & improvements + +- fix: polyfill to level createRequire for esm (#161) by @JonasBa + +## 1.0.0 + +- No documented changes. + +## 1.0.0-beta.2 + +### Various fixes & improvements + +- fix: remove esm polyfills (#159) by @JonasBa + +## 1.0.0-beta.1 + +### Various fixes & improvements + +- build: run update before installing tooling to fix stale index (e4c0916e) by @JonasBa +- fix broken reference to copy-target script (#156) by @alekitto +- fix(profiling): remove app_root relative dir detection (#155) by @JonasBa +- fix(profiling): fix build banner typo (47da6797) by @JonasBa +- readme: add prune docs (9e4a0f3f) by @JonasBa + +## 1.0.0-alpha.7 + +### Various fixes & improvements + +- ref(tracing): drop @sentry/tracing (#153) by @JonasBa +- fix(esm): fix esm compile error (1c4a3cc6) by @JonasBa +- deps(tracing): remove tracing dependency (#152) by @JonasBa +- feat(scripts): introduce a cleanup script (#151) by @JonasBa +- feat(profiling): debug_id support (#144) by @JonasBa + +## 1.0.0-alpha.6 + +### Various fixes & improvements + +- readme: drop beta mentions (c3c66a72) by @JonasBa +- ci: test on node20 (d33110de) by @JonasBa +- ref: remove options options type (41b8544f) by @JonasBa +- ref: use options type (154255d3) by @JonasBa +- fix: segfault in node18 (95545180) by @JonasBa +- fix identifier (21425e4f) by @JonasBa +- rename to mjs (a4d50996) by @JonasBa +- fallthrough in switch (e9f6a872) by @JonasBa +- fix: add back return type (d7560395) by @JonasBa +- fix: profiling binary fallthrough (beaf0c0a) by @JonasBa +- fix: build needs require (bc39eaab) by @JonasBa +- fix(build): enumerate precompiled binaries (#146) by @JonasBa +- feat(profiling): bundle lib code (#145) by @JonasBa +- fix(profiling): add exports to package.json (#142) by @JonasBa +- perf: optimize string ops + remove nan (#140) by @JonasBa +- ref: remove profile context before sending (#138) by @JonasBa + +## 1.0.0-alpha.5 + +### Various fixes & improvements + +- fix: use format version (0850fa0c) by @JonasBa + +## 1.0.0-alpha.4 + +### Various fixes & improvements + +- fix(sdk): bump sdk version and read it (#137) by @JonasBa + +## 1.0.0-alpha.3 + +### Various fixes & improvements + +- fix(frames): fix frame attributes (afc1c7b0) by @JonasBa + +## 1.0.0-alpha.2 + +### Various fixes & improvements + +- feat(esm): build esm properly (#135) by @JonasBa + +## 1.0.0-alpha.1 + +### Various fixes & improvements + +- fix(ci): unpack binaries to lib/binaries (5bbf957f) by @JonasBa +- gh: fix label for install issue (d0602b40) by @JonasBa +- gh: fix label for install issue (ca82e73f) by @JonasBa +- gh: add installation issue template (d1f0a304) by @JonasBa +- ci: downgrade to ubuntu 20.04 (8364deba) by @JonasBa +- fix(status): inline status assertions (#133) by @JonasBa +- perf: use a module cache (#131) by @JonasBa +- ci: bump and pin node images (#130) by @JonasBa +- feat(module): parse module (#129) by @JonasBa +- fix(precompile): fix dir sync (#128) by @JonasBa +- ref: remove test log (c7529b14) by @JonasBa +- ref(profiling): drop nan for node abi (#127) by @JonasBa +- feat(build): output esm and cjs modules (#126) by @JonasBa +- Check if module exists before loading. Compile if missing (#122) by @vidhu +- Add @sentry/core as dep (#125) by @vidhu + +## 0.3.0 + +### Various fixes & improvements + +- fix(profiling): avoid unnecessary copy operations (#117) by @JonasBa +- feat(profiling): expose timeout experiment and handle timeout in hooks (#118) by @JonasBa +- ref(profiling): add SDK hooks support (#110) by @JonasBa +- build(deps-dev): bump sqlite3 from 5.1.2 to 5.1.5 (#111) by @dependabot +- docs: update install link for sentry profiling (#116) by @emilsivervik +- feat(profiling): only mark in_app: false for system code (#114) by @JonasBa +- ref(format): remove format macros (cee68c53) by @JonasBa +- ref(prebuilds): remove binary (6ff35ecb) by @JonasBa +- feat(profiling): add profilesSampler (#109) by @JonasBa +- ref(format): remove transactions array in favor of transaction property (#108) by @JonasBa +- chore: improve documentation around prebuild binaries (dc37cbfb) by @JonasBa + +## 0.2.2 + +### Various fixes & improvements + +- deps(sentry): bump sentry packages (22610c47) by @JonasBa + +## 0.2.1 + +### Various fixes & improvements + +- Update README.md (a1e128c2) by @JonasBa +- Update README.md (d0ad2379) by @JonasBa +- Update README.md (d97aad6a) by @JonasBa +- fix(env): read env from sdk (#103) by @JonasBa + +## 0.2.0 + +### Various fixes & improvements + +- feat(profiling): switch to eager logging by default (#102) by @JonasBa +- Update README.md (2d8fd065) by @JonasBa +- build(deps): bump http-cache-semantics from 4.1.0 to 4.1.1 (#100) by @dependabot + +## 0.1.0 + +- No documented changes. + +## 0.1.0-alpha.2 + +### Various fixes & improvements + +- fix(bin): downgrade ubuntu to 20.04 (#99) by @JonasBa + +## 0.1.0-alpha.1 + +### Various fixes & improvements + +- fix(linux): if dlopen fails, build from source (63632046) by @JonasBa +- fix(build): avoid from compiling the build script (#98) by @JonasBa +- fix(test): remove only (6cd37259) by @JonasBa +- feat(ci): run jest with --silent (#96) by @JonasBa +- feat(profiling): add profile context (#95) by @JonasBa +- feat(profiling): discard profiles with <= 1 sample (#94) by @JonasBa +- build(binaries): prebuild binaries for more arch (#92) by @JonasBa + +## 0.0.13 + +### Various fixes & improvements + +- fix(segfault): fix return value order (#89) by @JonasBa +- fix(uuid): uuid is 32hex in sentry (#91) by @JonasBa +- test(build): add node 19 to build matrix (#87) by @JonasBa +- build(deps): bump json5 from 2.2.1 to 2.2.3 (#86) by @dependabot +- feat(profiling): add debug logs (#82) by @JonasBa +- fix(sampleRate): profilesSampleRate and tracesSampleRate are multiplied (#77) by @JonasBa +- fix(sdk): use release instead of sdk release (#76) by @JonasBa +- feat(filename): generate filename from abs path (#75) by @JonasBa +- fix: path -> absPath (#72) by @JonasBa + +## 0.0.12 + +### Various fixes & improvements + +- fix(timestamps): int64_t (#69) by @JonasBa + +## 0.0.11 + +### Various fixes & improvements + +- fix(stack): use unique pointer not i (#68) by @JonasBa + +## 0.0.10 + +### Various fixes & improvements + +- fix(profile): wrong stack indexing insertion (#67) by @JonasBa +- docs: readme semicolon (#66) by @scttcper +- Update README.md (595ebb99) by @JonasBa + +## 0.0.9 + +### Various fixes & improvements + +- fix(envelope): missmatch in type guard (#65) by @JonasBa +- feat(binaries): precompile binaries (#64) by @JonasBa + +## 0.0.8 + +### Various fixes & improvements + +- fix(profiling): remove build script (c5cf7353) by @JonasBa + +## 0.0.7 + +### Various fixes & improvements + +- ref(sdk): remove spans (#62) by @JonasBa +- feat(profiling): use env variable instead of compile time (#61) by @JonasBa + +## 0.0.6 + +### Various fixes & improvements + +- ref(sdk): add temporary spans (#60) by @JonasBa +- ref(sdk): remove sdk tag (#59) by @JonasBa +- fix: fix javascript repo reference (af046f28) by @JonasBa + +## 0.0.5 + +### Various fixes & improvements + +- feat(profiling): index stacks (#58) by @JonasBa +- test(config): skip benchmarks (ee8e4d90) by @JonasBa +- chore(pkg): add github (#57) by @JonasBa +- test(samples): bump min samples (123928a2) by @JonasBa + +## 0.0.4 + +### Various fixes & improvements + +- chore(license): switch to MIT (#56) by @JonasBa +- fix(release): js not sh (27482070) by @JonasBa + +## 0.0.3 + +### Various fixes & improvements + +- fix(test): fix setTag test (096619d9) by @JonasBa +- chore(deps): bump (331e8450) by @JonasBa +- fix(profiler): fix typo (f1cb8823) by @JonasBa +- feat(profiler): set logging mode as tag (f2517f69) by @JonasBa + +## 0.0.2 + +### Various fixes & improvements + +- ref(benchmark): add jest benchmark (#54) by @JonasBa +- build(gyp): add compile time flag for profiler logging strategy (#55) by @JonasBa + +## 0.0.1 + +### Various fixes & improvements + +- feat(profile): log call site info (#53) by @JonasBa +- fix(benchmark): recompute json (eddcad28) by @JonasBa +- fix(benchmark): pass both options (b9911726) by @JonasBa +- fix(benchmark): pass option to compare (ce805797) by @JonasBa +- fix(benchmark): run node benchmark instead of compare (0a4d9abb) by @JonasBa +- fix(benchmark): run node benchmark (595a4f96) by @JonasBa +- fix(scripts): stash and apply script results (3d4c92df) by @JonasBa +- fix(scripts): stash and apply script results (a9e7f360) by @JonasBa +- fix(benchmark): allow running between two commits (#52) by @JonasBa +- test(threshold) increase max sample threshold (847f42ac) by @JonasBa +- test(threshold) increase max sample threshold (6d10b885) by @JonasBa +- feat(profiling): adjust sampling frequency (#50) by @JonasBa +- chore(github): add issue bug template (d5b488ad) by @JonasBa +- chore(github): add feature template (f137585b) by @JonasBa +- chore(github): add pull request template (759237f0) by @JonasBa + +## 0.0.0-alpha.6 + +### Various fixes & improvements + +- feat(sdk): use uuid to avoid ignored transactions (#47) by @JonasBa +- fix(profiling): correct ts (b46547f6) by @JonasBa +- feat(sdk): add max duration timeout (#46) by @JonasBa +- fix(units): report ns to backend (#45) by @JonasBa +- feat(timestamps): more accurate timestamps (#44) by @JonasBa +- ref(hubextension): explain finish reference (05924fe7) by @JonasBa +- fix(sampling): remove unnecessary negate (1b6fdab5) by @JonasBa +- fix(profile): rename fields and eval device info only once (#43) by @JonasBa +- feat(skd): add better messaging when we cannot patch the lib (#42) by @JonasBa +- chore(github): add contributing (#41) by @JonasBa + +## 0.0.0-alpha.5 + +### Various fixes & improvements + +- fix(c++): make sure we use unique_id (#40) by @JonasBa +- chore(readme): improve wording (5f439bba) by @JonasBa +- chore(workers): remove disclaimer as we do not support node 10 (ac15d4f7) by @JonasBa + +## 0.0.0-alpha.4 + +### Various fixes & improvements + +- chore(readme): overhead explanation (c159e6bd) by @JonasBa +- chore(readme): overhead explanation (58d865c5) by @JonasBa +- feat(playground): add express node test (#39) by @JonasBa +- feat(c++): cleanup addon data (#38) by @JonasBa +- feat(playground): add express integration (#37) by @JonasBa +- chore(readme): remove todo (505888e7) by @JonasBa + +## 0.0.0-alpha.3 + +### Various fixes & improvements + +- deps(nan): move to dependencies (#36) by @JonasBa + +## 0.0.0-alpha.2 + +### Various fixes & improvements + +- feat(build): move node-gyp to dep (#33) by @JonasBa + +## 0.0.0-alpha.1 + +### Various fixes & improvements + +- ci: use npm pack (#32) by @JonasBa +- fix(ci): remove dependant job (#31) by @JonasBa +- ci(craft): setup build (#30) by @JonasBa +- fix(ci): run ci for release (#28) by @JonasBa +- chore(craft): add bump version script (#26) by @JonasBa +- chore(changelog): add changelog (#25) by @JonasBa diff --git a/packages/profiling-node/LICENSE b/packages/profiling-node/LICENSE new file mode 100644 index 000000000000..6031123bdc4c --- /dev/null +++ b/packages/profiling-node/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/profiling-node/README.md b/packages/profiling-node/README.md new file mode 100644 index 000000000000..82d7ea97b4c6 --- /dev/null +++ b/packages/profiling-node/README.md @@ -0,0 +1,301 @@ +

+ + Sentry + +

+ +# Official Sentry Profiling SDK for NodeJS + +[![npm version](https://img.shields.io/npm/v/@sentry/profiling-node.svg)](https://www.npmjs.com/package/@sentry/profiling-node) +[![npm dm](https://img.shields.io/npm/dm/@sentry/profiling-node.svg)](https://www.npmjs.com/package/@sentry/profiling-node) +[![npm dt](https://img.shields.io/npm/dt/@sentry/profiling-node.svg)](https://www.npmjs.com/package/@sentry/profiling-node) + +## Installation + +Profiling works as an extension of tracing so you will need both @sentry/node and @sentry/profiling-node installed. The +minimum required major version of @sentry/node that supports profiling is 7.x. + +```bash +# Using yarn +yarn add @sentry/node @sentry/profiling-node + +# Using npm +npm install --save @sentry/node @sentry/profiling-node +``` + +## Usage + +```javascript +import * as Sentry from '@sentry/node'; +import { ProfilingIntegration } from '@sentry/profiling-node'; + +Sentry.init({ + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + debug: true, + tracesSampleRate: 1, + profilesSampleRate: 1, // Set profiling sampling rate. + integrations: [new ProfilingIntegration()], +}); +``` + +Sentry SDK will now automatically profile all transactions, even the ones which may be started as a result of using an +automatic instrumentation integration. + +```javascript +const transaction = Sentry.startTransaction({ name: 'some workflow' }); + +// The code between startTransaction and transaction.finish will be profiled + +transaction.finish(); +``` + +### Building the package from source + +Profiling uses native modules to interop with the v8 javascript engine which means that you may be required to build it +from source. The libraries required to successfully build the package from source are often the same libraries that are +already required to build any other package which uses native modules and if your codebase uses any of those modules, +there is a fairly good chance this will work out of the box. The required packages are python, make and g++. + +**Windows:** If you are building on windows, you may need to install windows-build-tools + +```bash + +# using yarn package manager +yarn global add windows-build-tools +# or npm package manager +npm i -g windows-build-tools +``` + +### Prebuilt binaries + +We currently ship prebuilt binaries for a few of the most common platforms and node versions (v16-20). + +- macOS x64 +- Linux ARM64 (musl) +- Linux x64 (glibc) +- Windows x64 + +For a more detailed list, see job_compile_bindings_profiling_node job in our build.yml github action workflow. + +### Bundling + +If you are looking to squeeze some extra performance or improve cold start in your application (especially true for +serverless environments where modules are often evaluates on a per request basis), then we recommend you look into +bundling your code. Modern JS engines are much faster at parsing and compiling JS than following long module resolution +chains and reading file contents from disk. Because @sentry/profiling-node is a package that uses native node modules, +bundling it is slightly different than just bundling javascript. In other words, the bundler needs to recognize that a +.node file is node native binding and move it to the correct location so that it can later be used. Failing to do so +will result in a MODULE_NOT_FOUND error. + +The easiest way to make bundling work with @sentry/profiling-node and other modules which use native nodejs bindings is +to mark the package as external - this will prevent the code from the package from being bundled, but it means that you +will now need to rely on the package to be installed in your production environment. + +To mark the package as external, use the following configuration: + +[Next.js 13+](https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsExternalPackages) + +```js +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + // Add the "@sentry/profiling-node" to serverComponentsExternalPackages. + serverComponentsExternalPackages: ['@sentry/profiling-node'], + }, +}; + +module.exports = withSentryConfig(nextConfig, { + /* ... */ +}); +``` + +[webpack](https://webpack.js.org/configuration/externals/#externals) + +```js +externals: { + "@sentry/profiling-node": "commonjs @sentry/profiling-node", +}, +``` + +[esbuild](https://esbuild.github.io/api/#external) + +```js +{ + entryPoints: ['index.js'], + platform: 'node', + external: ['@sentry/profiling-node'], +} +``` + +[Rollup](https://rollupjs.org/configuration-options/#external) + +```js +{ + entry: 'index.js', + external: '@sentry/profiling-node' +} +``` + +[serverless-esbuild (serverless.yml)](https://www.serverless.com/plugins/serverless-esbuild#external-dependencies) + +```yml +custom: + esbuild: + external: + - @sentry/profiling-node + packagerOptions: + scripts: + - npm install @sentry/profiling-node +``` + +[vercel-ncc](https://github.com/vercel/ncc#programmatically-from-nodejs) + +```js +{ + externals: ["@sentry/profiling-node"], +} +``` + +[vite](https://vitejs.dev/config/ssr-options.html#ssr-external) + +```js +ssr: { + external: ['@sentry/profiling-node']; +} +``` + +Marking the package as external is the simplest and most future proof way of ensuring it will work, however if you want +to bundle it, it is possible to do so as well. Bundling has the benefit of improving your script startup time as all of +the code is (usually) inside a single executable .js file, which saves time on module resolution. + +In general, when attempting to bundle .node native file extensions, you will need to tell your bundler how to treat +these, as by default it does not know how to handle them. The required approach varies between build tools and you will +need to find which one will work for you. + +The result of bundling .node files correctly is that they are placed into your bundle output directory with their +require paths updated to reflect their final location. + +Example of bundling @sentry/profiling-node with esbuild and .copy loader + +```json +// package.json +{ + "scripts": "node esbuild.serverless.js" +} +``` + +```js +// esbuild.serverless.js +const { sentryEsbuildPlugin } = require('@sentry/esbuild-plugin'); + +require('esbuild').build({ + entryPoints: ['./index.js'], + outfile: './dist', + platform: 'node', + bundle: true, + minify: true, + sourcemap: true, + // This is no longer necessary + // external: ["@sentry/profiling-node"], + loader: { + // ensures .node binaries are copied to ./dist + '.node': 'copy', + }, + plugins: [ + // See https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/esbuild/ + sentryEsbuildPlugin({ + project: '', + org: '', + authToken: '', + release: '', + sourcemaps: { + // Specify the directory containing build artifacts + assets: './dist/**', + }, + }), + ], +}); +``` + +Once you run `node esbuild.serverless.js` esbuild wil bundle and output the files to ./dist folder, but note that all of +the binaries will be copied. This is wasteful as you will likely only need one of these libraries to be available during +runtime. + +To prune the other libraries, profiling-node ships with a small utility script that helps you prune unused binaries. The +script can be invoked via `sentry-prune-profiler-binaries`, use `--help` to see a list of available options or +`--dry-run` if you want it to log the binaries that would have been deleted. + +Example of only preserving a binary to run node16 on linux x64 musl. + +```bash +sentry-prune-profiler-binaries --target_dir_path=./dist --target_platform=linux --target_node=16 --target_stdlib=musl --target_arch=x64 +``` + +Which will output something like + +``` +Sentry: pruned ./dist/sentry_cpu_profiler-darwin-x64-108-IFGH3SUR.node (90.41 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-darwin-x64-93-Q7KBVHSP.node (74.16 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-arm64-glibc-108-NXSISRTB.node (52.17 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-arm64-glibc-83-OEQT5HUK.node (52.08 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-arm64-glibc-93-IIXXW2PN.node (52.06 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-arm64-musl-108-DSILNYHA.node (48.46 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-arm64-musl-83-4CNOBNC3.node (48.37 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-arm64-musl-93-JA5PKNWQ.node (48.38 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-x64-glibc-108-NXSISRTB.node (52.17 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-x64-glibc-83-OEQT5HUK.node (52.08 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-x64-glibc-93-IIXXW2PN.node (52.06 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-x64-musl-108-CX7SL27U.node (51.50 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-linux-x64-musl-83-YD7ZQK2E.node (51.53 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-win32-x64-108-P7V3URQV.node (181.50 KiB) +Sentry: pruned ./dist/sentry_cpu_profiler-win32-x64-93-3PKQDSGE.node (181.50 KiB) +✅ Sentry: pruned 15 binaries, saved 1.06 MiB in total. +``` + +### Environment flags + +The default mode of the v8 CpuProfiler is kEagerLoggin which enables the profiler even when no profiles are active - +this is good because it makes calls to startProfiling fast at the tradeoff for constant CPU overhead. The behavior can +be controlled via the `SENTRY_PROFILER_LOGGING_MODE` environment variable with values of `eager|lazy`. If you opt to use +the lazy logging mode, calls to startProfiling may be slow (depending on environment and node version, it can be in the +order of a few hundred ms). + +Example of starting a server with lazy logging mode. + +```javascript +SENTRY_PROFILER_LOGGING_MODE=lazy node server.js +``` + +## FAQ 💭 + +### Can the profiler leak PII to Sentry? + +The profiler does not collect function arguments so leaking any PII is unlikely. We only collect a subset of the values +which may identify the device and os that the profiler is running on (if you are already using tracing, it is likely +that these values are already being collected by the SDK). + +There is one way a profiler could leak pii information, but this is unlikely and would only happen for cases where you +might be creating or naming functions which might contain pii information such as + +```js +eval('function scriptFor${PII_DATA}....'); +``` + +In that case it is possible that the function name may end up being reported to Sentry. + +### Are worker threads supported? + +No. All instances of the profiler are scoped per thread In practice, this means that starting a transaction on thread A +and delegating work to thread B will only result in sample stacks being collected from thread A. That said, nothing +should prevent you from starting a transaction on thread B concurrently which will result in two independant profiles +being sent to the Sentry backend. We currently do not do any correlation between such transactions, but we would be open +to exploring the possibilities. Please file an issue if you have suggestions or specific use-cases in mind. + +### How much overhead will this profiler add? + +The profiler uses the kEagerLogging option by default which trades off fast calls to startProfiling for a small amount +of constant CPU overhead. If you are using kEagerLogging then the tradeoff is reversed and there will be a small CPU +overhead while the profiler is not running, but calls to startProfiling could be slow (in our tests, this varies by +environments and node versions, but could be in the order of a couple 100ms). diff --git a/packages/profiling-node/binding.gyp b/packages/profiling-node/binding.gyp new file mode 100644 index 000000000000..fd2322db4e94 --- /dev/null +++ b/packages/profiling-node/binding.gyp @@ -0,0 +1,10 @@ +{ + "targets": [ + { + "target_name": "sentry_cpu_profiler", + "sources": [ "bindings/cpu_profiler.cc" ], + # Silence gcc8 deprecation warning https://github.com/nodejs/nan/issues/807#issuecomment-455750192 + "cflags": ["-Wno-cast-function-type"] + }, + ] +} diff --git a/packages/profiling-node/bindings/cpu_profiler.cc b/packages/profiling-node/bindings/cpu_profiler.cc new file mode 100644 index 000000000000..f269990f425b --- /dev/null +++ b/packages/profiling-node/bindings/cpu_profiler.cc @@ -0,0 +1,1118 @@ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +static const uint8_t kMaxStackDepth(128); +static const float kSamplingFrequency(99.0); // 99 to avoid lockstep sampling +static const float kSamplingHz(1 / kSamplingFrequency); +static const int kSamplingInterval(kSamplingHz * 1e6); +static const v8::CpuProfilingNamingMode + kNamingMode(v8::CpuProfilingNamingMode::kDebugNaming); +static const v8::CpuProfilingLoggingMode + kDefaultLoggingMode(v8::CpuProfilingLoggingMode::kEagerLogging); + +// Allow users to override the default logging mode via env variable. This is +// useful because sometimes the flow of the profiled program can be to execute +// many sequential transaction - in that case, it may be preferable to set eager +// logging to avoid paying the high cost of profiling for each individual +// transaction (one example for this are jest tests when run with --runInBand +// option). +static const char *kEagerLoggingMode = "eager"; +static const char *kLazyLoggingMode = "lazy"; + +v8::CpuProfilingLoggingMode GetLoggingMode() { + static const char *logging_mode(getenv("SENTRY_PROFILER_LOGGING_MODE")); + + // most times this wont be set so just bail early + if (!logging_mode) { + return kDefaultLoggingMode; + } + + // other times it'll likely be set to lazy as eager is the default + if (strcmp(logging_mode, kLazyLoggingMode) == 0) { + return v8::CpuProfilingLoggingMode::kLazyLogging; + } else if (strcmp(logging_mode, kEagerLoggingMode) == 0) { + return v8::CpuProfilingLoggingMode::kEagerLogging; + } + + return kDefaultLoggingMode; +} + +class SentryProfile; +class Profiler; + +enum class ProfileStatus { + kNotStarted, + kStarted, + kStopped, +}; + +class MeasurementsTicker { +private: + uv_timer_t timer; + uint64_t period_ms; + std::unordered_map> + heap_listeners; + std::unordered_map> + cpu_listeners; + v8::Isolate *isolate; + v8::HeapStatistics heap_stats; + uv_cpu_info_t cpu_stats; + +public: + MeasurementsTicker(uv_loop_t *loop) + : period_ms(100), isolate(v8::Isolate::GetCurrent()) { + uv_timer_init(loop, &timer); + timer.data = this; + } + + static void ticker(uv_timer_t *); + // Memory listeners + void heap_callback(); + void add_heap_listener( + std::string &profile_id, + const std::function cb); + void remove_heap_listener( + std::string &profile_id, + const std::function &cb); + + // CPU listeners + void cpu_callback(); + void add_cpu_listener(std::string &profile_id, + const std::function cb); + void remove_cpu_listener(std::string &profile_id, + const std::function &cb); + + size_t listener_count(); + + ~MeasurementsTicker() { + uv_timer_stop(&timer); + + auto handle = reinterpret_cast(&timer); + + // Calling uv_close on an inactive handle will cause a segfault. + if (uv_is_active(handle)) { + uv_close(handle, nullptr); + } + } +}; + +size_t MeasurementsTicker::listener_count() { + return heap_listeners.size() + cpu_listeners.size(); +} + +// Heap tickers +void MeasurementsTicker::heap_callback() { + isolate->GetHeapStatistics(&heap_stats); + uint64_t ts = uv_hrtime(); + + for (auto cb : heap_listeners) { + cb.second(ts, heap_stats); + } +} + +void MeasurementsTicker::add_heap_listener( + std::string &profile_id, + const std::function cb) { + heap_listeners.emplace(profile_id, cb); + + if (listener_count() == 1) { + uv_timer_set_repeat(&timer, period_ms); + uv_timer_start(&timer, ticker, 0, period_ms); + } +} + +void MeasurementsTicker::remove_heap_listener( + std::string &profile_id, + const std::function &cb) { + heap_listeners.erase(profile_id); + + if (listener_count() == 0) { + uv_timer_stop(&timer); + } +}; + +// CPU tickers +void MeasurementsTicker::cpu_callback() { + uv_cpu_info_t *cpu = &cpu_stats; + int count; + int err = uv_cpu_info(&cpu, &count); + + if (err) { + return; + } + + if (count < 1) { + return; + } + + uint64_t ts = uv_hrtime(); + uint64_t total = 0; + uint64_t idle_total = 0; + + for (int i = 0; i < count; i++) { + uv_cpu_info_t *core = cpu + i; + + total += core->cpu_times.user; + total += core->cpu_times.nice; + total += core->cpu_times.sys; + total += core->cpu_times.idle; + total += core->cpu_times.irq; + + idle_total += core->cpu_times.idle; + } + + double idle_avg = idle_total / count; + double total_avg = total / count; + double rate = 1.0 - idle_avg / total_avg; + + if (rate < 0.0 || isinf(rate) || isnan(rate)) { + rate = 0.0; + } + + auto it = cpu_listeners.begin(); + while (it != cpu_listeners.end()) { + if (it->second(ts, rate)) { + it = cpu_listeners.erase(it); + } else { + ++it; + } + }; + + uv_free_cpu_info(cpu, count); +}; + +void MeasurementsTicker::ticker(uv_timer_t *handle) { + MeasurementsTicker *self = static_cast(handle->data); + self->heap_callback(); + self->cpu_callback(); +} + +void MeasurementsTicker::add_cpu_listener( + std::string &profile_id, const std::function cb) { + cpu_listeners.emplace(profile_id, cb); + + if (listener_count() == 1) { + uv_timer_set_repeat(&timer, period_ms); + uv_timer_start(&timer, ticker, 0, period_ms); + } +} + +void MeasurementsTicker::remove_cpu_listener( + std::string &profile_id, const std::function &cb) { + cpu_listeners.erase(profile_id); + + if (listener_count() == 0) { + uv_timer_stop(&timer); + } +}; + +class Profiler { +public: + std::unordered_map active_profiles; + + MeasurementsTicker measurements_ticker; + v8::CpuProfiler *cpu_profiler; + + explicit Profiler(const napi_env &env, v8::Isolate *isolate) + : measurements_ticker(uv_default_loop()), + cpu_profiler( + v8::CpuProfiler::New(isolate, kNamingMode, GetLoggingMode())) {} +}; + +class SentryProfile { +private: + uint64_t started_at; + uint16_t heap_write_index = 0; + uint16_t cpu_write_index = 0; + + std::vector heap_stats_ts; + std::vector heap_stats_usage; + + std::vector cpu_stats_ts; + std::vector cpu_stats_usage; + + const std::function memory_sampler_cb; + const std::function cpu_sampler_cb; + + ProfileStatus status = ProfileStatus::kNotStarted; + std::string id; + +public: + explicit SentryProfile(const char *id) + : started_at(uv_hrtime()), + memory_sampler_cb([this](uint64_t ts, v8::HeapStatistics &stats) { + if ((heap_write_index >= heap_stats_ts.capacity()) || + heap_write_index >= heap_stats_usage.capacity()) { + return true; + } + + heap_stats_ts.insert(heap_stats_ts.begin() + heap_write_index, + ts - started_at); + heap_stats_usage.insert( + heap_stats_usage.begin() + heap_write_index, + static_cast(stats.used_heap_size())); + ++heap_write_index; + + return false; + }), + + cpu_sampler_cb([this](uint64_t ts, double rate) { + if (cpu_write_index >= cpu_stats_ts.capacity() || + cpu_write_index >= cpu_stats_usage.capacity()) { + return true; + } + cpu_stats_ts.insert(cpu_stats_ts.begin() + cpu_write_index, + ts - started_at); + cpu_stats_usage.insert(cpu_stats_usage.begin() + cpu_write_index, + rate); + ++cpu_write_index; + return false; + }), + + status(ProfileStatus::kNotStarted), id(id) { + heap_stats_ts.reserve(300); + heap_stats_usage.reserve(300); + cpu_stats_ts.reserve(300); + cpu_stats_usage.reserve(300); + } + + const std::vector &heap_usage_timestamps() const; + const std::vector &heap_usage_values() const; + const uint16_t &heap_usage_write_index() const; + + const std::vector &cpu_usage_timestamps() const; + const std::vector &cpu_usage_values() const; + const uint16_t &cpu_usage_write_index() const; + + void Start(Profiler *profiler); + v8::CpuProfile *Stop(Profiler *profiler); +}; + +void SentryProfile::Start(Profiler *profiler) { + v8::Local profile_title = + v8::String::NewFromUtf8(v8::Isolate::GetCurrent(), id.c_str(), + v8::NewStringType::kNormal) + .ToLocalChecked(); + + started_at = uv_hrtime(); + + // Initialize the CPU Profiler + profiler->cpu_profiler->StartProfiling( + profile_title, + {v8::CpuProfilingMode::kCallerLineNumbers, + v8::CpuProfilingOptions::kNoSampleLimit, kSamplingInterval}); + + // listen for memory sample ticks + profiler->measurements_ticker.add_cpu_listener(id, cpu_sampler_cb); + profiler->measurements_ticker.add_heap_listener(id, memory_sampler_cb); + + status = ProfileStatus::kStarted; +} + +static void CleanupSentryProfile(Profiler *profiler, + SentryProfile *sentry_profile, + const std::string &profile_id) { + if (sentry_profile == nullptr) { + return; + } + + sentry_profile->Stop(profiler); + profiler->active_profiles.erase(profile_id); + delete sentry_profile; +}; + +v8::CpuProfile *SentryProfile::Stop(Profiler *profiler) { + // Stop the CPU Profiler + v8::CpuProfile *profile = profiler->cpu_profiler->StopProfiling( + v8::String::NewFromUtf8(v8::Isolate::GetCurrent(), id.c_str(), + v8::NewStringType::kNormal) + .ToLocalChecked()); + + // Remove the meemory sampler + profiler->measurements_ticker.remove_heap_listener(id, memory_sampler_cb); + profiler->measurements_ticker.remove_cpu_listener(id, cpu_sampler_cb); + // If for some reason stopProfiling was called with an invalid profile title + // or if that title had somehow been stopped already, profile will be null. + status = ProfileStatus::kStopped; + return profile; +} + +// Memory getters +const std::vector &SentryProfile::heap_usage_timestamps() const { + return heap_stats_ts; +}; + +const std::vector &SentryProfile::heap_usage_values() const { + return heap_stats_usage; +}; + +const uint16_t &SentryProfile::heap_usage_write_index() const { + return heap_write_index; +}; + +// CPU getters +const std::vector &SentryProfile::cpu_usage_timestamps() const { + return cpu_stats_ts; +}; + +const std::vector &SentryProfile::cpu_usage_values() const { + return cpu_stats_usage; +}; +const uint16_t &SentryProfile::cpu_usage_write_index() const { + return cpu_write_index; +}; + +#ifdef _WIN32 +static const char kPlatformSeparator = '\\'; +static const char kWinDiskPrefix = ':'; +#else +static const char kPlatformSeparator = '/'; +#endif + +static const char kSentryPathDelimiter = '.'; +static const char kSentryFileDelimiter = ':'; +static const std::string kNodeModulesPath = + std::string("node_modules") + kPlatformSeparator; + +static void GetFrameModule(const std::string &abs_path, std::string &module) { + if (abs_path.empty()) { + return; + } + + module = abs_path; + + // Drop .js extension + size_t module_len = module.length(); + if (module.compare(module_len - 3, 3, ".js") == 0) { + module = module.substr(0, module_len - 3); + } + + // Drop anything before and including node_modules/ + size_t node_modules_pos = module.rfind(kNodeModulesPath); + if (node_modules_pos != std::string::npos) { + module = module.substr(node_modules_pos + 13); + } + + // Replace all path separators with dots except the last one, that one is + // replaced with a colon + int match_count = 0; + for (int pos = module.length() - 1; pos >= 0; pos--) { + // if there is a match and it's not the first character, replace it + if (module[pos] == kPlatformSeparator) { + module[pos] = + match_count == 0 ? kSentryFileDelimiter : kSentryPathDelimiter; + match_count++; + } + } + +#ifdef _WIN32 + // Strip out C: prefix. On Windows, the drive letter is not part of the module + // name + if (module[1] == kWinDiskPrefix) { + // We will try and strip our the disk prefix. + module = module.substr(2, std::string::npos); + } +#endif + + if (module[0] == '.') { + module = module.substr(1, std::string::npos); + } +} + +static napi_value GetFrameModuleWrapped(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + + size_t len; + assert(napi_get_value_string_utf8(env, argv[0], NULL, 0, &len) == napi_ok); + + char *abs_path = (char *)malloc(len + 1); + assert(napi_get_value_string_utf8(env, argv[0], abs_path, len + 1, &len) == + napi_ok); + + std::string module; + napi_value napi_module; + + GetFrameModule(abs_path, module); + + assert(napi_create_string_utf8(env, module.c_str(), NAPI_AUTO_LENGTH, + &napi_module) == napi_ok); + return napi_module; +} + +napi_value +CreateFrameNode(const napi_env &env, const v8::CpuProfileNode &node, + std::unordered_map &module_cache, + napi_value &resources) { + napi_value js_node; + napi_create_object(env, &js_node); + + napi_value lineno_prop; + napi_create_int32(env, node.GetLineNumber(), &lineno_prop); + napi_set_named_property(env, js_node, "lineno", lineno_prop); + + napi_value colno_prop; + napi_create_int32(env, node.GetColumnNumber(), &colno_prop); + napi_set_named_property(env, js_node, "colno", colno_prop); + + if (node.GetSourceType() != v8::CpuProfileNode::SourceType::kScript) { + napi_value system_frame_prop; + napi_get_boolean(env, false, &system_frame_prop); + napi_set_named_property(env, js_node, "in_app", system_frame_prop); + } + + napi_value function; + napi_create_string_utf8(env, node.GetFunctionNameStr(), NAPI_AUTO_LENGTH, + &function); + napi_set_named_property(env, js_node, "function", function); + + const char *resource = node.GetScriptResourceNameStr(); + + if (resource != nullptr) { + // resource is absolute path, set it on the abs_path property + napi_value abs_path_prop; + napi_create_string_utf8(env, resource, NAPI_AUTO_LENGTH, &abs_path_prop); + napi_set_named_property(env, js_node, "abs_path", abs_path_prop); + // Error stack traces are not relative to root dir, doing our own path + // normalization breaks people's code mapping configs so we need to leave it + // as is. + napi_set_named_property(env, js_node, "filename", abs_path_prop); + + std::string module; + std::string resource_str = std::string(resource); + + if (resource_str.empty()) { + return js_node; + } + + if (module_cache.find(resource_str) != module_cache.end()) { + module = module_cache[resource_str]; + } else { + napi_value resource; + napi_create_string_utf8(env, resource_str.c_str(), NAPI_AUTO_LENGTH, + &resource); + napi_set_element(env, resources, module_cache.size(), resource); + + GetFrameModule(resource_str, module); + module_cache.emplace(resource_str, module); + } + + if (!module.empty()) { + napi_value filename_prop; + napi_create_string_utf8(env, module.c_str(), NAPI_AUTO_LENGTH, + &filename_prop); + napi_set_named_property(env, js_node, "module", filename_prop); + } + } + + return js_node; +}; + +napi_value CreateSample(const napi_env &env, const uint32_t stack_id, + const int64_t sample_timestamp_us, + const uint32_t thread_id) { + napi_value js_node; + napi_create_object(env, &js_node); + + napi_value stack_id_prop; + napi_create_uint32(env, stack_id, &stack_id_prop); + napi_set_named_property(env, js_node, "stack_id", stack_id_prop); + + napi_value thread_id_prop; + napi_create_string_utf8(env, std::to_string(thread_id).c_str(), + NAPI_AUTO_LENGTH, &thread_id_prop); + napi_set_named_property(env, js_node, "thread_id", thread_id_prop); + + napi_value elapsed_since_start_ns_prop; + napi_create_int64(env, sample_timestamp_us * 1000, + &elapsed_since_start_ns_prop); + napi_set_named_property(env, js_node, "elapsed_since_start_ns", + elapsed_since_start_ns_prop); + + return js_node; +}; + +std::string kDelimiter = std::string(";"); +std::string hashCpuProfilerNodeByPath(const v8::CpuProfileNode *node, + std::string &path) { + path.clear(); + + while (node != nullptr) { + path.append(std::to_string(node->GetNodeId())); + node = node->GetParent(); + } + + return path; +} + +static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, + const uint32_t thread_id, napi_value &samples, + napi_value &stacks, napi_value &frames, + napi_value &resources) { + const int64_t profile_start_time_us = profile->GetStartTime(); + const int sampleCount = profile->GetSamplesCount(); + + uint32_t unique_stack_id = 0; + uint32_t unique_frame_id = 0; + + // Initialize the lookup tables for stacks and frames, both of these are + // indexed in the sample format we are using to optimize for size. + std::unordered_map frame_lookup_table; + std::unordered_map stack_lookup_table; + std::unordered_map module_cache; + + // At worst, all stacks are unique so reserve the maximum amount of space + stack_lookup_table.reserve(sampleCount); + + std::string node_hash = ""; + + for (int i = 0; i < sampleCount; i++) { + uint32_t stack_index = unique_stack_id; + + const v8::CpuProfileNode *node = profile->GetSample(i); + const int64_t sample_timestamp = profile->GetSampleTimestamp(i); + + // If a node was only on top of the stack once, then it will only ever + // be inserted once and there is no need for hashing. + if (node->GetHitCount() > 1) { + hashCpuProfilerNodeByPath(node, node_hash); + + std::unordered_map::iterator + stack_index_cache_hit = stack_lookup_table.find(node_hash); + + // If we have a hit, update the stack index, otherwise + // insert it into the hash table and continue. + if (stack_index_cache_hit == stack_lookup_table.end()) { + stack_lookup_table.emplace(node_hash, stack_index); + } else { + stack_index = stack_index_cache_hit->second; + } + } + + napi_value sample = CreateSample( + env, stack_index, sample_timestamp - profile_start_time_us, thread_id); + + if (stack_index != unique_stack_id) { + napi_value index; + napi_create_uint32(env, i, &index); + napi_set_property(env, samples, index, sample); + continue; + } + + // A stack is a list of frames ordered from outermost (top) to innermost + // frame (bottom) + napi_value stack; + napi_create_array(env, &stack); + + uint32_t stack_depth = 0; + + while (node != nullptr && stack_depth < kMaxStackDepth) { + auto nodeId = node->GetNodeId(); + auto frame_index = frame_lookup_table.find(nodeId); + + // If the frame does not exist in the index + if (frame_index == frame_lookup_table.end()) { + frame_lookup_table.emplace(nodeId, unique_frame_id); + + napi_value frame_id; + napi_create_uint32(env, unique_frame_id, &frame_id); + + napi_value depth; + napi_create_uint32(env, stack_depth, &depth); + napi_set_property(env, stack, depth, frame_id); + napi_set_property(env, frames, frame_id, + CreateFrameNode(env, *node, module_cache, resources)); + + unique_frame_id++; + } else { + // If it was already indexed, just add it's id to the stack + napi_value depth; + napi_create_uint32(env, stack_depth, &depth); + + napi_value frame; + napi_create_uint32(env, frame_index->second, &frame); + napi_set_property(env, stack, depth, frame); + }; + + // Continue walking down the stack + node = node->GetParent(); + stack_depth++; + } + + napi_value napi_sample_index; + napi_value napi_stack_index; + + napi_create_uint32(env, i, &napi_sample_index); + napi_set_property(env, samples, napi_sample_index, sample); + napi_create_uint32(env, stack_index, &napi_stack_index); + napi_set_property(env, stacks, napi_stack_index, stack); + + unique_stack_id++; + } +} + +static napi_value +TranslateMeasurementsDouble(const napi_env &env, const char *unit, + const uint16_t size, + const std::vector &values, + const std::vector ×tamps) { + if (size > values.size() || size > timestamps.size()) { + napi_throw_range_error(env, "NAPI_ERROR", + "CPU measurement size is larger than the number of " + "values or timestamps"); + return nullptr; + } + + if (values.size() != timestamps.size()) { + napi_throw_range_error(env, "NAPI_ERROR", + "CPU measurement entries are corrupt, expected " + "values and timestamps to be of equal length"); + return nullptr; + } + + napi_value measurement; + napi_create_object(env, &measurement); + + napi_value unit_string; + napi_create_string_utf8(env, unit, NAPI_AUTO_LENGTH, &unit_string); + napi_set_named_property(env, measurement, "unit", unit_string); + + napi_value values_array; + napi_create_array(env, &values_array); + + uint16_t idx = size; + + for (size_t i = 0; i < idx; i++) { + napi_value entry; + napi_create_object(env, &entry); + + napi_value value; + if (napi_create_double(env, values[i], &value) != napi_ok) { + if (napi_create_double(env, 0.0, &value) != napi_ok) { + continue; + } + } + + napi_value ts; + napi_create_int64(env, timestamps[i], &ts); + + napi_set_named_property(env, entry, "value", value); + napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + napi_set_element(env, values_array, i, entry); + } + + napi_set_named_property(env, measurement, "values", values_array); + + return measurement; +} + +static napi_value +TranslateMeasurements(const napi_env &env, const char *unit, + const uint16_t size, const std::vector &values, + const std::vector ×tamps) { + if (size > values.size() || size > timestamps.size()) { + napi_throw_range_error(env, "NAPI_ERROR", + "Memory measurement size is larger than the number " + "of values or timestamps"); + return nullptr; + } + + if (values.size() != timestamps.size()) { + napi_throw_range_error(env, "NAPI_ERROR", + "Memory measurement entries are corrupt, expected " + "values and timestamps to be of equal length"); + return nullptr; + } + + napi_value measurement; + napi_create_object(env, &measurement); + + napi_value unit_string; + napi_create_string_utf8(env, unit, NAPI_AUTO_LENGTH, &unit_string); + napi_set_named_property(env, measurement, "unit", unit_string); + + napi_value values_array; + napi_create_array(env, &values_array); + + for (size_t i = 0; i < size; i++) { + napi_value entry; + napi_create_object(env, &entry); + + napi_value value; + napi_create_int64(env, values[i], &value); + + napi_value ts; + napi_create_int64(env, timestamps[i], &ts); + + napi_set_named_property(env, entry, "value", value); + napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + napi_set_element(env, values_array, i, entry); + } + + napi_set_named_property(env, measurement, "values", values_array); + + return measurement; +} + +static napi_value TranslateProfile(const napi_env &env, + const v8::CpuProfile *profile, + const uint32_t thread_id, + bool collect_resources) { + napi_value js_profile; + + napi_create_object(env, &js_profile); + + napi_value logging_mode; + napi_value samples; + napi_value stacks; + napi_value frames; + napi_value resources; + + napi_create_string_utf8( + env, + GetLoggingMode() == v8::CpuProfilingLoggingMode::kEagerLogging ? "eager" + : "lazy", + NAPI_AUTO_LENGTH, &logging_mode); + + napi_create_array(env, &samples); + napi_create_array(env, &stacks); + napi_create_array(env, &frames); + napi_create_array(env, &resources); + + napi_set_named_property(env, js_profile, "samples", samples); + napi_set_named_property(env, js_profile, "stacks", stacks); + napi_set_named_property(env, js_profile, "frames", frames); + napi_set_named_property(env, js_profile, "profiler_logging_mode", + logging_mode); + + GetSamples(env, profile, thread_id, samples, stacks, frames, resources); + + if (collect_resources) { + napi_set_named_property(env, js_profile, "resources", resources); + } else { + napi_create_array(env, &resources); + napi_set_named_property(env, js_profile, "resources", resources); + } + + return js_profile; +} + +static napi_value StartProfiling(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + + assert(napi_get_cb_info(env, info, &argc, argv, NULL, NULL) == napi_ok); + + napi_valuetype callbacktype0; + assert(napi_typeof(env, argv[0], &callbacktype0) == napi_ok); + + if (callbacktype0 != napi_string) { + napi_throw_error( + env, "NAPI_ERROR", + "TypeError: StartProfiling expects a string as first argument."); + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + size_t len; + assert(napi_get_value_string_utf8(env, argv[0], NULL, 0, &len) == napi_ok); + + char *title = (char *)malloc(len + 1); + assert(napi_get_value_string_utf8(env, argv[0], title, len + 1, &len) == + napi_ok); + + if (len < 1) { + napi_throw_error(env, "NAPI_ERROR", + "StartProfiling expects a non-empty string as first " + "argument, got an empty string."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + v8::Isolate *isolate = v8::Isolate::GetCurrent(); + assert(isolate != 0); + + Profiler *profiler; + assert(napi_get_instance_data(env, (void **)&profiler) == napi_ok); + + if (!profiler) { + napi_throw_error(env, "NAPI_ERROR", + "StartProfiling: Profiler is not initialized."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + const std::string profile_id(title); + // In case we have a collision, cleanup the old profile first + auto existing_profile = profiler->active_profiles.find(profile_id); + if (existing_profile != profiler->active_profiles.end()) { + existing_profile->second->Stop(profiler); + CleanupSentryProfile(profiler, existing_profile->second, profile_id); + } + + SentryProfile *sentry_profile = new SentryProfile(title); + sentry_profile->Start(profiler); + + profiler->active_profiles.emplace(profile_id, sentry_profile); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; +} + +// StopProfiling(string title) +// https://v8docs.nodesource.com/node-18.2/d2/d34/classv8_1_1_cpu_profiler.html#a40ca4c8a8aa4c9233aa2a2706457cc80 +static napi_value StopProfiling(napi_env env, napi_callback_info info) { + size_t argc = 3; + napi_value argv[3]; + + assert(napi_get_cb_info(env, info, &argc, argv, NULL, NULL) == napi_ok); + + if (argc < 2) { + napi_throw_error(env, "NAPI_ERROR", + "StopProfiling expects at least two arguments."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + // Verify the first argument is a string + napi_valuetype callbacktype0; + assert(napi_typeof(env, argv[0], &callbacktype0) == napi_ok); + + if (callbacktype0 != napi_string) { + napi_throw_error(env, "NAPI_ERROR", + "StopProfiling expects a string as first argument."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + // Verify the second argument is a number + napi_valuetype callbacktype1; + assert(napi_typeof(env, argv[1], &callbacktype1) == napi_ok); + + if (callbacktype1 != napi_number) { + napi_throw_error( + env, "NAPI_ERROR", + "StopProfiling expects a thread_id integer as second argument."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + size_t len; + assert(napi_get_value_string_utf8(env, argv[0], NULL, 0, &len) == napi_ok); + + char *title = (char *)malloc(len + 1); + assert(napi_get_value_string_utf8(env, argv[0], title, len + 1, &len) == + napi_ok); + + if (len < 1) { + napi_throw_error(env, "NAPI_ERROR", + "StopProfiling expects a string as first argument."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + // Get the value of the second argument and convert it to uint64 + int64_t thread_id; + assert(napi_get_value_int64(env, argv[1], &thread_id) == napi_ok); + + // Get profiler from instance data + Profiler *profiler; + assert(napi_get_instance_data(env, (void **)&profiler) == napi_ok); + + if (!profiler) { + napi_throw_error(env, "NAPI_ERROR", + "StopProfiling: Profiler is not initialized."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + + return napi_null; + } + + const std::string profile_id(title); + auto profile = profiler->active_profiles.find(profile_id); + + // If the profile was never started, silently ignore the call and return null + if (profile == profiler->active_profiles.end()) { + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + return napi_null; + } + + v8::CpuProfile *cpu_profile = profile->second->Stop(profiler); + + // If for some reason stopProfiling was called with an invalid profile title + // or if that title had somehow been stopped already, profile will be null. + if (!cpu_profile) { + CleanupSentryProfile(profiler, profile->second, profile_id); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + return napi_null; + }; + + napi_valuetype callbacktype3; + assert(napi_typeof(env, argv[2], &callbacktype3) == napi_ok); + + bool collect_resources; + napi_get_value_bool(env, argv[2], &collect_resources); + + napi_value js_profile = + TranslateProfile(env, cpu_profile, thread_id, collect_resources); + + napi_value measurements; + napi_create_object(env, &measurements); + + if (profile->second->heap_usage_write_index() > 0) { + static const char *memory_unit = "byte"; + napi_value heap_usage_measurements = TranslateMeasurements( + env, memory_unit, profile->second->heap_usage_write_index(), + profile->second->heap_usage_values(), + profile->second->heap_usage_timestamps()); + + if (heap_usage_measurements != nullptr) { + napi_set_named_property(env, measurements, "memory_footprint", + heap_usage_measurements); + }; + }; + + if (profile->second->cpu_usage_write_index() > 0) { + static const char *cpu_unit = "percent"; + napi_value cpu_usage_measurements = TranslateMeasurementsDouble( + env, cpu_unit, profile->second->cpu_usage_write_index(), + profile->second->cpu_usage_values(), + profile->second->cpu_usage_timestamps()); + + if (cpu_usage_measurements != nullptr) { + napi_set_named_property(env, measurements, "cpu_usage", + cpu_usage_measurements); + }; + }; + + napi_set_named_property(env, js_profile, "measurements", measurements); + + CleanupSentryProfile(profiler, profile->second, profile_id); + cpu_profile->Delete(); + + return js_profile; +}; + +void FreeAddonData(napi_env env, void *data, void *hint) { + Profiler *profiler = static_cast(data); + + if (profiler == nullptr) { + return; + } + + if (!profiler->active_profiles.empty()) { + for (auto &profile : profiler->active_profiles) { + CleanupSentryProfile(profiler, profile.second, profile.first); + } + } + + if (profiler->cpu_profiler != nullptr) { + profiler->cpu_profiler->Dispose(); + } + + delete profiler; +} + +napi_value Init(napi_env env, napi_value exports) { + v8::Isolate *isolate = v8::Isolate::GetCurrent(); + + if (isolate == nullptr) { + napi_throw_error(env, nullptr, + "Failed to initialize Sentry profiler: isolate is null."); + return NULL; + } + + Profiler *profiler = new Profiler(env, isolate); + + if (napi_set_instance_data(env, profiler, FreeAddonData, NULL) != napi_ok) { + napi_throw_error(env, nullptr, "Failed to set instance data for profiler."); + return NULL; + } + + napi_value start_profiling; + if (napi_create_function(env, "startProfiling", NAPI_AUTO_LENGTH, + StartProfiling, exports, + &start_profiling) != napi_ok) { + napi_throw_error(env, nullptr, "Failed to create startProfiling function."); + return NULL; + } + + if (napi_set_named_property(env, exports, "startProfiling", + start_profiling) != napi_ok) { + napi_throw_error(env, nullptr, + "Failed to set startProfiling property on exports."); + return NULL; + } + + napi_value stop_profiling; + if (napi_create_function(env, "stopProfiling", NAPI_AUTO_LENGTH, + StopProfiling, exports, + &stop_profiling) != napi_ok) { + napi_throw_error(env, nullptr, "Failed to create stopProfiling function."); + return NULL; + } + + if (napi_set_named_property(env, exports, "stopProfiling", stop_profiling) != + napi_ok) { + napi_throw_error(env, nullptr, + "Failed to set stopProfiling property on exports."); + return NULL; + } + + napi_value get_frame_module; + if (napi_create_function(env, "getFrameModule", NAPI_AUTO_LENGTH, + GetFrameModuleWrapped, exports, + &get_frame_module) != napi_ok) { + napi_throw_error(env, nullptr, "Failed to create getFrameModule function."); + return NULL; + } + + if (napi_set_named_property(env, exports, "getFrameModule", + get_frame_module) != napi_ok) { + napi_throw_error(env, nullptr, + "Failed to set getFrameModule property on exports."); + return NULL; + } + + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/packages/profiling-node/clang-format.js b/packages/profiling-node/clang-format.js new file mode 100644 index 000000000000..7deb9fb97993 --- /dev/null +++ b/packages/profiling-node/clang-format.js @@ -0,0 +1,20 @@ +const child_process = require('child_process'); + +const args = ['--Werror', '-i', '--style=file', 'bindings/cpu_profiler.cc']; +const cmd = `./node_modules/.bin/clang-format ${args.join(' ')}`; + +child_process.execSync(cmd); + +// eslint-disable-next-line no-console +console.log('clang-format: done, checking tree...'); + +const diff = child_process.execSync('git status --short').toString(); + +if (diff) { + // eslint-disable-next-line no-console + console.error('clang-format: check failed ❌'); + process.exit(1); +} + +// eslint-disable-next-line no-console +console.log('clang-format: check passed ✅'); diff --git a/packages/profiling-node/jest.config.js b/packages/profiling-node/jest.config.js new file mode 100644 index 000000000000..89bda645921b --- /dev/null +++ b/packages/profiling-node/jest.config.js @@ -0,0 +1,6 @@ +const baseConfig = require('../../jest/jest.config.js'); + +module.exports = { + ...baseConfig, + testEnvironment: 'node', +}; diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json new file mode 100644 index 000000000000..c72cc5c8ae6e --- /dev/null +++ b/packages/profiling-node/package.json @@ -0,0 +1,96 @@ +{ + "name": "@sentry/profiling-node", + "version": "1.3.5", + "description": "Official Sentry SDK for Node.js Profiling", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", + "author": "Sentry", + "license": "MIT", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "typesVersions": { + "<4.9": { + "lib/types/index.d.ts": [ + "lib/types-ts3.8/index.d.ts" + ] + } + }, + "bin": { + "sentry-prune-profiler-binaries": "scripts/prune-profiler-binaries.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "lib", + "bindings", + "binding.gyp", + "LICENSE", + "README.md", + "package.json", + "scripts/binaries.js", + "scripts/check-build.js", + "scripts/copy-target.js", + "scripts/prune-profiler-binaries.js" + ], + "scripts": { + "install": "node scripts/check-build.js", + "clean": "rm -rf build && rm -rf lib", + "lint": "yarn lint:eslint && yarn lint:clang", + "lint:eslint": "eslint . --format stylish", + "lint:clang": "node clang-format.js", + "fix": "eslint . --format stylish --fix", + "lint:fix": "yarn fix:eslint && yarn fix:clang", + "lint:fix:clang": "node clang-format.js --fix", + "build": "yarn build:lib && yarn build:bindings:configure && yarn build:bindings", + "build:lib": "yarn build:types && rollup -c rollup.npm.config.mjs", + "build:transpile": "yarn build:bindings:configure && yarn build:bindings && yarn build:lib", + "build:types:downlevel": "yarn downlevel-dts lib/types lib/types-ts3.8 --to ts3.8", + "build:types": "tsc -p tsconfig.types.json && yarn build:types:downlevel", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:bindings:configure": "node-gyp configure", + "build:bindings:configure:arm64": "node-gyp configure --arch=arm64 --target_arch=arm64", + "build:bindings": "node-gyp build && node scripts/copy-target.js", + "build:bindings:arm64": "node-gyp build --arch=arm64 && node scripts/copy-target.js", + "build:dev": "yarn clean && yarn build:bindings:configure && yarn build", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:watch": "run-p build:transpile:watch build:types:watch", + "build:tarball": "npm pack", + "test:watch": "cross-env SENTRY_PROFILER_BINARY_DIR=build jest --watch", + "test:bundle": "node test-binaries.esbuild.js", + "test": "cross-env SENTRY_PROFILER_BINARY_DIR=lib jest --config jest.config.js" + }, + "dependencies": { + "detect-libc": "^2.0.2", + "node-abi": "^3.52.0" + }, + "devDependencies": { + "@sentry/core": "7.93.0", + "@sentry/node": "7.93.0", + "@sentry/types": "7.93.0", + "@sentry/utils": "7.93.0", + "@types/node": "16.18.70", + "@types/node-abi": "^3.0.0", + "clang-format": "^1.8.0", + "cross-env": "^7.0.3", + "node-gyp": "^9.4.1", + "typescript": "^4.9.5" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "dependsOn": [ + "^build:transpile", + "^build:types" + ] + } + } + } +} diff --git a/packages/profiling-node/rollup.npm.config.mjs b/packages/profiling-node/rollup.npm.config.mjs new file mode 100644 index 000000000000..51e812488bb1 --- /dev/null +++ b/packages/profiling-node/rollup.npm.config.mjs @@ -0,0 +1,25 @@ +import commonjs from '@rollup/plugin-commonjs'; +import resolve from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; +import { makeBaseNPMConfig, makeNPMConfigVariants, plugins } from '@sentry-internal/rollup-utils'; + +const configs = makeNPMConfigVariants(makeBaseNPMConfig()); +const cjsConfig = configs.find(config => config.output.format === 'cjs'); + +if (!cjsConfig) { + throw new Error('CJS config is required for profiling-node.'); +} + +const config = { + ...cjsConfig, + input: 'src/index.ts', + output: { ...cjsConfig.output, file: 'lib/index.js', format: 'cjs', dir: undefined, preserveModules: false }, + plugins: [ + plugins.makeLicensePlugin('Sentry Node Profiling'), + resolve(), + commonjs(), + typescript({ tsconfig: './tsconfig.json' }), + ], +}; + +export default config; diff --git a/packages/profiling-node/scripts/binaries.js b/packages/profiling-node/scripts/binaries.js new file mode 100644 index 000000000000..2c0c6be2642b --- /dev/null +++ b/packages/profiling-node/scripts/binaries.js @@ -0,0 +1,27 @@ +const os = require('os'); +const path = require('path'); + +const abi = require('node-abi'); +const libc = require('detect-libc'); + +function getModuleName() { + const stdlib = libc.familySync(); + const platform = process.env['BUILD_PLATFORM'] || os.platform(); + const arch = process.env['BUILD_ARCH'] || os.arch(); + + if (platform === 'darwin' && arch === 'arm64') { + const identifier = [platform, 'arm64', abi.getAbi(process.versions.node, 'node')].filter(Boolean).join('-'); + return `sentry_cpu_profiler-${identifier}.node`; + } + + const identifier = [platform, arch, stdlib, abi.getAbi(process.versions.node, 'node')].filter(Boolean).join('-'); + + return `sentry_cpu_profiler-${identifier}.node`; +} + +const source = path.join(__dirname, '..', 'build', 'Release', 'sentry_cpu_profiler.node'); +const target = path.join(__dirname, '..', 'lib', getModuleName()); + +module.exports.source = source; +module.exports.target = target; +module.exports.getModuleName = getModuleName; diff --git a/packages/profiling-node/scripts/check-build.js b/packages/profiling-node/scripts/check-build.js new file mode 100644 index 000000000000..6892d90ba4b3 --- /dev/null +++ b/packages/profiling-node/scripts/check-build.js @@ -0,0 +1,56 @@ +// This is a build scripts, so some logging is desireable as it allows +// us to follow the code path that triggered the error. +/* eslint-disable no-console */ +const fs = require('fs'); +const child_process = require('child_process'); +const binaries = require('./binaries.js'); + +function clean(err) { + return err.toString().trim(); +} + +function recompileFromSource() { + console.log('@sentry/profiling-node: Compiling from source...'); + let spawn = child_process.spawnSync('npm', ['run', 'build:bindings:configure'], { + stdio: ['inherit', 'inherit', 'pipe'], + env: process.env, + shell: true, + }); + + if (spawn.status !== 0) { + console.log('@sentry/profiling-node: Failed to configure gyp'); + console.log('@sentry/profiling-node:', clean(spawn.stderr)); + return; + } + + spawn = child_process.spawnSync('npm', ['run', 'build:bindings'], { + stdio: ['inherit', 'inherit', 'pipe'], + env: process.env, + shell: true, + }); + if (spawn.status !== 0) { + console.log('@sentry/profiling-node: Failed to build bindings'); + console.log('@sentry/profiling-node:', clean(spawn.stderr)); + return; + } +} + +if (fs.existsSync(binaries.target)) { + try { + console.log(`@sentry/profiling-node: Precompiled binary found, attempting to load ${binaries.target}`); + require(binaries.target); + console.log('@sentry/profiling-node: Precompiled binary found, skipping build from source.'); + } catch (e) { + console.log('@sentry/profiling-node: Precompiled binary found but failed loading'); + console.log('@sentry/profiling-node:', e); + try { + recompileFromSource(); + } catch (e) { + console.log('@sentry/profiling-node: Failed to compile from source'); + throw e; + } + } +} else { + console.log('@sentry/profiling-node: No precompiled binary found'); + recompileFromSource(); +} diff --git a/packages/profiling-node/scripts/copy-target.js b/packages/profiling-node/scripts/copy-target.js new file mode 100644 index 000000000000..ee3b75163724 --- /dev/null +++ b/packages/profiling-node/scripts/copy-target.js @@ -0,0 +1,27 @@ +// This is a build scripts, so some logging is desireable as it allows +// us to follow the code path that triggered the error. +/* eslint-disable no-console */ +const fs = require('fs'); +const path = require('path'); +const process = require('process'); +const binaries = require('./binaries.js'); + +const build = path.resolve(__dirname, '..', 'lib'); + +if (!fs.existsSync(build)) { + fs.mkdirSync(build, { recursive: true }); +} + +const source = path.join(__dirname, '..', 'build', 'Release', 'sentry_cpu_profiler.node'); +const target = path.join(__dirname, '..', 'lib', binaries.getModuleName()); + +if (!fs.existsSync(source)) { + console.log('Source file does not exist:', source); + process.exit(1); +} else { + if (fs.existsSync(target)) { + console.log('Target file already exists, overwriting it'); + } + console.log('Renaming', source, 'to', target); + fs.renameSync(source, target); +} diff --git a/packages/profiling-node/scripts/prune-profiler-binaries.js b/packages/profiling-node/scripts/prune-profiler-binaries.js new file mode 100755 index 000000000000..925cedaee73a --- /dev/null +++ b/packages/profiling-node/scripts/prune-profiler-binaries.js @@ -0,0 +1,189 @@ +#! /usr/bin/env node + +// This is a build scripts, so some logging is desireable as it allows +// us to follow the code path that triggered the error. +/* eslint-disable no-console */ +const fs = require('fs'); + +let SOURCE_DIR, PLATFORM, ARCH, STDLIB, NODE, HELP; + +for (let i = 0; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg.startsWith('--target_dir_path=')) { + SOURCE_DIR = arg.split('=')[1]; + continue; + } + + if (arg.startsWith('--target_platform=')) { + PLATFORM = arg.split('=')[1]; + continue; + } + + if (arg.startsWith('--target_arch=')) { + ARCH = arg.split('=')[1]; + continue; + } + + if (arg.startsWith('--target_stdlib=')) { + STDLIB = arg.split('=')[1]; + continue; + } + + if (arg.startsWith('--target_node=')) { + NODE = arg.split('=')[1]; + continue; + } + + if (arg === '--help' || arg === '-h') { + HELP = true; + continue; + } +} + +if (HELP) { + console.log( + `\nSentry: Prune profiler binaries\n +Usage: sentry-prune-profiler-binaries --target_dir_path=... --target_platform=... --target_arch=... --target_stdlib=...\n +Arguments:\n +--target_dir_path: Path to the directory containing the final bundled code. If you are using webpack, this would be the equivalent of output.path option.\n +--target_node: The major node version the code will be running on. Example: 16, 18, 20...\n +--target_platform: The platform the code will be running on. Example: linux, darwin, win32\n +--target_arch: The architecture the code will be running on. Example: x64, arm64\n +--target_stdlib: The standard library the code will be running on. Example: glibc, musl\n +--dry-run: Do not delete any files, just print the files that would be deleted.\n +--help: Print this help message.\n`, + ); + process.exit(0); +} + +const ARGV_ERRORS = []; + +const NODE_TO_ABI = { + 16: '93', + 18: '108', + 20: '115', +}; + +if (NODE) { + if (NODE_TO_ABI[NODE]) { + NODE = NODE_TO_ABI[NODE]; + } else if (NODE.startsWith('16')) { + NODE = NODE_TO_ABI['16']; + } else if (NODE.startsWith('18')) { + NODE = NODE_TO_ABI['18']; + } else if (NODE.startsWith('20')) { + NODE = NODE_TO_ABI['20']; + } else { + ARGV_ERRORS.push( + '❌ Sentry: Invalid node version passed as argument, please make sure --target_node is a valid major node version. Supported versions are 16, 18 and 20.', + ); + } +} + +if (!SOURCE_DIR) { + ARGV_ERRORS.push( + '❌ Sentry: Missing target_dir_path argument. target_dir_path should point to the directory containing the final bundled code. If you are using webpack, this would be the equivalent of output.path option.', + ); +} + +if (!PLATFORM && !ARCH && !STDLIB) { + ARGV_ERRORS.push( + `❌ Sentry: Missing argument values, pruning requires either --target_platform, --target_arch or --targer_stdlib to be passed as argument values.\n Example: sentry-prune-profiler-binaries --target_platform=linux --target_arch=x64 --target_stdlib=glibc\n +If you are unsure about the execution environment, you can opt to skip some values, but at least one value must be passed.`, + ); +} + +if (ARGV_ERRORS.length > 0) { + console.log(ARGV_ERRORS.join('\n')); + process.exit(1); +} + +const SENTRY__PROFILER_BIN_REGEXP = /sentry_cpu_profiler-.*\.node$/; + +async function findSentryProfilerBinaries(source_dir) { + const binaries = new Set(); + const queue = [source_dir]; + + while (queue.length > 0) { + const dir = queue.pop(); + + for (const file of fs.readdirSync(dir)) { + if (SENTRY__PROFILER_BIN_REGEXP.test(file)) { + binaries.add(`${dir}/${file}`); + continue; + } + + if (fs.statSync(`${dir}/${file}`).isDirectory()) { + if (file === 'node_modules') { + continue; + } + + queue.push(`${dir}/${file}`); + } + } + } + + return binaries; +} + +function bytesToHumanReadable(bytes) { + if (bytes < 1024) { + return `${bytes} Bytes`; + } else if (bytes < 1048576) { + return `${(bytes / 1024).toFixed(2)} KiB`; + } else { + return `${(bytes / 1048576).toFixed(2)} MiB`; + } +} + +async function prune(binaries) { + let bytesSaved = 0; + let removedBinariesCount = 0; + + const conditions = [PLATFORM, ARCH, STDLIB, NODE].filter(n => !!n); + + for (const binary of binaries) { + if (conditions.every(condition => binary.includes(condition))) { + continue; + } + + const stats = fs.statSync(binary); + bytesSaved += stats.size; + removedBinariesCount++; + + if (process.argv.includes('--dry-run')) { + console.log(`Sentry: would have pruned ${binary} (${bytesToHumanReadable(stats.size)})`); + continue; + } + + console.log(`Sentry: pruned ${binary} (${bytesToHumanReadable(stats.size)})`); + fs.unlinkSync(binary); + } + + if (removedBinariesCount === 0) { + console.log( + '❌ Sentry: no binaries pruned, please make sure target argument values are valid or use --help for more information.', + ); + return; + } + + if (process.argv.includes('--dry-run')) { + console.log( + `✅ Sentry: would have pruned ${removedBinariesCount} ${ + removedBinariesCount === 1 ? 'binary' : 'binaries' + } and saved ${bytesToHumanReadable(bytesSaved)}.`, + ); + return; + } + + console.log( + `✅ Sentry: pruned ${removedBinariesCount} ${ + removedBinariesCount === 1 ? 'binary' : 'binaries' + }, saved ${bytesToHumanReadable(bytesSaved)} in total.`, + ); +} + +(async () => { + const binaries = await findSentryProfilerBinaries(SOURCE_DIR); + await prune(binaries); +})(); diff --git a/packages/profiling-node/src/cpu_profiler.ts b/packages/profiling-node/src/cpu_profiler.ts new file mode 100644 index 000000000000..e4ee11fee6a4 --- /dev/null +++ b/packages/profiling-node/src/cpu_profiler.ts @@ -0,0 +1,154 @@ +import { arch as _arch, platform as _platform } from 'os'; +import { join, resolve } from 'path'; +import { familySync } from 'detect-libc'; +import { getAbi } from 'node-abi'; +import { env, versions } from 'process'; +import { threadId } from 'worker_threads'; + +import { GLOBAL_OBJ, logger } from '@sentry/utils'; +import { DEBUG_BUILD } from './debug-build'; +import type { PrivateV8CpuProfilerBindings, V8CpuProfilerBindings } from './types'; + +const stdlib = familySync(); +const platform = process.env['BUILD_PLATFORM'] || _platform(); +const arch = process.env['BUILD_ARCH'] || _arch(); +const abi = getAbi(versions.node, 'node'); +const identifier = [platform, arch, stdlib, abi].filter(c => c !== undefined && c !== null).join('-'); + +const built_from_source_path = resolve(__dirname, `./sentry_cpu_profiler-${identifier}`); + +/** + * Imports cpp bindings based on the current platform and architecture. + */ +// eslint-disable-next-line complexity +export function importCppBindingsModule(): PrivateV8CpuProfilerBindings { + // If a binary path is specified, use that. + if (env['SENTRY_PROFILER_BINARY_PATH']) { + const envPath = env['SENTRY_PROFILER_BINARY_PATH']; + return require(envPath); + } + + // If a user specifies a different binary dir, they are in control of the binaries being moved there + if (env['SENTRY_PROFILER_BINARY_DIR']) { + const binaryPath = join(resolve(env['SENTRY_PROFILER_BINARY_DIR']), `sentry_cpu_profiler-${identifier}`); + return require(`${binaryPath}.node`); + } + + /* eslint-disable no-fallthrough */ + // We need the fallthrough so that in the end, we can fallback to the require dynamice require. + // This is for cases where precompiled binaries were not provided, but may have been compiled from source. + if (platform === 'darwin') { + if (arch === 'x64') { + if (abi === '93') { + return require('./sentry_cpu_profiler-darwin-x64-93.node'); + } + if (abi === '108') { + return require('./sentry_cpu_profiler-darwin-x64-108.node'); + } + if (abi === '115') { + return require('./sentry_cpu_profiler-darwin-x64-115.node'); + } + } + + if (arch === 'arm64') { + if (abi === '93') { + return require('./sentry_cpu_profiler-darwin-arm64-93.node'); + } + if (abi === '108') { + return require('./sentry_cpu_profiler-darwin-arm64-108.node'); + } + if (abi === '115') { + return require('./sentry_cpu_profiler-darwin-arm64-115.node'); + } + } + } + + if (platform === 'win32') { + if (arch === 'x64') { + if (abi === '93') { + return require('./sentry_cpu_profiler-win32-x64-93.node'); + } + if (abi === '108') { + return require('./sentry_cpu_profiler-win32-x64-108.node'); + } + if (abi === '115') { + return require('./sentry_cpu_profiler-win32-x64-115.node'); + } + } + } + + if (platform === 'linux') { + if (arch === 'x64') { + if (stdlib === 'musl') { + if (abi === '93') { + return require('./sentry_cpu_profiler-linux-x64-musl-93.node'); + } + if (abi === '108') { + return require('./sentry_cpu_profiler-linux-x64-musl-108.node'); + } + if (abi === '115') { + return require('./sentry_cpu_profiler-linux-x64-musl-115.node'); + } + } + if (stdlib === 'glibc') { + if (abi === '93') { + return require('./sentry_cpu_profiler-linux-x64-glibc-93.node'); + } + if (abi === '108') { + return require('./sentry_cpu_profiler-linux-x64-glibc-108.node'); + } + if (abi === '115') { + return require('./sentry_cpu_profiler-linux-x64-glibc-115.node'); + } + } + } + if (arch === 'arm64') { + if (stdlib === 'musl') { + if (abi === '93') { + return require('./sentry_cpu_profiler-linux-arm64-musl-93.node'); + } + if (abi === '108') { + return require('./sentry_cpu_profiler-linux-arm64-musl-108.node'); + } + if (abi === '115') { + return require('./sentry_cpu_profiler-linux-arm64-musl-115.node'); + } + } + if (stdlib === 'glibc') { + if (abi === '93') { + return require('./sentry_cpu_profiler-linux-arm64-glibc-93.node'); + } + if (abi === '108') { + return require('./sentry_cpu_profiler-linux-arm64-glibc-108.node'); + } + if (abi === '115') { + return require('./sentry_cpu_profiler-linux-arm64-glibc-115.node'); + } + } + } + } + return require(`${built_from_source_path}.node`); +} + +const PrivateCpuProfilerBindings: PrivateV8CpuProfilerBindings = importCppBindingsModule(); +const CpuProfilerBindings: V8CpuProfilerBindings = { + startProfiling(name: string) { + if (!PrivateCpuProfilerBindings) { + DEBUG_BUILD && logger.log('[Profiling] Bindings not loaded, ignoring call to startProfiling.'); + return; + } + + return PrivateCpuProfilerBindings.startProfiling(name); + }, + stopProfiling(name: string) { + if (!PrivateCpuProfilerBindings) { + DEBUG_BUILD && + logger.log('[Profiling] Bindings not loaded or profile was never started, ignoring call to stopProfiling.'); + return null; + } + return PrivateCpuProfilerBindings.stopProfiling(name, threadId, !!GLOBAL_OBJ._sentryDebugIds); + }, +}; + +export { PrivateCpuProfilerBindings }; +export { CpuProfilerBindings }; diff --git a/packages/profiling-node/src/debug-build.ts b/packages/profiling-node/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/profiling-node/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/profiling-node/src/hubextensions.ts b/packages/profiling-node/src/hubextensions.ts new file mode 100644 index 000000000000..66a43d3fcb28 --- /dev/null +++ b/packages/profiling-node/src/hubextensions.ts @@ -0,0 +1,253 @@ +import { getMainCarrier } from '@sentry/core'; +import type { NodeClient } from '@sentry/node'; +import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types'; +import { logger, uuid4 } from '@sentry/utils'; + +import { CpuProfilerBindings } from './cpu_profiler'; +import { DEBUG_BUILD } from './debug-build'; +import { isValidSampleRate } from './utils'; + +export const MAX_PROFILE_DURATION_MS = 30 * 1000; + +type StartTransaction = ( + this: Hub, + transactionContext: TransactionContext, + customSamplingContext?: CustomSamplingContext, +) => Transaction; + +/** + * Takes a transaction and determines if it should be profiled or not. If it should be profiled, it returns the + * profile_id, otherwise returns undefined. Takes care of setting profile context on transaction as well + */ +export function maybeProfileTransaction( + client: NodeClient | undefined, + transaction: Transaction, + customSamplingContext?: CustomSamplingContext, +): string | undefined { + // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform + // the actual multiplication to get the final rate, but we discard the profile if the transaction was sampled, + // so anything after this block from here is based on the transaction sampling. + // eslint-disable-next-line deprecation/deprecation + if (!transaction.sampled) { + return; + } + + // Client and options are required for profiling + if (!client) { + DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no client found.'); + return; + } + + const options = client.getOptions(); + if (!options) { + DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no options found.'); + return; + } + + const profilesSampler = options.profilesSampler; + let profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + + // Prefer sampler to sample rate if both are provided. + if (typeof profilesSampler === 'function') { + // eslint-disable-next-line deprecation/deprecation + profilesSampleRate = profilesSampler({ transactionContext: transaction.toContext(), ...customSamplingContext }); + } + + // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The + // only valid values are booleans or numbers between 0 and 1.) + if (!isValidSampleRate(profilesSampleRate)) { + DEBUG_BUILD && logger.warn('[Profiling] Discarding profile because of invalid sample rate.'); + return; + } + + // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped + if (!profilesSampleRate) { + DEBUG_BUILD && + logger.log( + `[Profiling] Discarding profile because ${ + typeof profilesSampler === 'function' + ? 'profileSampler returned 0 or false' + : 'a negative sampling decision was inherited or profileSampleRate is set to 0' + }`, + ); + return; + } + + // Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is + // a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false. + const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate; + // Check if we should sample this profile + if (!sampled) { + DEBUG_BUILD && + logger.log( + `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( + profilesSampleRate, + )})`, + ); + return; + } + + const profile_id = uuid4(); + CpuProfilerBindings.startProfiling(profile_id); + DEBUG_BUILD && + // eslint-disable-next-line deprecation/deprecation + logger.log(`[Profiling] started profiling transaction: ${transaction.name}`); + + // set transaction context - do this regardless if profiling fails down the line + // so that we can still see the profile_id in the transaction context + return profile_id; +} + +/** + * Stops the profiler for profile_id and returns the profile + * @param transaction + * @param profile_id + * @returns + */ +export function stopTransactionProfile( + transaction: Transaction, + profile_id: string | undefined, +): ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null { + // Should not happen, but satisfy the type checker and be safe regardless. + if (!profile_id) { + return null; + } + + const profile = CpuProfilerBindings.stopProfiling(profile_id); + + DEBUG_BUILD && + // eslint-disable-next-line deprecation/deprecation + logger.log(`[Profiling] stopped profiling of transaction: ${transaction.name}`); + + // In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile. + if (!profile) { + DEBUG_BUILD && + logger.log( + // eslint-disable-next-line deprecation/deprecation + `[Profiling] profiler returned null profile for: ${transaction.name}`, + 'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started', + ); + return null; + } + + // Assign profile_id to the profile + profile.profile_id = profile_id; + return profile; +} + +/** + * Wraps startTransaction and stopTransaction with profiling related logic. + * startProfiling is called after the call to startTransaction in order to avoid our own code from + * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. + */ +export function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction { + return function wrappedStartTransaction( + this: Hub, + transactionContext: TransactionContext, + customSamplingContext?: CustomSamplingContext, + ): Transaction { + const transaction: Transaction = startTransaction.call(this, transactionContext, customSamplingContext); + + // Client is required if we want to profile + // eslint-disable-next-line deprecation/deprecation + const client = this.getClient() as NodeClient | undefined; + if (!client) { + return transaction; + } + + // Check if we should profile this transaction. If a profile_id is returned, then profiling has been started. + const profile_id = maybeProfileTransaction(client, transaction, customSamplingContext); + if (!profile_id) { + return transaction; + } + + // A couple of important things to note here: + // `CpuProfilerBindings.stopProfiling` will be scheduled to run in 30seconds in order to exceed max profile duration. + // Whichever of the two (transaction.finish/timeout) is first to run, the profiling will be stopped and the gathered profile + // will be processed when the original transaction is finished. Since onProfileHandler can be invoked multiple times in the + // event of an error or user mistake (calling transaction.finish multiple times), it is important that the behavior of onProfileHandler + // is idempotent as we do not want any timings or profiles to be overriden by the last call to onProfileHandler. + // After the original finish method is called, the event will be reported through the integration and delegated to transport. + let profile: ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null = null; + + const options = client.getOptions(); + // Not intended for external use, hence missing types, but we want to profile a couple of things at Sentry that + // currently exceed the default timeout set by the SDKs. + const maxProfileDurationMs = + (options._experiments && options._experiments['maxProfileDurationMs']) || MAX_PROFILE_DURATION_MS; + + // Enqueue a timeout to prevent profiles from running over max duration. + let maxDurationTimeoutID: NodeJS.Timeout | void = global.setTimeout(() => { + DEBUG_BUILD && + // eslint-disable-next-line deprecation/deprecation + logger.log('[Profiling] max profile duration elapsed, stopping profiling for:', transaction.name); + + profile = stopTransactionProfile(transaction, profile_id); + }, maxProfileDurationMs); + + // We need to reference the original finish call to avoid creating an infinite loop + // eslint-disable-next-line deprecation/deprecation + const originalFinish = transaction.finish.bind(transaction); + + // Wrap the transaction finish method to stop profiling and set the profile on the transaction. + function profilingWrappedTransactionFinish(): void { + if (!profile_id) { + return originalFinish(); + } + + // We stop the handler first to ensure that the timeout is cleared and the profile is stopped. + if (maxDurationTimeoutID) { + global.clearTimeout(maxDurationTimeoutID); + maxDurationTimeoutID = undefined; + } + + // onProfileHandler should always return the same profile even if this is called multiple times. + // Always call onProfileHandler to ensure stopProfiling is called and the timeout is cleared. + if (!profile) { + profile = stopTransactionProfile(transaction, profile_id); + } + + // @ts-expect-error profile is not part of metadata + // eslint-disable-next-line deprecation/deprecation + transaction.setMetadata({ profile }); + return originalFinish(); + } + + // eslint-disable-next-line deprecation/deprecation + transaction.finish = profilingWrappedTransactionFinish; + return transaction; + }; +} + +/** + * Patches startTransaction and stopTransaction with profiling logic. + * This is used by the SDK's that do not support event hooks. + * @private + */ +function _addProfilingExtensionMethods(): void { + const carrier = getMainCarrier(); + if (!carrier.__SENTRY__) { + DEBUG_BUILD && logger.log("[Profiling] Can't find main carrier, profiling won't work."); + return; + } + + carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; + if (!carrier.__SENTRY__.extensions['startTransaction']) { + DEBUG_BUILD && logger.log('[Profiling] startTransaction does not exists, profiling will not work.'); + return; + } + + DEBUG_BUILD && logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...'); + + carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling( + // This is patched by sentry/tracing, we are going to re-patch it... + carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction, + ); +} + +/** + * This patches the global object and injects the Profiling extensions methods + */ +export function addProfilingExtensionMethods(): void { + _addProfilingExtensionMethods(); +} diff --git a/packages/profiling-node/src/index.ts b/packages/profiling-node/src/index.ts new file mode 100644 index 000000000000..fee7c526929d --- /dev/null +++ b/packages/profiling-node/src/index.ts @@ -0,0 +1 @@ +export { ProfilingIntegration } from './integration'; diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts new file mode 100644 index 000000000000..28bf24f0d784 --- /dev/null +++ b/packages/profiling-node/src/integration.ts @@ -0,0 +1,245 @@ +import type { NodeClient } from '@sentry/node'; +import type { Event, EventProcessor, Hub, Integration, Transaction } from '@sentry/types'; + +import { logger } from '@sentry/utils'; + +import { DEBUG_BUILD } from './debug-build'; +import { + MAX_PROFILE_DURATION_MS, + addProfilingExtensionMethods, + maybeProfileTransaction, + stopTransactionProfile, +} from './hubextensions'; +import type { Profile, RawThreadCpuProfile } from './types'; + +import { + addProfilesToEnvelope, + createProfilingEvent, + createProfilingEventEnvelope, + findProfiledTransactionsFromEnvelope, + isProfiledTransactionEvent, + maybeRemoveProfileFromSdkMetadata, +} from './utils'; + +const MAX_PROFILE_QUEUE_LENGTH = 50; +const PROFILE_QUEUE: RawThreadCpuProfile[] = []; +const PROFILE_TIMEOUTS: Record = {}; + +function addToProfileQueue(profile: RawThreadCpuProfile): void { + PROFILE_QUEUE.push(profile); + + // We only want to keep the last n profiles in the queue. + if (PROFILE_QUEUE.length > MAX_PROFILE_QUEUE_LENGTH) { + PROFILE_QUEUE.shift(); + } +} + +/** + * We need this integration in order to send data to Sentry. We hook into the event processor + * and inspect each event to see if it is a transaction event and if that transaction event + * contains a profile on it's metadata. If that is the case, we create a profiling event envelope + * and delete the profile from the transaction metadata. + */ +export class ProfilingIntegration implements Integration { + /** + * @inheritDoc + */ + public readonly name: string; + public getCurrentHub?: () => Hub; + + public constructor() { + this.name = 'ProfilingIntegration'; + } + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this.getCurrentHub = getCurrentHub; + // eslint-disable-next-line deprecation/deprecation + const client = this.getCurrentHub().getClient() as NodeClient; + + if (client && typeof client.on === 'function') { + client.on('startTransaction', (transaction: Transaction) => { + const profile_id = maybeProfileTransaction(client, transaction, undefined); + + if (profile_id) { + const options = client.getOptions(); + // Not intended for external use, hence missing types, but we want to profile a couple of things at Sentry that + // currently exceed the default timeout set by the SDKs. + const maxProfileDurationMs = + (options._experiments && options._experiments['maxProfileDurationMs']) || MAX_PROFILE_DURATION_MS; + + if (PROFILE_TIMEOUTS[profile_id]) { + global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete PROFILE_TIMEOUTS[profile_id]; + } + + // Enqueue a timeout to prevent profiles from running over max duration. + PROFILE_TIMEOUTS[profile_id] = global.setTimeout(() => { + DEBUG_BUILD && + // eslint-disable-next-line deprecation/deprecation + logger.log('[Profiling] max profile duration elapsed, stopping profiling for:', transaction.name); + + const profile = stopTransactionProfile(transaction, profile_id); + if (profile) { + addToProfileQueue(profile); + } + }, maxProfileDurationMs); + + // eslint-disable-next-line deprecation/deprecation + transaction.setContext('profile', { profile_id }); + // @ts-expect-error profile_id is not part of the metadata type + // eslint-disable-next-line deprecation/deprecation + transaction.setMetadata({ profile_id: profile_id }); + } + }); + + client.on('finishTransaction', transaction => { + // @ts-expect-error profile_id is not part of the metadata type + // eslint-disable-next-line deprecation/deprecation + const profile_id = transaction.metadata.profile_id; + if (profile_id && typeof profile_id === 'string') { + if (PROFILE_TIMEOUTS[profile_id]) { + global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete PROFILE_TIMEOUTS[profile_id]; + } + const profile = stopTransactionProfile(transaction, profile_id); + + if (profile) { + addToProfileQueue(profile); + } + } + }); + + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!PROFILE_QUEUE.length) { + return; + } + + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; + } + + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const profileContext = profiledTransaction.contexts?.['profile']; + const profile_id = profileContext?.['profile_id']; + + if (!profile_id) { + throw new TypeError('[Profiling] cannot find profile for a transaction without a profile context'); + } + + // Remove the profile from the transaction context before sending, relay will take care of the rest. + if (profileContext) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete profiledTransaction.contexts?.['profile']; + } + + // We need to find both a profile and a transaction event for the same profile_id. + const profileIndex = PROFILE_QUEUE.findIndex(p => p.profile_id === profile_id); + if (profileIndex === -1) { + DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + const cpuProfile = PROFILE_QUEUE[profileIndex]; + if (!cpuProfile) { + DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + // Remove the profile from the queue. + PROFILE_QUEUE.splice(profileIndex, 1); + const profile = createProfilingEvent(cpuProfile, profiledTransaction); + + if (client.emit && profile) { + const integrations = + client['_integrations'] && client['_integrations'] !== null && !Array.isArray(client['_integrations']) + ? Object.keys(client['_integrations']) + : undefined; + + // @ts-expect-error bad overload due to unknown event + client.emit('preprocessEvent', profile, { + event_id: profiledTransaction.event_id, + integrations, + }); + } + + if (profile) { + profilesToAddToEnvelope.push(profile); + } + } + + addProfilesToEnvelope(envelope, profilesToAddToEnvelope); + }); + } else { + // Patch the carrier methods and add the event processor. + addProfilingExtensionMethods(); + addGlobalEventProcessor(this.handleGlobalEvent.bind(this)); + } + } + + /** + * @inheritDoc + */ + public async handleGlobalEvent(event: Event): Promise { + if (this.getCurrentHub === undefined) { + return maybeRemoveProfileFromSdkMetadata(event); + } + + if (isProfiledTransactionEvent(event)) { + // Client, Dsn and Transport are all required to be able to send the profiling event to Sentry. + // If either of them is not available, we remove the profile from the transaction event. + // and forward it to the next event processor. + const hub = this.getCurrentHub(); + + // eslint-disable-next-line deprecation/deprecation + const client = hub.getClient(); + + if (!client) { + DEBUG_BUILD && + logger.log( + '[Profiling] getClient did not return a Client, removing profile from event and forwarding to next event processors.', + ); + return maybeRemoveProfileFromSdkMetadata(event); + } + + const dsn = client.getDsn(); + if (!dsn) { + DEBUG_BUILD && + logger.log( + '[Profiling] getDsn did not return a Dsn, removing profile from event and forwarding to next event processors.', + ); + return maybeRemoveProfileFromSdkMetadata(event); + } + + const transport = client.getTransport(); + if (!transport) { + DEBUG_BUILD && + logger.log( + '[Profiling] getTransport did not return a Transport, removing profile from event and forwarding to next event processors.', + ); + return maybeRemoveProfileFromSdkMetadata(event); + } + + // If all required components are available, we construct a profiling event envelope and send it to Sentry. + DEBUG_BUILD && logger.log('[Profiling] Preparing envelope and sending a profiling event'); + const envelope = createProfilingEventEnvelope(event, dsn); + + if (envelope) { + // Fire and forget, we don't want to block the main event processing flow. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + transport.send(envelope); + } + } + + // Ensure sdkProcessingMetadata["profile"] is removed from the event before forwarding it to the next event processor. + return maybeRemoveProfileFromSdkMetadata(event); + } +} diff --git a/packages/profiling-node/src/types.ts b/packages/profiling-node/src/types.ts new file mode 100644 index 000000000000..3042335269eb --- /dev/null +++ b/packages/profiling-node/src/types.ts @@ -0,0 +1,105 @@ +import type { Event } from '@sentry/types'; + +interface Sample { + stack_id: number; + thread_id: string; + elapsed_since_start_ns: string; +} + +type Stack = number[]; + +type Frame = { + function: string; + file: string; + lineno: number; + colno: number; +}; + +interface Measurement { + unit: string; + values: { + elapsed_since_start_ns: number; + value: number; + }[]; +} + +export interface DebugImage { + code_file: string; + type: string; + debug_id: string; + image_addr?: string; + image_size?: number; + image_vmaddr?: string; +} + +// Profile is marked as optional because it is deleted from the metadata +// by the integration before the event is processed by other integrations. +export interface ProfiledEvent extends Event { + sdkProcessingMetadata: { + profile?: RawThreadCpuProfile; + }; +} + +export interface RawThreadCpuProfile { + profile_id?: string; + stacks: ReadonlyArray; + samples: ReadonlyArray; + frames: ReadonlyArray; + resources: ReadonlyArray; + profiler_logging_mode: 'eager' | 'lazy'; + measurements: Record; +} +export interface ThreadCpuProfile { + stacks: ReadonlyArray; + samples: ReadonlyArray; + frames: ReadonlyArray; + thread_metadata: Record; + queue_metadata?: Record; +} + +export interface PrivateV8CpuProfilerBindings { + startProfiling(name: string): void; + stopProfiling(name: string, threadId: number, collectResources: boolean): RawThreadCpuProfile | null; + getFrameModule(abs_path: string): string; +} + +export interface V8CpuProfilerBindings { + startProfiling(name: string): void; + stopProfiling(name: string): RawThreadCpuProfile | null; +} + +export interface Profile { + event_id: string; + version: string; + os: { + name: string; + version: string; + build_number: string; + }; + runtime: { + name: string; + version: string; + }; + device: { + architecture: string; + is_emulator: boolean; + locale: string; + manufacturer: string; + model: string; + }; + timestamp: string; + release: string; + environment: string; + platform: string; + profile: ThreadCpuProfile; + debug_meta?: { + images: DebugImage[]; + }; + transaction: { + name: string; + id: string; + trace_id: string; + active_thread_id: string; + }; + measurements: Record; +} diff --git a/packages/profiling-node/src/utils.ts b/packages/profiling-node/src/utils.ts new file mode 100644 index 000000000000..1e34fbfd8974 --- /dev/null +++ b/packages/profiling-node/src/utils.ts @@ -0,0 +1,513 @@ +/* eslint-disable max-lines */ +import * as os from 'os'; +import type { + Context, + DsnComponents, + DynamicSamplingContext, + Envelope, + Event, + EventEnvelope, + EventEnvelopeHeaders, + EventItem, + SdkInfo, + SdkMetadata, + StackFrame, + StackParser, +} from '@sentry/types'; +import { env, versions } from 'process'; +import { isMainThread, threadId } from 'worker_threads'; + +import * as Sentry from '@sentry/node'; +import { GLOBAL_OBJ, createEnvelope, dropUndefinedKeys, dsnToString, forEachEnvelopeItem, logger } from '@sentry/utils'; + +import { DEBUG_BUILD } from './debug-build'; +import type { Profile, ProfiledEvent, RawThreadCpuProfile, ThreadCpuProfile } from './types'; +import type { DebugImage } from './types'; + +// We require the file because if we import it, it will be included in the bundle. +// I guess tsc does not check file contents when it's imported. +// eslint-disable-next-line +const THREAD_ID_STRING = String(threadId); +const THREAD_NAME = isMainThread ? 'main' : 'worker'; +const FORMAT_VERSION = '1'; + +// Os machine was backported to 16.18, but this was not reflected in the types +// @ts-expect-error ignore missing +const machine = typeof os.machine === 'function' ? os.machine() : os.arch(); + +// Machine properties (eval only once) +const PLATFORM = os.platform(); +const RELEASE = os.release(); +const VERSION = os.version(); +const TYPE = os.type(); +const MODEL = machine; +const ARCH = os.arch(); + +/** + * Checks if the profile is a raw profile or a profile enriched with thread information. + * @param {ThreadCpuProfile | RawThreadCpuProfile} profile + * @returns {boolean} + */ +function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): profile is RawThreadCpuProfile { + return !('thread_metadata' in profile); +} + +/** + * Enriches the profile with threadId of the current thread. + * This is done in node as we seem to not be able to get the info from C native code. + * + * @param {ThreadCpuProfile | RawThreadCpuProfile} profile + * @returns {ThreadCpuProfile} + */ +export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThreadCpuProfile): ThreadCpuProfile { + if (!isRawThreadCpuProfile(profile)) { + return profile; + } + + return { + samples: profile.samples, + frames: profile.frames, + stacks: profile.stacks, + thread_metadata: { + [THREAD_ID_STRING]: { + name: THREAD_NAME, + }, + }, + }; +} + +/** + * Extract sdk info from from the API metadata + * @param {SdkMetadata | undefined} metadata + * @returns {SdkInfo | undefined} + */ +function getSdkMetadataForEnvelopeHeader(metadata?: SdkMetadata): SdkInfo | undefined { + if (!metadata || !metadata.sdk) { + return undefined; + } + + return { name: metadata.sdk.name, version: metadata.sdk.version } as SdkInfo; +} + +/** + * Apply SdkInfo (name, version, packages, integrations) to the corresponding event key. + * Merge with existing data if any. + * + * @param {Event} event + * @param {SdkInfo | undefined} sdkInfo + * @returns {Event} + */ +function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { + if (!sdkInfo) { + return event; + } + event.sdk = event.sdk || {}; + event.sdk.name = event.sdk.name || sdkInfo.name || 'unknown sdk'; + event.sdk.version = event.sdk.version || sdkInfo.version || 'unknown sdk version'; + event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])]; + event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])]; + return event; +} + +/** + * + * @param {Event} event + * @param {SdkInfo | undefined} sdkInfo + * @param {string | undefined} tunnel + * @param {DsnComponents} dsn + * @returns {EventEnvelopeHeaders} + */ +function createEventEnvelopeHeaders( + event: Event, + sdkInfo: SdkInfo | undefined, + tunnel: string | undefined, + dsn: DsnComponents, +): EventEnvelopeHeaders { + const dynamicSamplingContext = event.sdkProcessingMetadata && event.sdkProcessingMetadata['dynamicSamplingContext']; + + return { + event_id: event.event_id as string, + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && { dsn: dsnToString(dsn) }), + ...(event.type === 'transaction' && + dynamicSamplingContext && { + trace: dropUndefinedKeys({ ...dynamicSamplingContext }) as DynamicSamplingContext, + }), + }; +} + +/** + * Creates a profiling event envelope from a Sentry event. If profile does not pass + * validation, returns null. + * @param {Event} + * @returns {Profile | null} + */ +export function createProfilingEventFromTransaction(event: ProfiledEvent): Profile | null { + if (event.type !== 'transaction') { + // createProfilingEventEnvelope should only be called for transactions, + // we type guard this behavior with isProfiledTransactionEvent. + throw new TypeError('Profiling events may only be attached to transactions, this should never occur.'); + } + + const rawProfile = event.sdkProcessingMetadata['profile']; + if (rawProfile === undefined || rawProfile === null) { + throw new TypeError( + `Cannot construct profiling event envelope without a valid profile. Got ${rawProfile} instead.`, + ); + } + + if (!rawProfile.profile_id) { + throw new TypeError( + `Cannot construct profiling event envelope without a valid profile id. Got ${rawProfile.profile_id} instead.`, + ); + } + + if (!isValidProfile(rawProfile)) { + return null; + } + + return createProfilePayload(rawProfile, { + release: event.release ?? '', + environment: event.environment ?? '', + event_id: event.event_id ?? '', + transaction: event.transaction ?? '', + start_timestamp: event.start_timestamp ? event.start_timestamp * 1000 : Date.now(), + trace_id: event.contexts?.['trace']?.['trace_id'] ?? '', + profile_id: rawProfile.profile_id, + }); +} + +/** + * Creates a profiling envelope item, if the profile does not pass validation, returns null. + * @param {RawThreadCpuProfile} + * @param {Event} + * @returns {Profile | null} + */ +export function createProfilingEvent(profile: RawThreadCpuProfile, event: Event): Profile | null { + if (!isValidProfile(profile)) { + return null; + } + + return createProfilePayload(profile, { + release: event.release ?? '', + environment: event.environment ?? '', + event_id: event.event_id ?? '', + transaction: event.transaction ?? '', + start_timestamp: event.start_timestamp ? event.start_timestamp * 1000 : Date.now(), + trace_id: event.contexts?.['trace']?.['trace_id'] ?? '', + profile_id: profile.profile_id, + }); +} + +/** + * Create a profile + * @param {RawThreadCpuProfile} cpuProfile + * @param {options} + * @returns {Profile} + */ + +function createProfilePayload( + cpuProfile: RawThreadCpuProfile, + { + release, + environment, + event_id, + transaction, + start_timestamp, + trace_id, + profile_id, + }: { + release: string; + environment: string; + event_id: string; + transaction: string; + start_timestamp: number; + trace_id: string | undefined; + profile_id: string; + }, +): Profile { + // Log a warning if the profile has an invalid traceId (should be uuidv4). + // All profiles and transactions are rejected if this is the case and we want to + // warn users that this is happening if they enable debug flag + if (trace_id && trace_id.length !== 32) { + DEBUG_BUILD && logger.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); + } + + const enrichedThreadProfile = enrichWithThreadInformation(cpuProfile); + + const profile: Profile = { + event_id: profile_id, + timestamp: new Date(start_timestamp).toISOString(), + platform: 'node', + version: FORMAT_VERSION, + release: release, + environment: environment, + measurements: cpuProfile.measurements, + runtime: { + name: 'node', + version: versions.node || '', + }, + os: { + name: PLATFORM, + version: RELEASE, + build_number: VERSION, + }, + device: { + locale: env['LC_ALL'] || env['LC_MESSAGES'] || env['LANG'] || env['LANGUAGE'] || '', + model: MODEL, + manufacturer: TYPE, + architecture: ARCH, + is_emulator: false, + }, + debug_meta: { + images: applyDebugMetadata(cpuProfile.resources), + }, + profile: enrichedThreadProfile, + transaction: { + name: transaction, + id: event_id, + trace_id: trace_id || '', + active_thread_id: THREAD_ID_STRING, + }, + }; + + return profile; +} + +/** + * Creates an envelope from a profiling event. + * @param {Event} Profile + * @param {DsnComponents} dsn + * @param {SdkMetadata} metadata + * @param {string|undefined} tunnel + * @returns {Envelope|null} + */ +export function createProfilingEventEnvelope( + event: ProfiledEvent, + dsn: DsnComponents, + metadata?: SdkMetadata, + tunnel?: string, +): EventEnvelope | null { + const sdkInfo = getSdkMetadataForEnvelopeHeader(metadata); + enhanceEventWithSdkInfo(event, metadata && metadata.sdk); + + const envelopeHeaders = createEventEnvelopeHeaders(event, sdkInfo, tunnel, dsn); + const profile = createProfilingEventFromTransaction(event); + + if (!profile) { + return null; + } + + const envelopeItem: EventItem = [ + { + type: 'profile', + }, + // @ts-expect-error profile is not part of EventItem yet + profile, + ]; + + return createEnvelope(envelopeHeaders, [envelopeItem]); +} + +/** + * Check if event metadata contains profile information + * @param {Event} + * @returns {boolean} + */ +export function isProfiledTransactionEvent(event: Event): event is ProfiledEvent { + return !!(event.sdkProcessingMetadata && event.sdkProcessingMetadata['profile']); +} + +/** + * Due to how profiles are attached to event metadata, we may sometimes want to remove them to ensure + * they are not processed by other Sentry integrations. This can be the case when we cannot construct a valid + * profile from the data we have or some of the mechanisms to send the event (Hub, Transport etc) are not available to us. + * + * @param {Event | ProfiledEvent} event + * @returns {Event} + */ +export function maybeRemoveProfileFromSdkMetadata(event: Event | ProfiledEvent): Event { + if (!isProfiledTransactionEvent(event)) { + return event; + } + + delete event.sdkProcessingMetadata.profile; + return event; +} + +/** + * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). + * @param {unknown} rate + * @returns {boolean} + */ +export function isValidSampleRate(rate: unknown): boolean { + // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck + if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) { + DEBUG_BUILD && + logger.warn( + `[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( + rate, + )} of type ${JSON.stringify(typeof rate)}.`, + ); + return false; + } + + // Boolean sample rates are always valid + if (rate === true || rate === false) { + return true; + } + + // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false + if (rate < 0 || rate > 1) { + DEBUG_BUILD && logger.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`); + return false; + } + return true; +} + +/** + * Checks if the profile is valid and can be sent to Sentry. + * @param {RawThreadCpuProfile} profile + * @returns {boolean} + */ +export function isValidProfile(profile: RawThreadCpuProfile): profile is RawThreadCpuProfile & { profile_id: string } { + if (profile.samples.length <= 1) { + DEBUG_BUILD && + // Log a warning if the profile has less than 2 samples so users can know why + // they are not seeing any profiling data and we cant avoid the back and forth + // of asking them to provide us with a dump of the profile data. + logger.log('[Profiling] Discarding profile because it contains less than 2 samples'); + return false; + } + + if (!profile.profile_id) { + return false; + } + + return true; +} + +/** + * Adds items to envelope if they are not already present - mutates the envelope. + * @param {Envelope} envelope + * @param {Profile[]} profiles + * @returns {Envelope} + */ +export function addProfilesToEnvelope(envelope: Envelope, profiles: Profile[]): Envelope { + if (!profiles.length) { + return envelope; + } + + for (const profile of profiles) { + // @ts-expect-error untyped envelope + envelope[1].push([{ type: 'profile' }, profile]); + } + return envelope; +} + +/** + * Finds transactions with profile_id context in the envelope + * @param {Envelope} envelope + * @returns {Event[]} + */ +export function findProfiledTransactionsFromEnvelope(envelope: Envelope): Event[] { + const events: Event[] = []; + + forEachEnvelopeItem(envelope, (item, type) => { + if (type !== 'transaction') { + return; + } + + // First item is the type, so we can skip it, everything else is an event + for (let j = 1; j < item.length; j++) { + const event = item[j]; + + if (!event) { + // Shouldnt happen, but lets be safe + continue; + } + + // @ts-expect-error profile_id is not part of the metadata type + const profile_id = (event.contexts as Context)?.['profile']?.['profile_id']; + + if (event && profile_id) { + events.push(item[j] as Event); + } + } + }); + + return events; +} + +const debugIdStackParserCache = new WeakMap>(); + +/** + * Cross reference profile collected resources with debug_ids and return a list of debug images. + * @param {string[]} resource_paths + * @returns {DebugImage[]} + */ +export function applyDebugMetadata(resource_paths: ReadonlyArray): DebugImage[] { + const debugIdMap = GLOBAL_OBJ._sentryDebugIds; + + if (!debugIdMap) { + return []; + } + + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + const client = hub.getClient(); + const options = client && client.getOptions(); + + if (!options || !options.stackParser) { + return []; + } + + let debugIdStackFramesCache: Map; + const cachedDebugIdStackFrameCache = debugIdStackParserCache.get(options.stackParser); + if (cachedDebugIdStackFrameCache) { + debugIdStackFramesCache = cachedDebugIdStackFrameCache; + } else { + debugIdStackFramesCache = new Map(); + debugIdStackParserCache.set(options.stackParser, debugIdStackFramesCache); + } + + // Build a map of filename -> debug_id. + const filenameDebugIdMap = Object.keys(debugIdMap).reduce>((acc, debugIdStackTrace) => { + let parsedStack: StackFrame[]; + + const cachedParsedStack = debugIdStackFramesCache.get(debugIdStackTrace); + if (cachedParsedStack) { + parsedStack = cachedParsedStack; + } else { + parsedStack = options.stackParser(debugIdStackTrace); + debugIdStackFramesCache.set(debugIdStackTrace, parsedStack); + } + + for (let i = parsedStack.length - 1; i >= 0; i--) { + const stackFrame = parsedStack[i]; + const file = stackFrame && stackFrame.filename; + + if (stackFrame && file) { + acc[file] = debugIdMap[debugIdStackTrace] as string; + break; + } + } + return acc; + }, {}); + + const images: DebugImage[] = []; + + for (const resource of resource_paths) { + if (resource && filenameDebugIdMap[resource]) { + images.push({ + type: 'sourcemap', + code_file: resource, + debug_id: filenameDebugIdMap[resource] as string, + }); + } + } + + return images; +} diff --git a/packages/profiling-node/test/bindings.test.ts b/packages/profiling-node/test/bindings.test.ts new file mode 100644 index 000000000000..c524a277bfa9 --- /dev/null +++ b/packages/profiling-node/test/bindings.test.ts @@ -0,0 +1,30 @@ +import { platform } from 'os'; +// Contains unit tests for some of the C++ bindings. These functions +// are exported on the private bindings object, so we can test them and +// they should not be used outside of this file. +import { PrivateCpuProfilerBindings } from '../src/cpu_profiler'; + +const cases = [ + ['/Users/jonas/code/node_modules/@scope/package/file.js', '@scope.package:file'], + ['/Users/jonas/code/node_modules/package/dir/file.js', 'package.dir:file'], + ['/Users/jonas/code/node_modules/package/file.js', 'package:file'], + ['/Users/jonas/code/src/file.js', 'Users.jonas.code.src:file'], + + // Preserves non .js extensions + ['/Users/jonas/code/src/file.ts', 'Users.jonas.code.src:file.ts'], + // No extension + ['/Users/jonas/code/src/file', 'Users.jonas.code.src:file'], + // Edge cases that shouldn't happen in practice, but try and handle them so we dont crash + ['/Users/jonas/code/src/file.js', 'Users.jonas.code.src:file'], + ['', ''], +]; + +describe('GetFrameModule', () => { + it.each( + platform() === 'win32' + ? cases.map(([abs_path, expected]) => [abs_path ? `C:${abs_path.replace(/\//g, '\\')}` : '', expected]) + : cases, + )('%s => %s', (abs_path: string, expected: string) => { + expect(PrivateCpuProfilerBindings.getFrameModule(abs_path)).toBe(expected); + }); +}); diff --git a/packages/profiling-node/test/cpu_profiler.test.ts b/packages/profiling-node/test/cpu_profiler.test.ts new file mode 100644 index 000000000000..8f66a91cb5ef --- /dev/null +++ b/packages/profiling-node/test/cpu_profiler.test.ts @@ -0,0 +1,302 @@ +import { CpuProfilerBindings, PrivateCpuProfilerBindings } from '../src/cpu_profiler'; +import type { RawThreadCpuProfile, ThreadCpuProfile } from '../src/types'; + +// Required because we test a hypothetical long profile +// and we cannot use advance timers as the c++ relies on +// actual event loop ticks that we cannot advance from jest. +jest.setTimeout(60_000); + +function fail(message: string): never { + throw new Error(message); +} + +const fibonacci = (n: number): number => { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +}; + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const profiled = async (name: string, fn: () => void) => { + CpuProfilerBindings.startProfiling(name); + await fn(); + return CpuProfilerBindings.stopProfiling(name); +}; + +const assertValidSamplesAndStacks = (stacks: ThreadCpuProfile['stacks'], samples: ThreadCpuProfile['samples']) => { + expect(stacks.length).toBeGreaterThan(0); + expect(samples.length).toBeGreaterThan(0); + expect(stacks.length <= samples.length).toBe(true); + + for (const sample of samples) { + if (sample.stack_id === undefined) { + throw new Error(`Sample ${JSON.stringify(sample)} has not stack id associated`); + } + if (!stacks[sample.stack_id]) { + throw new Error(`Failed to find stack for sample: ${JSON.stringify(sample)}`); + } + expect(stacks[sample.stack_id]).not.toBe(undefined); + } + + for (const stack of stacks) { + expect(stack).not.toBe(undefined); + } +}; + +const isValidMeasurementValue = (v: any) => { + if (isNaN(v)) return false; + return typeof v === 'number' && v > 0; +}; + +const assertValidMeasurements = (measurement: RawThreadCpuProfile['measurements']['memory_footprint'] | undefined) => { + if (!measurement) { + throw new Error('Measurement is undefined'); + } + expect(measurement).not.toBe(undefined); + expect(typeof measurement.unit).toBe('string'); + expect(measurement.unit.length).toBeGreaterThan(0); + + for (let i = 0; i < measurement.values.length; i++) { + expect(measurement?.values?.[i]?.elapsed_since_start_ns).toBeGreaterThan(0); + expect(measurement?.values?.[i]?.value).toBeGreaterThan(0); + } +}; + +describe('Private bindings', () => { + it('does not crash if collect resources is false', async () => { + PrivateCpuProfilerBindings.startProfiling('profiled-program'); + await wait(100); + expect(() => { + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, false); + if (!profile) throw new Error('No profile'); + }).not.toThrow(); + }); + + it('collects resources', async () => { + PrivateCpuProfilerBindings.startProfiling('profiled-program'); + await wait(100); + + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, true); + if (!profile) throw new Error('No profile'); + + expect(profile.resources.length).toBeGreaterThan(0); + + expect(new Set(profile.resources).size).toBe(profile.resources.length); + + for (const resource of profile.resources) { + expect(typeof resource).toBe('string'); + expect(resource).not.toBe(undefined); + } + }); + + it('does not collect resources', async () => { + PrivateCpuProfilerBindings.startProfiling('profiled-program'); + await wait(100); + + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, false); + if (!profile) throw new Error('No profile'); + + expect(profile.resources.length).toBe(0); + }); +}); + +describe('Profiler bindings', () => { + it('exports profiler binding methods', () => { + expect(typeof CpuProfilerBindings['startProfiling']).toBe('function'); + expect(typeof CpuProfilerBindings['stopProfiling']).toBe('function'); + }); + + it('profiles a program', async () => { + const profile = await profiled('profiled-program', async () => { + await wait(100); + }); + + if (!profile) fail('Profile is null'); + + assertValidSamplesAndStacks(profile.stacks, profile.samples); + }); + + it('adds thread_id info', async () => { + const profile = await profiled('profiled-program', async () => { + await wait(100); + }); + + if (!profile) fail('Profile is null'); + const samples = profile.samples; + + if (!samples.length) { + throw new Error('No samples'); + } + for (const sample of samples) { + expect(sample.thread_id).toBe('0'); + } + }); + + it('caps stack depth at 128', async () => { + const recurseToDepth = async (depth: number): Promise => { + if (depth === 0) { + // Wait a bit to make sure stack gets sampled here + await wait(1000); + return 0; + } + const v = await recurseToDepth(depth - 1); + return v; + }; + + const profile = await profiled('profiled-program', async () => { + await recurseToDepth(256); + }); + + if (!profile) fail('Profile is null'); + + for (const stack of profile.stacks) { + expect(stack.length).toBeLessThanOrEqual(128); + } + }); + + it('does not record two profiles when titles match', () => { + CpuProfilerBindings.startProfiling('same-title'); + CpuProfilerBindings.startProfiling('same-title'); + + const first = CpuProfilerBindings.stopProfiling('same-title'); + const second = CpuProfilerBindings.stopProfiling('same-title'); + + expect(first).not.toBe(null); + expect(second).toBe(null); + }); + + it('weird cases', () => { + CpuProfilerBindings.startProfiling('same-title'); + expect(() => { + CpuProfilerBindings.stopProfiling('same-title'); + CpuProfilerBindings.stopProfiling('same-title'); + }).not.toThrow(); + }); + + it('does not crash if stopTransaction is called before startTransaction', () => { + expect(CpuProfilerBindings.stopProfiling('does not exist')).toBe(null); + }); + + it('does crash if name is invalid', () => { + expect(() => CpuProfilerBindings.stopProfiling('')).toThrow(); + // @ts-expect-error test invalid input + expect(() => CpuProfilerBindings.stopProfiling(undefined)).toThrow(); + // @ts-expect-error test invalid input + expect(() => CpuProfilerBindings.stopProfiling(null)).toThrow(); + // @ts-expect-error test invalid input + expect(() => CpuProfilerBindings.stopProfiling({})).toThrow(); + }); + + it('does not throw if stopTransaction is called before startTransaction', () => { + expect(CpuProfilerBindings.stopProfiling('does not exist')).toBe(null); + expect(() => CpuProfilerBindings.stopProfiling('does not exist')).not.toThrow(); + }); + + it('compiles with eager logging by default', async () => { + const profile = await profiled('profiled-program', async () => { + await wait(100); + }); + + if (!profile) fail('Profile is null'); + expect(profile.profiler_logging_mode).toBe('eager'); + }); + + it('stacks are not null', async () => { + const profile = await profiled('non nullable stack', async () => { + await wait(1000); + fibonacci(36); + await wait(1000); + }); + + if (!profile) fail('Profile is null'); + assertValidSamplesAndStacks(profile.stacks, profile.samples); + }); + + it('samples at ~99hz', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(100); + const profile = CpuProfilerBindings.stopProfiling('profile'); + + if (!profile) fail('Profile is null'); + + // Exception for macos and windows - we seem to get way less samples there, but I'm not sure if that's due to poor + // performance of the actions runner, machine or something else. This needs more investigation to determine + // the cause of low sample count. https://github.com/actions/runner-images/issues/1336 seems relevant. + if (process.platform === 'darwin' || process.platform === 'win32') { + if (profile.samples.length < 2) { + fail(`Only ${profile.samples.length} samples obtained on ${process.platform}, expected at least 2`); + } + } else { + if (profile.samples.length < 6) { + fail(`Only ${profile.samples.length} samples obtained on ${process.platform}, expected at least 6`); + } + } + if (profile.samples.length > 15) { + fail(`Too many samples on ${process.platform}, got ${profile.samples.length}`); + } + }); + + it('collects memory footprint', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(1000); + const profile = CpuProfilerBindings.stopProfiling('profile'); + + const heap_usage = profile?.measurements['memory_footprint']; + if (!heap_usage) { + throw new Error('memory_footprint is null'); + } + expect(heap_usage.values.length).toBeGreaterThan(6); + expect(heap_usage.values.length).toBeLessThanOrEqual(11); + expect(heap_usage.unit).toBe('byte'); + expect(heap_usage.values.every(v => isValidMeasurementValue(v.value))).toBe(true); + assertValidMeasurements(profile.measurements['memory_footprint']); + }); + + it('collects cpu usage', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(1000); + const profile = CpuProfilerBindings.stopProfiling('profile'); + + const cpu_usage = profile?.measurements['cpu_usage']; + if (!cpu_usage) { + throw new Error('cpu_usage is null'); + } + expect(cpu_usage.values.length).toBeGreaterThan(6); + expect(cpu_usage.values.length).toBeLessThanOrEqual(11); + expect(cpu_usage.values.every(v => isValidMeasurementValue(v.value))).toBe(true); + expect(cpu_usage.unit).toBe('percent'); + assertValidMeasurements(profile.measurements['cpu_usage']); + }); + + it('does not overflow measurement buffer if profile runs longer than 30s', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(35000); + const profile = CpuProfilerBindings.stopProfiling('profile'); + expect(profile).not.toBe(null); + expect(profile?.measurements?.['cpu_usage']?.values.length).toBeLessThanOrEqual(300); + expect(profile?.measurements?.['memory_footprint']?.values.length).toBeLessThanOrEqual(300); + }); + + // eslint-disable-next-line jest/no-disabled-tests + it.skip('includes deopt reason', async () => { + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#52-the-object-being-iterated-is-not-a-simple-enumerable + function iterateOverLargeHashTable() { + const table: Record = {}; + for (let i = 0; i < 1e5; i++) { + table[i] = i; + } + // eslint-disable-next-line + for (const _ in table) { + } + } + + const profile = await profiled('profiled-program', async () => { + iterateOverLargeHashTable(); + }); + + // @ts-expect-error deopt reasons are disabled for now as we need to figure out the backend support + const hasDeoptimizedFrame = profile.frames.some(f => f.deopt_reasons && f.deopt_reasons.length > 0); + expect(hasDeoptimizedFrame).toBe(true); + }); +}); diff --git a/packages/profiling-node/test/hubextensions.hub.test.ts b/packages/profiling-node/test/hubextensions.hub.test.ts new file mode 100644 index 000000000000..954f3300ffca --- /dev/null +++ b/packages/profiling-node/test/hubextensions.hub.test.ts @@ -0,0 +1,481 @@ +import * as Sentry from '@sentry/node'; + +import { getMainCarrier } from '@sentry/core'; +import type { Transport } from '@sentry/types'; +import { GLOBAL_OBJ, createEnvelope, logger } from '@sentry/utils'; +import { CpuProfilerBindings } from '../src/cpu_profiler'; +import { ProfilingIntegration } from '../src/index'; + +function makeClientWithoutHooks(): [Sentry.NodeClient, Transport] { + const integration = new ProfilingIntegration(); + const transport = Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }); + const client = new Sentry.NodeClient({ + stackParser: Sentry.defaultStackParser, + tracesSampleRate: 1, + profilesSampleRate: 1, + debug: true, + environment: 'test-environment', + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + integrations: [integration], + transport: _opts => transport, + }); + // eslint-disable-next-line deprecation/deprecation + client.setupIntegrations = () => { + integration.setupOnce( + cb => { + // @ts-expect-error __SENTRY__ is a private property + getMainCarrier().__SENTRY__.globalEventProcessors = [cb]; + }, + // eslint-disable-next-line deprecation/deprecation + () => Sentry.getCurrentHub(), + ); + }; + // @ts-expect-error override private + client.on = undefined; + return [client, transport]; +} + +function makeClientWithHooks(): [Sentry.NodeClient, Transport] { + const integration = new ProfilingIntegration(); + const client = new Sentry.NodeClient({ + stackParser: Sentry.defaultStackParser, + tracesSampleRate: 1, + profilesSampleRate: 1, + debug: true, + environment: 'test-environment', + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + integrations: [integration], + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + }); + + // eslint-disable-next-line deprecation/deprecation + client.setupIntegrations = () => { + integration.setupOnce( + cb => { + // @ts-expect-error __SENTRY__ is a private property + getMainCarrier().__SENTRY__.globalEventProcessors = [cb]; + }, + // eslint-disable-next-line deprecation/deprecation + () => Sentry.getCurrentHub(), + ); + }; + + return [client, client.getTransport() as Transport]; +} + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('hubextensions', () => { + beforeEach(() => { + jest.useRealTimers(); + // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited + getMainCarrier().__SENTRY__ = {}; + GLOBAL_OBJ._sentryDebugIds = undefined as any; + }); + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + delete getMainCarrier().__SENTRY__; + }); + + it('pulls environment from sdk init', async () => { + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve()); + + // eslint-disable-next-line deprecation/deprecation + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).toMatchObject({ environment: 'test-environment' }); + }); + + it('logger warns user if there are insufficient samples and discards the profile', async () => { + const logSpy = jest.spyOn(logger, 'log'); + + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { + return { + samples: [ + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + ], + measurements: {}, + stacks: [[0]], + frames: [], + resources: [], + profiler_logging_mode: 'lazy', + }; + }); + + jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve()); + + // eslint-disable-next-line deprecation/deprecation + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub' }); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + expect(logSpy.mock?.calls[logSpy.mock.calls.length - 1]?.[0]).toBe( + '[Profiling] Discarding profile because it contains less than 2 samples', + ); + + expect((transport.send as any).mock.calls[0][0][1][0][0].type).toBe('transaction'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transport.send).toHaveBeenCalledTimes(1); + }); + + it('logger warns user if traceId is invalid', async () => { + const logSpy = jest.spyOn(logger, 'log'); + + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { + return { + samples: [ + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + ], + measurements: {}, + resources: [], + stacks: [[0]], + frames: [], + profiler_logging_mode: 'lazy', + }; + }); + + jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve()); + + // eslint-disable-next-line deprecation/deprecation + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub', traceId: 'boop' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + expect(logSpy.mock?.calls?.[6]?.[0]).toBe('[Profiling] Invalid traceId: ' + 'boop' + ' on profiled event'); + }); + + describe('with hooks', () => { + it('calls profiler when transaction is started/stopped', async () => { + const [client, transport] = makeClientWithHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve()); + + // eslint-disable-next-line deprecation/deprecation + const transaction = hub.startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + expect((stopProfilingSpy.mock.calls[stopProfilingSpy.mock.calls.length - 1]?.[0] as string).length).toBe(32); + }); + + it('sends profile in the same envelope as transaction', async () => { + const [client, transport] = makeClientWithHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve()); + + // eslint-disable-next-line deprecation/deprecation + const transaction = hub.startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + // One for profile, the other for transaction + expect(transportSpy).toHaveBeenCalledTimes(1); + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[1]?.[0]).toMatchObject({ type: 'profile' }); + }); + + it('does not crash if transaction has no profile context or it is invalid', async () => { + const [client] = makeClientWithHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + // @ts-expect-error transaction is partial + client.emit('beforeEnvelope', createEnvelope({ type: 'transaction' }, { type: 'transaction' })); + // @ts-expect-error transaction is partial + client.emit('beforeEnvelope', createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: {} })); + client.emit( + 'beforeEnvelope', + // @ts-expect-error transaction is partial + createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: { profile: {} } }), + ); + client.emit( + 'beforeEnvelope', + // @ts-expect-error transaction is partial + createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: { profile: { profile_id: null } } }), + ); + + // Emit is sync, so we can just assert that we got here + expect(true).toBe(true); + }); + + it('if transaction was profiled, but profiler returned null', async () => { + const [client, transport] = makeClientWithHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockReturnValue(null); + // Emit is sync, so we can just assert that we got here + const transportSpy = jest.spyOn(transport, 'send').mockImplementation(() => { + // Do nothing so we don't send events to Sentry + return Promise.resolve(); + }); + + // eslint-disable-next-line deprecation/deprecation + const transaction = hub.startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + // Only transaction is sent + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ type: 'transaction' }); + expect(transportSpy.mock.calls?.[0]?.[0]?.[1][1]).toBeUndefined(); + }); + + it('emits preprocessEvent for profile', async () => { + const [client] = makeClientWithHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + const onPreprocessEvent = jest.fn(); + + client.on('preprocessEvent', onPreprocessEvent); + + // eslint-disable-next-line deprecation/deprecation + const transaction = hub.startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + expect(onPreprocessEvent.mock.calls[1][0]).toMatchObject({ + profile: { + samples: expect.arrayContaining([expect.anything()]), + stacks: expect.arrayContaining([expect.anything()]), + }, + }); + }); + }); + + describe('without hooks', () => { + it('calls profiler when transaction is started/stopped', async () => { + const [client] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + // eslint-disable-next-line deprecation/deprecation + const transaction = hub.startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + expect((stopProfilingSpy.mock.calls[startProfilingSpy.mock.calls.length - 1]?.[0] as string).length).toBe(32); + }); + + it('sends profile in separate envelope', async () => { + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + const transportSpy = jest.spyOn(transport, 'send').mockImplementation(() => { + // Do nothing so we don't send events to Sentry + return Promise.resolve(); + }); + + // eslint-disable-next-line deprecation/deprecation + const transaction = hub.startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + // One for profile, the other for transaction + expect(transportSpy).toHaveBeenCalledTimes(2); + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ type: 'profile' }); + }); + + it('respect max profile duration timeout', async () => { + // it seems that in node 19 globals (or least part of them) are a readonly object + // so when useFakeTimers is called it throws an error because it cannot override + // a readonly property of performance on global object. Use legacyFakeTimers for now + jest.useFakeTimers('legacy'); + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + // eslint-disable-next-line deprecation/deprecation + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'timeout_transaction' }); + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(30001); + + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + expect((stopProfilingSpy.mock.calls[startProfilingSpy.mock.calls.length - 1]?.[0] as string).length).toBe(32); + + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('does not crash if stop is called multiple times', async () => { + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + // eslint-disable-next-line deprecation/deprecation + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'txn' }); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('enriches profile with debug_id', async () => { + GLOBAL_OBJ._sentryDebugIds = { + 'Error\n at filename.js (filename.js:36:15)': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + 'Error\n at filename2.js (filename2.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + 'Error\n at filename3.js (filename3.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }; + + jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { + return { + samples: [ + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + ], + measurements: {}, + resources: ['filename.js', 'filename2.js'], + stacks: [[0]], + frames: [], + profiler_logging_mode: 'lazy', + }; + }); + + const [client, transport] = makeClientWithHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve()); + + // eslint-disable-next-line deprecation/deprecation + const transaction = hub.startTransaction({ name: 'profile_hub' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(1000); + + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[1]?.[1]).toMatchObject({ + debug_meta: { + images: [ + { + type: 'sourcemap', + debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + code_file: 'filename.js', + }, + { + type: 'sourcemap', + debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + code_file: 'filename2.js', + }, + ], + }, + }); + }); +}); diff --git a/packages/profiling-node/test/hubextensions.test.ts b/packages/profiling-node/test/hubextensions.test.ts new file mode 100644 index 000000000000..df90200c2a5d --- /dev/null +++ b/packages/profiling-node/test/hubextensions.test.ts @@ -0,0 +1,242 @@ +import type { + BaseTransportOptions, + ClientOptions, + Context, + Hub, + Transaction, + TransactionMetadata, +} from '@sentry/types'; + +import type { NodeClient } from '@sentry/node'; + +import { CpuProfilerBindings } from '../src/cpu_profiler'; +import { __PRIVATE__wrapStartTransactionWithProfiling } from '../src/hubextensions'; + +function makeTransactionMock(options = {}): Transaction { + return { + metadata: {}, + tags: {}, + sampled: true, + contexts: {}, + startChild: () => ({ finish: () => void 0 }), + finish() { + return; + }, + toContext: () => { + return {}; + }, + setContext(this: Transaction, key: string, context: Context) { + // @ts-expect-error - contexts is private + this.contexts[key] = context; + }, + setTag(this: Transaction, key: string, value: any) { + // eslint-disable-next-line deprecation/deprecation + this.tags[key] = value; + }, + setMetadata(this: Transaction, metadata: Partial) { + // eslint-disable-next-line deprecation/deprecation + this.metadata = { ...metadata } as TransactionMetadata; + }, + ...options, + } as unknown as Transaction; +} + +function makeHubMock({ + profilesSampleRate, + client, +}: { + profilesSampleRate: number | undefined; + client?: Partial; +}): Hub { + return { + getClient: jest.fn().mockImplementation(() => { + return { + getOptions: jest.fn().mockImplementation(() => { + return { + profilesSampleRate, + } as unknown as ClientOptions; + }), + ...(client ?? {}), + }; + }), + } as unknown as Hub; +} + +describe('hubextensions', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + it('skips profiling if profilesSampleRate is not set (undefined)', () => { + const hub = makeHubMock({ profilesSampleRate: undefined }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); + transaction.finish(); + + expect(startTransaction).toHaveBeenCalledTimes(1); + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect((transaction.metadata as any)?.profile).toBeUndefined(); + }); + it('skips profiling if profilesSampleRate is set to 0', () => { + const hub = makeHubMock({ profilesSampleRate: 0 }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); + transaction.finish(); + + expect(startTransaction).toHaveBeenCalledTimes(1); + expect(startProfilingSpy).not.toHaveBeenCalled(); + + expect((transaction.metadata as any)?.profile).toBeUndefined(); + }); + it('skips profiling when random > sampleRate', () => { + const hub = makeHubMock({ profilesSampleRate: 0.5 }); + jest.spyOn(global.Math, 'random').mockReturnValue(1); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); + transaction.finish(); + + expect(startTransaction).toHaveBeenCalledTimes(1); + expect(startProfilingSpy).not.toHaveBeenCalled(); + + expect((transaction.metadata as any)?.profile).toBeUndefined(); + }); + it('starts the profiler', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const hub = makeHubMock({ profilesSampleRate: 1 }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); + transaction.finish(); + + expect(startTransaction).toHaveBeenCalledTimes(1); + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + + expect((transaction.metadata as any)?.profile).toBeDefined(); + }); + + it('does not start the profiler if transaction is sampled', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const hub = makeHubMock({ profilesSampleRate: 1 }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock({ sampled: false })); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); + transaction.finish(); + + expect(startTransaction).toHaveBeenCalledTimes(1); + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + expect(stopProfilingSpy).not.toHaveBeenCalledTimes(1); + }); + + it('disabled if neither profilesSampler and profilesSampleRate are not set', () => { + const hub = makeHubMock({ profilesSampleRate: undefined }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const samplingContext = { beep: 'boop' }; + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); + transaction.finish(); + + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('does not call startProfiling if profilesSampler returns invalid rate', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const options = { profilesSampler: jest.fn().mockReturnValue(NaN) }; + const hub = makeHubMock({ + profilesSampleRate: undefined, + client: { + // @ts-expect-error partial client + getOptions: () => options, + }, + }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const samplingContext = { beep: 'boop' }; + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); + transaction.finish(); + + expect(options.profilesSampler).toHaveBeenCalled(); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('does not call startProfiling if profilesSampleRate is invalid', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const options = { profilesSampler: jest.fn().mockReturnValue(NaN) }; + const hub = makeHubMock({ + profilesSampleRate: NaN, + client: { + // @ts-expect-error partial client + getOptions: () => options, + }, + }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const samplingContext = { beep: 'boop' }; + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); + transaction.finish(); + + expect(options.profilesSampler).toHaveBeenCalled(); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('calls profilesSampler with sampling context', () => { + const options = { profilesSampler: jest.fn() }; + const hub = makeHubMock({ + profilesSampleRate: undefined, + client: { + // @ts-expect-error partial client + getOptions: () => options, + }, + }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const samplingContext = { beep: 'boop' }; + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); + transaction.finish(); + + expect(options.profilesSampler).toHaveBeenCalledWith({ + ...samplingContext, + transactionContext: transaction.toContext(), + }); + }); + + it('prioritizes profilesSampler outcome over profilesSampleRate', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const options = { profilesSampler: jest.fn().mockReturnValue(1) }; + const hub = makeHubMock({ + profilesSampleRate: 0, + client: { + // @ts-expect-error partial client + getOptions: () => options, + }, + }); + const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); + + const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); + const samplingContext = { beep: 'boop' }; + const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); + transaction.finish(); + + expect(startProfilingSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/profiling-node/test/index.test.ts b/packages/profiling-node/test/index.test.ts new file mode 100644 index 000000000000..ab6aaebfb86a --- /dev/null +++ b/packages/profiling-node/test/index.test.ts @@ -0,0 +1,166 @@ +import * as Sentry from '@sentry/node'; +import type { Transport } from '@sentry/types'; + +import { getMainCarrier } from '@sentry/core'; +import { ProfilingIntegration } from '../src/index'; +import type { Profile } from '../src/types'; + +interface MockTransport extends Transport { + events: any[]; +} + +function makeStaticTransport(): MockTransport { + return { + events: [] as any[], + send: function (...args: any[]) { + this.events.push(args); + return Promise.resolve(); + }, + flush: function () { + return Promise.resolve(true); + }, + }; +} + +function makeClientWithoutHooks(): [Sentry.NodeClient, MockTransport] { + const integration = new ProfilingIntegration(); + const transport = makeStaticTransport(); + const client = new Sentry.NodeClient({ + stackParser: Sentry.defaultStackParser, + tracesSampleRate: 1, + profilesSampleRate: 1, + debug: true, + environment: 'test-environment', + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + integrations: [integration], + transport: () => transport, + }); + // eslint-disable-next-line deprecation/deprecation + client.setupIntegrations = () => { + integration.setupOnce( + cb => { + // @ts-expect-error __SENTRY__ is private + getMainCarrier().__SENTRY__.globalEventProcessors = [cb]; + }, + // eslint-disable-next-line deprecation/deprecation + () => Sentry.getCurrentHub(), + ); + }; + // @ts-expect-error override private property + client.on = undefined; + return [client, transport]; +} + +function findAllProfiles(transport: MockTransport): [any, Profile][] | null { + return transport?.events.filter(call => { + return call[0][1][0][0].type === 'profile'; + }); +} + +function findProfile(transport: MockTransport): Profile | null { + return ( + transport?.events.find(call => { + return call[0][1][0][0].type === 'profile'; + })?.[0][1][0][1] ?? null + ); +} + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('Sentry - Profiling', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited + getMainCarrier().__SENTRY__ = {}; + }); + afterEach(() => { + delete getMainCarrier().__SENTRY__; + }); + describe('without hooks', () => { + it('profiles a transaction', async () => { + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + // eslint-disable-next-line deprecation/deprecation + const transaction = Sentry.startTransaction({ name: 'title' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(500); + expect(findProfile(transport)).not.toBe(null); + }); + + it('can profile overlapping transactions', async () => { + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + // eslint-disable-next-line deprecation/deprecation + const t1 = Sentry.startTransaction({ name: 'outer' }); + // eslint-disable-next-line deprecation/deprecation + const t2 = Sentry.startTransaction({ name: 'inner' }); + await wait(500); + + // eslint-disable-next-line deprecation/deprecation + t2.finish(); + // eslint-disable-next-line deprecation/deprecation + t1.finish(); + + await Sentry.flush(500); + + expect(findAllProfiles(transport)?.[0]?.[0]?.[1]?.[0]?.[1].transaction.name).toBe('inner'); + expect(findAllProfiles(transport)?.[1]?.[0]?.[1]?.[0]?.[1].transaction.name).toBe('outer'); + expect(findAllProfiles(transport)).toHaveLength(2); + expect(findProfile(transport)).not.toBe(null); + }); + + it('does not discard overlapping transaction with same title', async () => { + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + // eslint-disable-next-line deprecation/deprecation + const t1 = Sentry.startTransaction({ name: 'same-title' }); + // eslint-disable-next-line deprecation/deprecation + const t2 = Sentry.startTransaction({ name: 'same-title' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + t2.finish(); + // eslint-disable-next-line deprecation/deprecation + t1.finish(); + + await Sentry.flush(500); + expect(findAllProfiles(transport)).toHaveLength(2); + expect(findProfile(transport)).not.toBe(null); + }); + + it('does not crash if finish is called multiple times', async () => { + const [client, transport] = makeClientWithoutHooks(); + // eslint-disable-next-line deprecation/deprecation + const hub = Sentry.getCurrentHub(); + // eslint-disable-next-line deprecation/deprecation + hub.bindClient(client); + + // eslint-disable-next-line deprecation/deprecation + const transaction = Sentry.startTransaction({ name: 'title' }); + await wait(500); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + // eslint-disable-next-line deprecation/deprecation + transaction.finish(); + + await Sentry.flush(500); + expect(findAllProfiles(transport)).toHaveLength(1); + expect(findProfile(transport)).not.toBe(null); + }); + }); +}); diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts new file mode 100644 index 000000000000..8f336600fa84 --- /dev/null +++ b/packages/profiling-node/test/integration.test.ts @@ -0,0 +1,272 @@ +import { EventEmitter } from 'events'; + +import type { Event, Hub, Transport } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { ProfilingIntegration } from '../src/integration'; +import type { ProfiledEvent } from '../src/types'; + +function assertCleanProfile(event: ProfiledEvent | Event): void { + expect(event.sdkProcessingMetadata?.profile).toBeUndefined(); +} + +function makeProfiledEvent(): ProfiledEvent { + return { + type: 'transaction', + sdkProcessingMetadata: { + profile: { + profile_id: 'id', + profiler_logging_mode: 'lazy', + samples: [ + { + elapsed_since_start_ns: '0', + thread_id: '0', + stack_id: 0, + }, + { + elapsed_since_start_ns: '1', + thread_id: '0', + stack_id: 0, + }, + ], + measurements: {}, + frames: [], + stacks: [], + resources: [], + }, + }, + }; +} + +describe('ProfilingIntegration', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('has a name', () => { + expect(new ProfilingIntegration().name).toBe('ProfilingIntegration'); + }); + + it('stores a reference to getCurrentHub', () => { + const integration = new ProfilingIntegration(); + + const getCurrentHub = jest.fn().mockImplementation(() => { + return { + getClient: jest.fn(), + }; + }); + const addGlobalEventProcessor = () => void 0; + + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + expect(integration.getCurrentHub).toBe(getCurrentHub); + }); + + describe('without hooks', () => { + it('does not call transporter if null profile is received', () => { + const transport: Transport = { + send: jest.fn().mockImplementation(() => Promise.resolve()), + flush: jest.fn().mockImplementation(() => Promise.resolve()), + }; + const integration = new ProfilingIntegration(); + + const getCurrentHub = jest.fn((): Hub => { + return { + getClient: () => { + return { + getOptions: () => { + return { + _metadata: {}, + }; + }, + getDsn: () => { + return {}; + }, + getTransport: () => transport, + }; + }, + } as Hub; + }); + const addGlobalEventProcessor = () => void 0; + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + + integration.handleGlobalEvent({ + type: 'transaction', + sdkProcessingMetadata: { + profile: null, + }, + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transport.send).not.toHaveBeenCalled(); + }); + + it('when Hub.getClient returns undefined', async () => { + const logSpy = jest.spyOn(logger, 'log'); + const integration = new ProfilingIntegration(); + + const getCurrentHub = jest.fn((): Hub => { + return { getClient: () => undefined } as Hub; + }); + const addGlobalEventProcessor = () => void 0; + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + + assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); + expect(logSpy).toHaveBeenCalledWith( + '[Profiling] getClient did not return a Client, removing profile from event and forwarding to next event processors.', + ); + }); + it('when getDsn returns undefined', async () => { + const logSpy = jest.spyOn(logger, 'log'); + const integration = new ProfilingIntegration(); + + const getCurrentHub = jest.fn((): Hub => { + return { + getClient: () => { + return { + getDsn: () => undefined, + }; + }, + } as Hub; + }); + const addGlobalEventProcessor = () => void 0; + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + + assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); + expect(logSpy).toHaveBeenCalledWith( + '[Profiling] getDsn did not return a Dsn, removing profile from event and forwarding to next event processors.', + ); + }); + it('when getTransport returns undefined', async () => { + const logSpy = jest.spyOn(logger, 'log'); + const integration = new ProfilingIntegration(); + + const getCurrentHub = jest.fn((): Hub => { + return { + getClient: () => { + return { + getDsn: () => { + return {}; + }, + getTransport: () => undefined, + }; + }, + } as Hub; + }); + const addGlobalEventProcessor = () => void 0; + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + + assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); + expect(logSpy).toHaveBeenCalledWith( + '[Profiling] getTransport did not return a Transport, removing profile from event and forwarding to next event processors.', + ); + }); + + it('sends profile to sentry', async () => { + const logSpy = jest.spyOn(logger, 'log'); + const transport: Transport = { + send: jest.fn().mockImplementation(() => Promise.resolve()), + flush: jest.fn().mockImplementation(() => Promise.resolve()), + }; + const integration = new ProfilingIntegration(); + + const getCurrentHub = jest.fn((): Hub => { + return { + getClient: () => { + return { + getOptions: () => { + return { + _metadata: {}, + }; + }, + getDsn: () => { + return {}; + }, + getTransport: () => transport, + }; + }, + } as Hub; + }); + const addGlobalEventProcessor = () => void 0; + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + + assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); + expect(logSpy.mock.calls?.[1]?.[0]).toBe('[Profiling] Preparing envelope and sending a profiling event'); + }); + }); + + describe('with SDK hooks', () => { + it('does not call transporter if null profile is received', () => { + const transport: Transport = { + send: jest.fn().mockImplementation(() => Promise.resolve()), + flush: jest.fn().mockImplementation(() => Promise.resolve()), + }; + const integration = new ProfilingIntegration(); + const emitter = new EventEmitter(); + + const getCurrentHub = jest.fn((): Hub => { + return { + getClient: () => { + return { + on: emitter.on.bind(emitter), + emit: emitter.emit.bind(emitter), + getOptions: () => { + return { + _metadata: {}, + }; + }, + getDsn: () => { + return {}; + }, + getTransport: () => transport, + } as any; + }, + } as Hub; + }); + + const addGlobalEventProcessor = () => void 0; + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transport.send).not.toHaveBeenCalled(); + }); + + it('binds to startTransaction, finishTransaction and beforeEnvelope', () => { + const transport: Transport = { + send: jest.fn().mockImplementation(() => Promise.resolve()), + flush: jest.fn().mockImplementation(() => Promise.resolve()), + }; + const integration = new ProfilingIntegration(); + const emitter = new EventEmitter(); + + const getCurrentHub = jest.fn((): Hub => { + return { + getClient: () => { + return { + on: emitter.on.bind(emitter), + emit: emitter.emit.bind(emitter), + getOptions: () => { + return { + _metadata: {}, + }; + }, + getDsn: () => { + return {}; + }, + getTransport: () => transport, + } as any; + }, + } as Hub; + }); + + const spy = jest.spyOn(emitter, 'on'); + + const addGlobalEventProcessor = jest.fn(); + integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock?.calls?.[0]?.[0]).toBe('startTransaction'); + expect(spy.mock?.calls?.[1]?.[0]).toBe('finishTransaction'); + expect(spy.mock?.calls?.[2]?.[0]).toBe('beforeEnvelope'); + + expect(addGlobalEventProcessor).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/profiling-node/test/utils.test.ts b/packages/profiling-node/test/utils.test.ts new file mode 100644 index 000000000000..640d0eace7f2 --- /dev/null +++ b/packages/profiling-node/test/utils.test.ts @@ -0,0 +1,361 @@ +import type { DsnComponents, Event, SdkMetadata } from '@sentry/types'; +import { addItemToEnvelope, createEnvelope, uuid4 } from '@sentry/utils'; + +import { + addProfilesToEnvelope, + findProfiledTransactionsFromEnvelope, + isValidProfile, + isValidSampleRate, +} from '../src/utils'; + +import type { Profile, ProfiledEvent } from '../src/types'; +import { + createProfilingEventEnvelope, + isProfiledTransactionEvent, + maybeRemoveProfileFromSdkMetadata, +} from '../src/utils'; + +function makeSdkMetadata(props: Partial): SdkMetadata { + return { + sdk: { + ...props, + }, + }; +} + +function makeDsn(props: Partial): DsnComponents { + return { + protocol: 'http', + projectId: '1', + host: 'localhost', + ...props, + }; +} + +function makeEvent( + props: Partial, + profile: NonNullable, +): ProfiledEvent { + return { ...props, sdkProcessingMetadata: { profile: profile } }; +} + +function makeProfile( + props: Partial, +): NonNullable { + return { + profile_id: '1', + profiler_logging_mode: 'lazy', + stacks: [], + samples: [ + { elapsed_since_start_ns: '0', thread_id: '0', stack_id: 0 }, + { elapsed_since_start_ns: '10', thread_id: '0', stack_id: 0 }, + ], + measurements: {}, + resources: [], + frames: [], + ...props, + }; +} + +describe('isProfiledTransactionEvent', () => { + it('profiled event', () => { + expect(isProfiledTransactionEvent({ sdkProcessingMetadata: { profile: {} } })).toBe(true); + }); + it('not profiled event', () => { + expect(isProfiledTransactionEvent({ sdkProcessingMetadata: { something: {} } })).toBe(false); + }); +}); + +describe('maybeRemoveProfileFromSdkMetadata', () => { + it('removes profile', () => { + expect(maybeRemoveProfileFromSdkMetadata({ sdkProcessingMetadata: { profile: {} } })).toEqual({ + sdkProcessingMetadata: {}, + }); + }); + + it('does nothing', () => { + expect(maybeRemoveProfileFromSdkMetadata({ sdkProcessingMetadata: { something: {} } })).toEqual({ + sdkProcessingMetadata: { something: {} }, + }); + }); +}); + +describe('createProfilingEventEnvelope', () => { + it('throws if profile_id is not set', () => { + const profile = makeProfile({}); + delete profile.profile_id; + + expect(() => + createProfilingEventEnvelope(makeEvent({ type: 'transaction' }, profile), makeDsn({}), makeSdkMetadata({})), + ).toThrow('Cannot construct profiling event envelope without a valid profile id. Got undefined instead.'); + }); + it('throws if profile is undefined', () => { + expect(() => + // @ts-expect-error mock profile as undefined + createProfilingEventEnvelope(makeEvent({ type: 'transaction' }, undefined), makeDsn({}), makeSdkMetadata({})), + ).toThrow('Cannot construct profiling event envelope without a valid profile. Got undefined instead.'); + expect(() => + // @ts-expect-error mock profile as null + createProfilingEventEnvelope(makeEvent({ type: 'transaction' }, null), makeDsn({}), makeSdkMetadata({})), + ).toThrow('Cannot construct profiling event envelope without a valid profile. Got null instead.'); + }); + + it('envelope header is of type: profile', () => { + const envelope = createProfilingEventEnvelope( + makeEvent( + { type: 'transaction' }, + makeProfile({ + samples: [ + { elapsed_since_start_ns: '0', thread_id: '0', stack_id: 0 }, + { elapsed_since_start_ns: '0', thread_id: '0', stack_id: 0 }, + ], + }), + ), + makeDsn({}), + makeSdkMetadata({ + name: 'sentry.javascript.node', + version: '1.2.3', + integrations: ['integration1', 'integration2'], + packages: [ + { name: 'package1', version: '1.2.3' }, + { name: 'package2', version: '4.5.6' }, + ], + }), + ); + expect(envelope?.[1][0]?.[0].type).toBe('profile'); + }); + + it('returns if samples.length <= 1', () => { + const envelope = createProfilingEventEnvelope( + makeEvent( + { type: 'transaction' }, + makeProfile({ + samples: [{ elapsed_since_start_ns: '0', thread_id: '0', stack_id: 0 }], + }), + ), + makeDsn({}), + makeSdkMetadata({ + name: 'sentry.javascript.node', + version: '1.2.3', + integrations: ['integration1', 'integration2'], + packages: [ + { name: 'package1', version: '1.2.3' }, + { name: 'package2', version: '4.5.6' }, + ], + }), + ); + expect(envelope).toBe(null); + }); + + it('enriches envelope with sdk metadata', () => { + const envelope = createProfilingEventEnvelope( + makeEvent({ type: 'transaction' }, makeProfile({})), + makeDsn({}), + makeSdkMetadata({ + name: 'sentry.javascript.node', + version: '1.2.3', + }), + ); + + expect(envelope && envelope[0]?.sdk?.name).toBe('sentry.javascript.node'); + expect(envelope && envelope[0]?.sdk?.version).toBe('1.2.3'); + }); + + it('handles undefined sdk metadata', () => { + const envelope = createProfilingEventEnvelope( + makeEvent({ type: 'transaction' }, makeProfile({})), + makeDsn({}), + undefined, + ); + + expect(envelope?.[0].sdk).toBe(undefined); + }); + + it('enriches envelope with dsn metadata', () => { + const envelope = createProfilingEventEnvelope( + makeEvent({ type: 'transaction' }, makeProfile({})), + makeDsn({ + host: 'sentry.io', + projectId: '123', + protocol: 'https', + path: 'path', + port: '9000', + publicKey: 'publicKey', + }), + makeSdkMetadata({}), + 'tunnel', + ); + + expect(envelope?.[0].dsn).toBe('https://publicKey@sentry.io:9000/path/123'); + }); + + it('enriches profile with device info', () => { + const envelope = createProfilingEventEnvelope( + makeEvent({ type: 'transaction' }, makeProfile({})), + makeDsn({}), + makeSdkMetadata({}), + ); + const profile = envelope?.[1][0]?.[1] as unknown as Profile; + + expect(typeof profile.device.manufacturer).toBe('string'); + expect(typeof profile.device.model).toBe('string'); + expect(typeof profile.os.name).toBe('string'); + expect(typeof profile.os.version).toBe('string'); + + expect(profile.device.manufacturer.length).toBeGreaterThan(0); + expect(profile.device.model.length).toBeGreaterThan(0); + expect(profile.os.name.length).toBeGreaterThan(0); + expect(profile.os.version.length).toBeGreaterThan(0); + }); + + it('throws if event.type is not a transaction', () => { + expect(() => + createProfilingEventEnvelope( + makeEvent( + // @ts-expect-error force invalid value + { type: 'error' }, + // @ts-expect-error mock tid as undefined + makeProfile({ samples: [{ stack_id: 0, thread_id: undefined, elapsed_since_start_ns: '0' }] }), + ), + makeDsn({}), + makeSdkMetadata({}), + ), + ).toThrow('Profiling events may only be attached to transactions, this should never occur.'); + }); + + it('inherits transaction properties', () => { + const start = new Date(2022, 8, 1, 12, 0, 0); + const end = new Date(2022, 8, 1, 12, 0, 10); + + const envelope = createProfilingEventEnvelope( + makeEvent( + { + event_id: uuid4(), + type: 'transaction', + transaction: 'transaction-name', + start_timestamp: start.getTime() / 1000, + timestamp: end.getTime() / 1000, + contexts: { + trace: { + span_id: 'span_id', + trace_id: 'trace_id', + }, + }, + }, + makeProfile({ + samples: [ + // @ts-expect-error mock tid as undefined + { stack_id: 0, thread_id: undefined, elapsed_since_start_ns: '0' }, + // @ts-expect-error mock tid as undefined + { stack_id: 0, thread_id: undefined, elapsed_since_start_ns: '0' }, + ], + }), + ), + makeDsn({}), + makeSdkMetadata({}), + ); + + const profile = envelope?.[1][0]?.[1] as unknown as Profile; + + expect(profile.transaction.name).toBe('transaction-name'); + expect(typeof profile.transaction.id).toBe('string'); + expect(profile.transaction.id?.length).toBe(32); + expect(profile.transaction.trace_id).toBe('trace_id'); + }); +}); + +describe('isValidSampleRate', () => { + it.each([ + [0, true], + [0.1, true], + [1, true], + [true, true], + [false, true], + // invalid values + [1.1, false], + [-0.1, false], + [NaN, false], + [Infinity, false], + [null, false], + [undefined, false], + ['', false], + [' ', false], + [{}, false], + [[], false], + [() => null, false], + ])('value %s is %s', (input, expected) => { + expect(isValidSampleRate(input)).toBe(expected); + }); +}); + +describe('isValidProfile', () => { + it('is not valid if samples <= 1', () => { + expect(isValidProfile(makeProfile({ samples: [] }))).toBe(false); + }); + + it('is not valid if it does not have a profile_id', () => { + expect(isValidProfile(makeProfile({ samples: [], profile_id: undefined } as any))).toBe(false); + }); +}); + +describe('addProfilesToEnvelope', () => { + it('adds profile', () => { + const profile = makeProfile({}); + const envelope = createEnvelope({}); + + // @ts-expect-error profile is untyped + addProfilesToEnvelope(envelope, [profile]); + + // @ts-expect-error profile is untyped + const addedBySdk = addItemToEnvelope(createEnvelope({}), [{ type: 'profile' }, profile]); + + expect(envelope?.[1][0]?.[0]).toEqual({ type: 'profile' }); + expect(envelope?.[1][0]?.[1]).toEqual(profile); + + expect(JSON.stringify(addedBySdk)).toEqual(JSON.stringify(envelope)); + }); +}); + +describe('findProfiledTransactionsFromEnvelope', () => { + it('returns transactions with profile context', () => { + const txnWithProfile: Event = { + event_id: uuid4(), + type: 'transaction', + contexts: { + profile: { + profile_id: uuid4(), + }, + }, + }; + + const envelope = addItemToEnvelope(createEnvelope({}), [{ type: 'transaction' }, txnWithProfile]); + expect(findProfiledTransactionsFromEnvelope(envelope)[0]).toBe(txnWithProfile); + }); + + it('skips if transaction event is not profiled', () => { + const txnWithProfile: Event = { + event_id: uuid4(), + type: 'transaction', + contexts: {}, + }; + + const envelope = addItemToEnvelope(createEnvelope({}), [{ type: 'transaction' }, txnWithProfile]); + expect(findProfiledTransactionsFromEnvelope(envelope)[0]).toBe(undefined); + }); + + it('skips if event is not a transaction', () => { + const nonTransactionEvent: Event = { + event_id: uuid4(), + type: 'replay_event', + contexts: { + profile: { + profile_id: uuid4(), + }, + }, + }; + + // @ts-expect-error replay event is partial + const envelope = addItemToEnvelope(createEnvelope({}), [{ type: 'replay_event' }, nonTransactionEvent]); + expect(findProfiledTransactionsFromEnvelope(envelope)[0]).toBe(undefined); + }); +}); diff --git a/packages/profiling-node/tsconfig.json b/packages/profiling-node/tsconfig.json new file mode 100644 index 000000000000..0c4404d8d70c --- /dev/null +++ b/packages/profiling-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "esnext", + "lib": ["es6"], + "outDir": "lib", + "types": ["node"] + }, + "include": ["src/**/*"] +} + diff --git a/packages/profiling-node/tsconfig.test.json b/packages/profiling-node/tsconfig.test.json new file mode 100644 index 000000000000..52333183eb70 --- /dev/null +++ b/packages/profiling-node/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "src/**/*.d.ts"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node", "jest"] + + // other package-specific, test-specific options + } +} diff --git a/packages/profiling-node/tsconfig.types.json b/packages/profiling-node/tsconfig.types.json new file mode 100644 index 000000000000..d613534a1674 --- /dev/null +++ b/packages/profiling-node/tsconfig.types.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "lib/types", + "types": ["node"] + }, + "files": ["src/index.ts"] +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ad66d1e77801..2fa3e32e67d6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,10 +5,24 @@ export { Profiler, withProfiler, useProfiler } from './profiler'; export type { ErrorBoundaryProps, FallbackRender } from './errorboundary'; export { ErrorBoundary, withErrorBoundary } from './errorboundary'; export { createReduxEnhancer } from './redux'; -export { reactRouterV3Instrumentation } from './reactrouterv3'; -export { reactRouterV4Instrumentation, reactRouterV5Instrumentation, withSentryRouting } from './reactrouter'; export { + // eslint-disable-next-line deprecation/deprecation + reactRouterV3Instrumentation, + reactRouterV3BrowserTracingIntegration, +} from './reactrouterv3'; +export { + // eslint-disable-next-line deprecation/deprecation + reactRouterV4Instrumentation, + // eslint-disable-next-line deprecation/deprecation + reactRouterV5Instrumentation, + withSentryRouting, + reactRouterV4BrowserTracingIntegration, + reactRouterV5BrowserTracingIntegration, +} from './reactrouter'; +export { + // eslint-disable-next-line deprecation/deprecation reactRouterV6Instrumentation, + reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, wrapUseRoutes, wrapCreateBrowserRouter, diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouter.tsx index 04995ee4bc44..ba6fc523ee58 100644 --- a/packages/react/src/reactrouter.tsx +++ b/packages/react/src/reactrouter.tsx @@ -1,6 +1,18 @@ -import { WINDOW } from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import type { Transaction, TransactionSource } from '@sentry/types'; +import { + WINDOW, + browserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + getActiveSpan, + getRootSpan, + spanToJSON, +} from '@sentry/core'; +import type { Integration, Span, StartSpanOptions, Transaction, TransactionSource } from '@sentry/types'; import hoistNonReactStatics from 'hoist-non-react-statics'; import * as React from 'react'; @@ -23,29 +35,121 @@ export type RouteConfig = { routes?: RouteConfig[]; }; -type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null; // eslint-disable-line @typescript-eslint/no-explicit-any +export type MatchPath = (pathname: string, props: string | string[] | any, parent?: Match | null) => Match | null; // eslint-disable-line @typescript-eslint/no-explicit-any + +interface ReactRouterOptions { + history: RouterHistory; + routes?: RouteConfig[]; + matchPath?: MatchPath; +} let activeTransaction: Transaction | undefined; +/** + * A browser tracing integration that uses React Router v4 to instrument navigations. + * Expects `history` (and optionally `routes` and `matchPath`) to be passed as options. + */ +export function reactRouterV4BrowserTracingIntegration( + options: Parameters[0] & ReactRouterOptions, +): Integration { + const integration = browserTracingIntegration({ + ...options, + instrumentPageLoad: false, + instrumentNavigation: false, + }); + + const { history, routes, matchPath, instrumentPageLoad = true, instrumentNavigation = true } = options; + + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup(client); + + const startPageloadCallback = (startSpanOptions: StartSpanOptions): undefined => { + startBrowserTracingPageLoadSpan(client, startSpanOptions); + return undefined; + }; + + const startNavigationCallback = (startSpanOptions: StartSpanOptions): undefined => { + startBrowserTracingNavigationSpan(client, startSpanOptions); + return undefined; + }; + + // eslint-disable-next-line deprecation/deprecation + const instrumentation = reactRouterV4Instrumentation(history, routes, matchPath); + + // Now instrument page load & navigation with correct settings + instrumentation(startPageloadCallback, instrumentPageLoad, false); + instrumentation(startNavigationCallback, false, instrumentNavigation); + }, + }; +} + +/** + * A browser tracing integration that uses React Router v5 to instrument navigations. + * Expects `history` (and optionally `routes` and `matchPath`) to be passed as options. + */ +export function reactRouterV5BrowserTracingIntegration( + options: Parameters[0] & ReactRouterOptions, +): Integration { + const integration = browserTracingIntegration({ + ...options, + instrumentPageLoad: false, + instrumentNavigation: false, + }); + + const { history, routes, matchPath } = options; + + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup(client); + + const startPageloadCallback = (startSpanOptions: StartSpanOptions): undefined => { + startBrowserTracingPageLoadSpan(client, startSpanOptions); + return undefined; + }; + + const startNavigationCallback = (startSpanOptions: StartSpanOptions): undefined => { + startBrowserTracingNavigationSpan(client, startSpanOptions); + return undefined; + }; + + // eslint-disable-next-line deprecation/deprecation + const instrumentation = reactRouterV5Instrumentation(history, routes, matchPath); + + // Now instrument page load & navigation with correct settings + instrumentation(startPageloadCallback, options.instrumentPageLoad, false); + instrumentation(startNavigationCallback, false, options.instrumentNavigation); + }, + }; +} + +/** + * @deprecated Use `browserTracingReactRouterV4()` instead. + */ export function reactRouterV4Instrumentation( history: RouterHistory, routes?: RouteConfig[], matchPath?: MatchPath, ): ReactRouterInstrumentation { - return createReactRouterInstrumentation(history, 'react-router-v4', routes, matchPath); + return createReactRouterInstrumentation(history, 'reactrouter_v4', routes, matchPath); } +/** + * @deprecated Use `browserTracingReactRouterV5()` instead. + */ export function reactRouterV5Instrumentation( history: RouterHistory, routes?: RouteConfig[], matchPath?: MatchPath, ): ReactRouterInstrumentation { - return createReactRouterInstrumentation(history, 'react-router-v5', routes, matchPath); + return createReactRouterInstrumentation(history, 'reactrouter_v5', routes, matchPath); } function createReactRouterInstrumentation( history: RouterHistory, - name: string, + instrumentationName: string, allRoutes: RouteConfig[] = [], matchPath?: MatchPath, ): ReactRouterInstrumentation { @@ -83,21 +187,17 @@ function createReactRouterInstrumentation( return [pathname, 'url']; } - const tags = { - 'routing.instrumentation': name, - }; - return (customStartTransaction, startTransactionOnPageLoad = true, startTransactionOnLocationChange = true): void => { const initPathName = getInitPathName(); + if (startTransactionOnPageLoad && initPathName) { const [name, source] = normalizeTransactionName(initPathName); activeTransaction = customStartTransaction({ name, - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - tags, - metadata: { - source, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.pageload.react.${instrumentationName}`, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, }, }); } @@ -112,11 +212,10 @@ function createReactRouterInstrumentation( const [name, source] = normalizeTransactionName(location.pathname); activeTransaction = customStartTransaction({ name, - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags, - metadata: { - source, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.${instrumentationName}`, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, }, }); } @@ -164,10 +263,12 @@ function computeRootMatch(pathname: string): Match { export function withSentryRouting

, R extends React.ComponentType

>(Route: R): R { const componentDisplayName = (Route as any).displayName || (Route as any).name; + const activeRootSpan = getActiveRootSpan(); + const WrappedRoute: React.FC

= (props: P) => { - if (activeTransaction && props && props.computedMatch && props.computedMatch.isExact) { - activeTransaction.updateName(props.computedMatch.path); - activeTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + if (activeRootSpan && props && props.computedMatch && props.computedMatch.isExact) { + activeRootSpan.updateName(props.computedMatch.path); + activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); } // @ts-expect-error Setting more specific React Component typing for `R` generic above @@ -184,3 +285,22 @@ export function withSentryRouting

, R extends React return WrappedRoute; } /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ + +function getActiveRootSpan(): Span | undefined { + // Legacy behavior for "old" react router instrumentation + if (activeTransaction) { + return activeTransaction; + } + + const span = getActiveSpan(); + const rootSpan = span ? getRootSpan(span) : undefined; + + if (!rootSpan) { + return undefined; + } + + const op = spanToJSON(rootSpan).op; + + // Only use this root span if it is a pageload or navigation span + return op === 'navigation' || op === 'pageload' ? rootSpan : undefined; +} diff --git a/packages/react/src/reactrouterv3.ts b/packages/react/src/reactrouterv3.ts index db1ce1320508..905ebec13897 100644 --- a/packages/react/src/reactrouterv3.ts +++ b/packages/react/src/reactrouterv3.ts @@ -1,5 +1,22 @@ -import { WINDOW } from '@sentry/browser'; -import type { Primitive, Transaction, TransactionContext, TransactionSource } from '@sentry/types'; +import { + WINDOW, + browserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import type { + Integration, + SpanAttributes, + StartSpanOptions, + Transaction, + TransactionContext, + TransactionSource, +} from '@sentry/types'; import type { Location, ReactRouterInstrumentation } from './types'; @@ -21,6 +38,52 @@ export type Match = ( type ReactRouterV3TransactionSource = Extract; +interface ReactRouterOptions { + history: HistoryV3; + routes: Route[]; + match: Match; +} + +/** + * A browser tracing integration that uses React Router v3 to instrument navigations. + * Expects `history` (and optionally `routes` and `matchPath`) to be passed as options. + */ +export function reactRouterV3BrowserTracingIntegration( + options: Parameters[0] & ReactRouterOptions, +): Integration { + const integration = browserTracingIntegration({ + ...options, + instrumentPageLoad: false, + instrumentNavigation: false, + }); + + const { history, routes, match, instrumentPageLoad = true, instrumentNavigation = true } = options; + + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup(client); + + const startPageloadCallback = (startSpanOptions: StartSpanOptions): undefined => { + startBrowserTracingPageLoadSpan(client, startSpanOptions); + return undefined; + }; + + const startNavigationCallback = (startSpanOptions: StartSpanOptions): undefined => { + startBrowserTracingNavigationSpan(client, startSpanOptions); + return undefined; + }; + + // eslint-disable-next-line deprecation/deprecation + const instrumentation = reactRouterV3Instrumentation(history, routes, match); + + // Now instrument page load & navigation with correct settings + instrumentation(startPageloadCallback, instrumentPageLoad, false); + instrumentation(startNavigationCallback, false, instrumentNavigation); + }, + }; +} + /** * Creates routing instrumentation for React Router v3 * Works for React Router >= 3.2.0 and < 4.0.0 @@ -28,6 +91,8 @@ type ReactRouterV3TransactionSource = Extract = { - 'routing.instrumentation': 'react-router-v3', - }; - if (prevName) { - tags.from = prevName; - } normalizeTransactionName(routes, location, match, (localName: string, source: TransactionSource = 'url') => { prevName = localName; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + }; + activeTransaction = startTransaction({ name: prevName, - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv3', - tags, - metadata: { - source, - }, + attributes, }); }); } diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx index c2dc56687571..73196bcfcc2a 100644 --- a/packages/react/src/reactrouterv6.tsx +++ b/packages/react/src/reactrouterv6.tsx @@ -1,9 +1,29 @@ +/* eslint-disable max-lines */ // Inspired from Donnie McNeal's solution: // https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536 -import { WINDOW } from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import type { Transaction, TransactionContext, TransactionSource } from '@sentry/types'; +import { + WINDOW, + browserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + getActiveSpan, + getRootSpan, + spanToJSON, +} from '@sentry/core'; +import type { + Integration, + Span, + StartSpanOptions, + Transaction, + TransactionContext, + TransactionSource, +} from '@sentry/types'; import { getNumberOfUrlSegments, logger } from '@sentry/utils'; import hoistNonReactStatics from 'hoist-non-react-statics'; import * as React from 'react'; @@ -37,10 +57,77 @@ let _customStartTransaction: (context: TransactionContext) => Transaction | unde let _startTransactionOnLocationChange: boolean; let _stripBasename: boolean = false; -const SENTRY_TAGS = { - 'routing.instrumentation': 'react-router-v6', -}; +interface ReactRouterOptions { + useEffect: UseEffect; + useLocation: UseLocation; + useNavigationType: UseNavigationType; + createRoutesFromChildren: CreateRoutesFromChildren; + matchRoutes: MatchRoutes; + stripBasename?: boolean; +} + +/** + * A browser tracing integration that uses React Router v3 to instrument navigations. + * Expects `history` (and optionally `routes` and `matchPath`) to be passed as options. + */ +export function reactRouterV6BrowserTracingIntegration( + options: Parameters[0] & ReactRouterOptions, +): Integration { + const integration = browserTracingIntegration({ + ...options, + instrumentPageLoad: false, + instrumentNavigation: false, + }); + + const { + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + stripBasename, + instrumentPageLoad = true, + instrumentNavigation = true, + } = options; + + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup(client); + + const startNavigationCallback = (startSpanOptions: StartSpanOptions): undefined => { + startBrowserTracingNavigationSpan(client, startSpanOptions); + return undefined; + }; + + const initPathName = WINDOW && WINDOW.location && WINDOW.location.pathname; + if (instrumentPageLoad && initPathName) { + startBrowserTracingPageLoadSpan(client, { + name: initPathName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, + }); + } + _useEffect = useEffect; + _useLocation = useLocation; + _useNavigationType = useNavigationType; + _matchRoutes = matchRoutes; + _createRoutesFromChildren = createRoutesFromChildren; + _stripBasename = stripBasename || false; + + _customStartTransaction = startNavigationCallback; + _startTransactionOnLocationChange = instrumentNavigation; + }, + }; +} + +/** + * @deprecated Use `reactRouterV6BrowserTracingIntegration()` instead. + */ export function reactRouterV6Instrumentation( useEffect: UseEffect, useLocation: UseLocation, @@ -58,11 +145,10 @@ export function reactRouterV6Instrumentation( if (startTransactionOnPageLoad && initPathName) { activeTransaction = customStartTransaction({ name: initPathName, - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv6', - tags: SENTRY_TAGS, - metadata: { - source: 'url', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', }, }); } @@ -155,6 +241,7 @@ function getNormalizedName( } function updatePageloadTransaction( + activeRootSpan: Span | undefined, location: Location, routes: RouteObject[], matches?: AgnosticDataRouteMatch, @@ -164,10 +251,10 @@ function updatePageloadTransaction( ? matches : (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]); - if (activeTransaction && branches) { + if (activeRootSpan && branches) { const [name, source] = getNormalizedName(routes, location, branches, basename); - activeTransaction.updateName(name); - activeTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + activeRootSpan.updateName(name); + activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); } } @@ -188,11 +275,10 @@ function handleNavigation( const [name, source] = getNormalizedName(routes, location, branches, basename); activeTransaction = _customStartTransaction({ name, - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: SENTRY_TAGS, - metadata: { - source, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, }); } @@ -227,7 +313,7 @@ export function withSentryReactRouterV6Routing

, R const routes = _createRoutesFromChildren(props.children) as RouteObject[]; if (isMountRenderPass) { - updatePageloadTransaction(location, routes); + updatePageloadTransaction(getActiveRootSpan(), location, routes); isMountRenderPass = false; } else { handleNavigation(location, routes, navigationType); @@ -285,7 +371,7 @@ export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes { typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam; if (isMountRenderPass) { - updatePageloadTransaction(normalizedLocation, routes); + updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes); isMountRenderPass = false; } else { handleNavigation(normalizedLocation, routes, navigationType); @@ -312,21 +398,18 @@ export function wrapCreateBrowserRouter< const router = createRouterFunction(routes, opts); const basename = opts && opts.basename; + const activeRootSpan = getActiveRootSpan(); + // The initial load ends when `createBrowserRouter` is called. // This is the earliest convenient time to update the transaction name. // Callbacks to `router.subscribe` are not called for the initial load. - if (router.state.historyAction === 'POP' && activeTransaction) { - updatePageloadTransaction(router.state.location, routes, undefined, basename); + if (router.state.historyAction === 'POP' && activeRootSpan) { + updatePageloadTransaction(activeRootSpan, router.state.location, routes, undefined, basename); } router.subscribe((state: RouterState) => { const location = state.location; - - if ( - _startTransactionOnLocationChange && - (state.historyAction === 'PUSH' || state.historyAction === 'POP') && - activeTransaction - ) { + if (_startTransactionOnLocationChange && (state.historyAction === 'PUSH' || state.historyAction === 'POP')) { handleNavigation(location, routes, state.historyAction, undefined, basename); } }); @@ -334,3 +417,22 @@ export function wrapCreateBrowserRouter< return router; }; } + +function getActiveRootSpan(): Span | undefined { + // Legacy behavior for "old" react router instrumentation + if (activeTransaction) { + return activeTransaction; + } + + const span = getActiveSpan(); + const rootSpan = span ? getRootSpan(span) : undefined; + + if (!rootSpan) { + return undefined; + } + + const op = spanToJSON(rootSpan).op; + + // Only use this root span if it is a pageload or navigation span + return op === 'navigation' || op === 'pageload' ? rootSpan : undefined; +} diff --git a/packages/react/test/reactrouterv3.test.tsx b/packages/react/test/reactrouterv3.test.tsx index 21b9054e45ce..c9926567cea4 100644 --- a/packages/react/test/reactrouterv3.test.tsx +++ b/packages/react/test/reactrouterv3.test.tsx @@ -1,8 +1,18 @@ +import { BrowserClient } from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + createTransport, + getCurrentScope, + setCurrentClient, +} from '@sentry/core'; import { act, render } from '@testing-library/react'; import * as React from 'react'; import { IndexRoute, Route, Router, createMemoryHistory, createRoutes, match } from 'react-router-3'; import type { Match, Route as RouteType } from '../src/reactrouterv3'; +import { reactRouterV3BrowserTracingIntegration } from '../src/reactrouterv3'; import { reactRouterV3Instrumentation } from '../src/reactrouterv3'; // Have to manually set types because we are using package-alias @@ -24,7 +34,7 @@ function createMockStartTransaction(opts: { finish?: jest.FunctionLike; setMetad }); } -describe('React Router V3', () => { +describe('reactRouterV3Instrumentation', () => { const routes = (

{children}
}>
Home
} /> @@ -43,6 +53,7 @@ describe('React Router V3', () => { const history = createMemoryHistory(); const instrumentationRoutes = createRoutes(routes); + // eslint-disable-next-line deprecation/deprecation const instrumentation = reactRouterV3Instrumentation(history, instrumentationRoutes, match); it('starts a pageload transaction when instrumentation is started', () => { @@ -51,11 +62,10 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv3', - tags: { 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'route', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', }, }); }); @@ -77,11 +87,10 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv3', - tags: { from: '/', 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'route', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, }); @@ -91,11 +100,10 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/features', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv3', - tags: { from: '/about', 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'route', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, }); }); @@ -145,11 +153,10 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/users/:userid', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv3', - tags: { from: '/', 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'route', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, }); }); @@ -167,11 +174,10 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/:orgid/v1/:teamid', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv3', - tags: { from: '/', 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'route', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, }); @@ -183,11 +189,10 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/:orgid', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv3', - tags: { from: '/organizations/:orgid/v1/:teamid', 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'route', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, }); }); @@ -204,11 +209,10 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/1234/some/other/route', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv3', - tags: { from: '/', 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'url', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, }); }); @@ -216,6 +220,7 @@ describe('React Router V3', () => { it('sets metadata to url if no routes are provided', () => { const fakeRoutes =
hello
; const mockStartTransaction = createMockStartTransaction(); + // eslint-disable-next-line deprecation/deprecation const mockInstrumentation = reactRouterV3Instrumentation(history, createRoutes(fakeRoutes), match); mockInstrumentation(mockStartTransaction); // We render here with `routes` instead of `fakeRoutes` from above to validate the case @@ -225,11 +230,179 @@ describe('React Router V3', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv3', - tags: { 'routing.instrumentation': 'react-router-v3' }, - metadata: { - source: 'url', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, + }); + }); +}); + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockRootSpan = { + updateName: jest.fn(), + setAttribute: jest.fn(), + getSpanJSON() { + return { op: 'pageload' }; + }, +}; + +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + +describe('browserTracingReactRouterV3', () => { + const routes = ( +
{children}
}> +
Home
} /> +
About
} /> +
Features
} /> + }) =>
{params.userid}
} + /> + +
OrgId
} /> +
Team
} /> +
+
+ ); + const history = createMemoryHistory(); + + const instrumentationRoutes = createRoutes(routes); + + function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + debug: true, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('starts a pageload transaction when instrumentation is started', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration(reactRouterV3BrowserTracingIntegration({ history, routes: instrumentationRoutes, match })); + + client.init(); + render({routes}); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, + }); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV3BrowserTracingIntegration({ history, routes: instrumentationRoutes, match })); + + client.init(); + render({routes}); + + act(() => { + history.push('/about'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/features'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/features', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('only starts a navigation transaction on push', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV3BrowserTracingIntegration({ history, routes: instrumentationRoutes, match })); + + client.init(); + render({routes}); + + act(() => { + history.replace('hello'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(0); + }); + + it('normalizes transaction name ', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV3BrowserTracingIntegration({ history, routes: instrumentationRoutes, match })); + + client.init(); + const { container } = render({routes}); + + act(() => { + history.push('/users/123'); + }); + expect(container.innerHTML).toContain('123'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/:userid', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, }); }); diff --git a/packages/react/test/reactrouterv4.test.tsx b/packages/react/test/reactrouterv4.test.tsx index 5849bb688598..973bda75d273 100644 --- a/packages/react/test/reactrouterv4.test.tsx +++ b/packages/react/test/reactrouterv4.test.tsx @@ -1,14 +1,26 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + createTransport, + getCurrentScope, + setCurrentClient, +} from '@sentry/core'; import { act, render } from '@testing-library/react'; import { createMemoryHistory } from 'history-4'; // biome-ignore lint/nursery/noUnusedImports: Need React import for JSX import * as React from 'react'; import { Route, Router, Switch, matchPath } from 'react-router-4'; -import { reactRouterV4Instrumentation, withSentryRouting } from '../src'; +import { + BrowserClient, + reactRouterV4BrowserTracingIntegration, + reactRouterV4Instrumentation, + withSentryRouting, +} from '../src'; import type { RouteConfig } from '../src/reactrouter'; -describe('React Router v4', () => { +describe('reactRouterV4Instrumentation', () => { function createInstrumentation(_opts?: { startTransactionOnPageLoad?: boolean; startTransactionOnLocationChange?: boolean; @@ -28,6 +40,7 @@ describe('React Router v4', () => { const mockStartTransaction = jest .fn() .mockReturnValue({ updateName: mockUpdateName, end: mockFinish, setAttribute: mockSetAttribute }); + // eslint-disable-next-line deprecation/deprecation reactRouterV4Instrumentation(history, options.routes, options.matchPath)( mockStartTransaction, options.startTransactionOnPageLoad, @@ -41,10 +54,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, }); }); @@ -71,10 +85,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); act(() => { @@ -83,10 +98,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/features', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); }); @@ -162,10 +178,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/users/123', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); }); @@ -190,10 +207,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/users/123', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); expect(mockUpdateName).toHaveBeenCalledTimes(2); expect(mockUpdateName).toHaveBeenLastCalledWith('/users/:userid'); @@ -221,10 +239,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/1234/v1/758', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); expect(mockUpdateName).toHaveBeenCalledTimes(2); expect(mockUpdateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); @@ -238,10 +257,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/543', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); expect(mockUpdateName).toHaveBeenCalledTimes(3); expect(mockUpdateName).toHaveBeenLastCalledWith('/organizations/:orgid'); @@ -273,10 +293,11 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/:orgid/v1/:teamid', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); act(() => { @@ -285,10 +306,339 @@ describe('React Router v4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/:orgid', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v4' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); +}); + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockRootSpan = { + updateName: jest.fn(), + setAttribute: jest.fn(), + getSpanJSON() { + return { op: 'pageload' }; + }, +}; + +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + +describe('browserTracingReactRouterV4', () => { + function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + debug: true, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('starts a pageload transaction when instrumentation is started', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4BrowserTracingIntegration({ history })); + + client.init(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, + }); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4BrowserTracingIntegration({ history })); + + client.init(); + + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/about'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/features'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/features', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('only starts a navigation transaction on push', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4BrowserTracingIntegration({ history })); + + client.init(); + + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.replace('hello'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(0); + }); + + it('does not normalize transaction name ', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4BrowserTracingIntegration({ history })); + + client.init(); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('normalizes transaction name with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4BrowserTracingIntegration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('normalizes nested transaction names with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4BrowserTracingIntegration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + getByText('Team'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/1234/v1/758', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + act(() => { + history.push('/organizations/543'); + }); + getByText('OrgId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/543', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('matches with route object', () => { + const routes: RouteConfig[] = [ + { + path: '/organizations/:orgid/v1/:teamid', + }, + { path: '/organizations/:orgid' }, + { path: '/' }, + ]; + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV4BrowserTracingIntegration({ history, routes, matchPath })); + + client.init(); + + render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid/v1/:teamid', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/organizations/1234'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); }); }); diff --git a/packages/react/test/reactrouterv5.test.tsx b/packages/react/test/reactrouterv5.test.tsx index c571b3590b8f..b08f7de702a1 100644 --- a/packages/react/test/reactrouterv5.test.tsx +++ b/packages/react/test/reactrouterv5.test.tsx @@ -1,14 +1,26 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + createTransport, + getCurrentScope, + setCurrentClient, +} from '@sentry/core'; import { act, render } from '@testing-library/react'; import { createMemoryHistory } from 'history-4'; // biome-ignore lint/nursery/noUnusedImports: Need React import for JSX import * as React from 'react'; import { Route, Router, Switch, matchPath } from 'react-router-5'; -import { reactRouterV5Instrumentation, withSentryRouting } from '../src'; +import { + BrowserClient, + reactRouterV5BrowserTracingIntegration, + reactRouterV5Instrumentation, + withSentryRouting, +} from '../src'; import type { RouteConfig } from '../src/reactrouter'; -describe('React Router v5', () => { +describe('reactRouterV5Instrumentation', () => { function createInstrumentation(_opts?: { startTransactionOnPageLoad?: boolean; startTransactionOnLocationChange?: boolean; @@ -28,6 +40,7 @@ describe('React Router v5', () => { const mockStartTransaction = jest .fn() .mockReturnValue({ updateName: mockUpdateName, end: mockFinish, setAttribute: mockSetAttribute }); + // eslint-disable-next-line deprecation/deprecation reactRouterV5Instrumentation(history, options.routes, options.matchPath)( mockStartTransaction, options.startTransactionOnPageLoad, @@ -41,10 +54,11 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, }); }); @@ -71,10 +85,11 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); act(() => { @@ -83,10 +98,11 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/features', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); }); @@ -162,17 +178,17 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/users/123', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); }); it('normalizes transaction name with custom Route', () => { const [mockStartTransaction, history, { mockUpdateName, mockSetAttribute }] = createInstrumentation(); const SentryRoute = withSentryRouting(Route); - const { getByText } = render( @@ -182,6 +198,7 @@ describe('React Router v5', () => { , ); + act(() => { history.push('/users/123'); }); @@ -190,20 +207,20 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/users/123', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); expect(mockUpdateName).toHaveBeenCalledTimes(2); expect(mockUpdateName).toHaveBeenLastCalledWith('/users/:userid'); - expect(mockSetAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockSetAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); }); it('normalizes nested transaction names with custom Route', () => { const [mockStartTransaction, history, { mockUpdateName, mockSetAttribute }] = createInstrumentation(); const SentryRoute = withSentryRouting(Route); - const { getByText } = render( @@ -222,10 +239,11 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/1234/v1/758', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); expect(mockUpdateName).toHaveBeenCalledTimes(2); expect(mockUpdateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); @@ -239,13 +257,15 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/543', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); expect(mockUpdateName).toHaveBeenCalledTimes(3); expect(mockUpdateName).toHaveBeenLastCalledWith('/organizations/:orgid'); + expect(mockSetAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); }); it('matches with route object', () => { @@ -273,10 +293,11 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/:orgid/v1/:teamid', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); act(() => { @@ -285,10 +306,339 @@ describe('React Router v5', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(3); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/organizations/:orgid', - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - tags: { 'routing.instrumentation': 'react-router-v5' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); +}); + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockRootSpan = { + updateName: jest.fn(), + setAttribute: jest.fn(), + getSpanJSON() { + return { op: 'pageload' }; + }, +}; + +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + +describe('browserTracingReactRouterV5', () => { + function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + debug: true, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('starts a pageload transaction when instrumentation is started', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV5BrowserTracingIntegration({ history })); + + client.init(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }, + }); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV5BrowserTracingIntegration({ history })); + + client.init(); + + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/about'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/features'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/features', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('only starts a navigation transaction on push', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV5BrowserTracingIntegration({ history })); + + client.init(); + + render( + + +
Features
} /> +
About
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.replace('hello'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(0); + }); + + it('does not normalize transaction name ', () => { + const client = createMockBrowserClient(); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV5BrowserTracingIntegration({ history })); + + client.init(); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + }); + + it('normalizes transaction name with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV5BrowserTracingIntegration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
UserId
} /> +
Users
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/users/123'); + }); + getByText('UserId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/users/123', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('normalizes nested transaction names with custom Route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV5BrowserTracingIntegration({ history })); + + client.init(); + + const SentryRoute = withSentryRouting(Route); + + const { getByText } = render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + getByText('Team'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/1234/v1/758', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + act(() => { + history.push('/organizations/543'); + }); + getByText('OrgId'); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/543', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); + expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('matches with route object', () => { + const routes: RouteConfig[] = [ + { + path: '/organizations/:orgid/v1/:teamid', + }, + { path: '/organizations/:orgid' }, + { path: '/' }, + ]; + const client = createMockBrowserClient(); + setCurrentClient(client); + + const history = createMemoryHistory(); + client.addIntegration(reactRouterV5BrowserTracingIntegration({ history, routes, matchPath })); + + client.init(); + + render( + + +
Team
} /> +
OrgId
} /> +
Home
} /> +
+
, + ); + + act(() => { + history.push('/organizations/1234/v1/758'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid/v1/:teamid', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, + }); + + act(() => { + history.push('/organizations/1234'); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/organizations/:orgid', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }, }); }); }); diff --git a/packages/react/test/reactrouterv6.4.test.tsx b/packages/react/test/reactrouterv6.4.test.tsx index 29fe612f7e97..f534d02f97e2 100644 --- a/packages/react/test/reactrouterv6.4.test.tsx +++ b/packages/react/test/reactrouterv6.4.test.tsx @@ -1,4 +1,11 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + createTransport, + getCurrentScope, + setCurrentClient, +} from '@sentry/core'; import { render } from '@testing-library/react'; import { Request } from 'node-fetch'; import * as React from 'react'; @@ -13,7 +20,8 @@ import { useNavigationType, } from 'react-router-6.4'; -import { reactRouterV6Instrumentation, wrapCreateBrowserRouter } from '../src'; +import { BrowserClient, reactRouterV6Instrumentation, wrapCreateBrowserRouter } from '../src'; +import { reactRouterV6BrowserTracingIntegration } from '../src/reactrouterv6'; import type { CreateRouterFunction } from '../src/types'; beforeAll(() => { @@ -22,7 +30,7 @@ beforeAll(() => { global.Request = Request; }); -describe('React Router v6.4', () => { +describe('reactRouterV6Instrumentation (v6.4)', () => { function createInstrumentation(_opts?: { startTransactionOnPageLoad?: boolean; startTransactionOnLocationChange?: boolean; @@ -41,6 +49,7 @@ describe('React Router v6.4', () => { .fn() .mockReturnValue({ updateName: mockUpdateName, end: mockFinish, setAttribute: mockSetAttribute }); + // eslint-disable-next-line deprecation/deprecation reactRouterV6Instrumentation( React.useEffect, useLocation, @@ -75,13 +84,10 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv6', - tags: { - 'routing.instrumentation': 'react-router-v6', - }, - metadata: { - source: 'url', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', }, }); }); @@ -112,10 +118,11 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -151,10 +158,11 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about/us', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -190,10 +198,11 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about/:page', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -241,10 +250,11 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/stores/:storeId/products/:productId', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -311,10 +321,11 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/app/about/us', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -355,10 +366,11 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/admin/:orgId/users/:userId', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -401,10 +413,11 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/:orgId/users/:userId', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -443,10 +456,575 @@ describe('React Router v6.4', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about/us', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + }); +}); + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockRootSpan = { + updateName: jest.fn(), + setAttribute: jest.fn(), + getSpanJSON() { + return { op: 'pageload' }; + }, +}; + +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + +describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { + function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + debug: true, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + describe('wrapCreateBrowserRouter', () => { + it('starts a pageload transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element:
TEST
, + }, + ], + { + initialEntries: ['/'], + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, + }); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: 'about', + element:
About
, + }, + ], + { + initialEntries: ['/'], + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with nested routes', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: 'about', + element:
About
, + children: [ + { + path: 'us', + element:
Us
, + }, + ], + }, + ], + { + initialEntries: ['/'], + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about/us', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with parameterized paths', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: 'about', + element:
About
, + children: [ + { + path: ':page', + element:
Page
, + }, + ], + }, + ], + { + initialEntries: ['/'], + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about/:page', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with paths with multiple parameters', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: 'stores', + element:
Stores
, + children: [ + { + path: ':storeId', + element:
Store
, + children: [ + { + path: 'products', + element:
Products
, + children: [ + { + path: ':productId', + element:
Product
, + }, + ], + }, + ], + }, + ], + }, + ], + { + initialEntries: ['/'], + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/stores/:storeId/products/:productId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('updates pageload transaction to a parameterized route', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: 'about', + element:
About
, + children: [ + { + path: ':page', + element:
page
, + }, + ], + }, + ], + { + initialEntries: ['/about/us'], + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/about/:page'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('works with `basename` option', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: 'about', + element:
About
, + children: [ + { + path: 'us', + element:
Us
, + }, + ], + }, + ], + { + initialEntries: ['/app'], + basename: '/app', + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/app/about/us', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with parameterized paths and `basename`', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: ':orgId', + children: [ + { + path: 'users', + children: [ + { + path: ':userId', + element:
User
, + }, + ], + }, + ], + }, + ], + { + initialEntries: ['/admin'], + basename: '/admin', + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/admin/:orgId/users/:userId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('strips `basename` from transaction names of parameterized paths', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + stripBasename: true, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: ':orgId', + children: [ + { + path: 'users', + children: [ + { + path: ':userId', + element:
User
, + }, + ], + }, + ], + }, + ], + { + initialEntries: ['/admin'], + basename: '/admin', + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/:orgId/users/:userId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('strips `basename` from transaction names of non-parameterized paths', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + stripBasename: true, + }), + ); + const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); + + const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: 'about', + element:
About
, + children: [ + { + path: 'us', + element:
Us
, + }, + ], + }, + ], + { + initialEntries: ['/app'], + basename: '/app', + }, + ); + + // @ts-expect-error router is fine + render(); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about/us', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); }); diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index df30c4596dbf..f2ec3fb3a4b9 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -1,4 +1,11 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + createTransport, + getCurrentScope, + setCurrentClient, +} from '@sentry/core'; import { render } from '@testing-library/react'; import * as React from 'react'; import { @@ -15,10 +22,14 @@ import { useRoutes, } from 'react-router-6'; -import { reactRouterV6Instrumentation } from '../src'; -import { withSentryReactRouterV6Routing, wrapUseRoutes } from '../src/reactrouterv6'; +import { BrowserClient, reactRouterV6Instrumentation } from '../src'; +import { + reactRouterV6BrowserTracingIntegration, + withSentryReactRouterV6Routing, + wrapUseRoutes, +} from '../src/reactrouterv6'; -describe('React Router v6', () => { +describe('reactRouterV6Instrumentation', () => { function createInstrumentation(_opts?: { startTransactionOnPageLoad?: boolean; startTransactionOnLocationChange?: boolean; @@ -36,6 +47,7 @@ describe('React Router v6', () => { .fn() .mockReturnValue({ updateName: mockUpdateName, end: mockFinish, setAttribute: mockSetAttribute }); + // eslint-disable-next-line deprecation/deprecation reactRouterV6Instrumentation( React.useEffect, useLocation, @@ -62,10 +74,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, }); }); @@ -100,10 +113,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, }); }); @@ -123,10 +137,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -148,10 +163,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about/us', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -173,10 +189,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about/:page', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -200,10 +217,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/stores/:storeId/products/:productId', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -235,10 +253,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/projects/:projectId/views/:viewId', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); }); @@ -265,10 +284,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, }); }); @@ -318,10 +338,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(1); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/', - op: 'pageload', - origin: 'auto.pageload.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'url' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, }); }); @@ -350,10 +371,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -388,10 +410,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about/us', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -426,10 +449,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/about/:page', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -470,10 +494,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/stores/:storeId/products/:productId', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -538,10 +563,11 @@ describe('React Router v6', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/projects/:projectId/views/:viewId', - op: 'navigation', - origin: 'auto.navigation.react.reactrouterv6', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, }); }); @@ -639,3 +665,853 @@ describe('React Router v6', () => { }); }); }); + +const mockStartBrowserTracingPageLoadSpan = jest.fn(); +const mockStartBrowserTracingNavigationSpan = jest.fn(); + +const mockRootSpan = { + updateName: jest.fn(), + setAttribute: jest.fn(), + getSpanJSON() { + return { op: 'pageload' }; + }, +}; + +jest.mock('@sentry/browser', () => { + const actual = jest.requireActual('@sentry/browser'); + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + getRootSpan: () => { + return mockRootSpan; + }, + }; +}); + +describe('reactRouterV6BrowserTracingIntegration', () => { + function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + debug: true, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + describe('withSentryReactRouterV6Routing', () => { + it('starts a pageload transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + Home} /> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, + }); + }); + + it('skips pageload transaction with `instrumentPageLoad: false`', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + instrumentPageLoad: false, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + Home} /> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(0); + }); + + it('skips navigation transaction, with `instrumentNavigation: false`', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + instrumentNavigation: false, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About} /> + } /> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(0); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About} /> + } /> + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with nested routes', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About}> + us} /> + + } /> + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about/us', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with paramaterized paths', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About}> + page} /> + + } /> + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about/:page', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with paths with multiple parameters', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + Stores}> + Store}> + Product} /> + + + } /> + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/stores/:storeId/products/:productId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with nested paths with parameters', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + } /> + Account Page} /> + + Project Index} /> + Project Page}> + Project Page Root} /> + Editor}> + View Canvas} /> + Space Canvas} /> + + + + + No Match Page} /> + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/projects/:projectId/views/:viewId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + }); + + describe('wrapUseRoutes', () => { + it('starts a pageload transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element:
Home
, + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, + }); + }); + + it('skips pageload transaction with `instrumentPageLoad: false`', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + instrumentPageLoad: false, + }), + ); + + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element:
Home
, + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(0); + }); + + it('skips navigation transaction, with `instrumentNavigation: false`', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + instrumentNavigation: false, + }), + ); + + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(0); + }); + + it('starts a navigation transaction', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with nested routes', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + children: [ + { + path: '/about/us', + element:
us
, + }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about/us', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with paramaterized paths', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + children: [ + { + path: '/about/:page', + element:
page
, + }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about/:page', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with paths with multiple parameters', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/stores', + element:
Stores
, + children: [ + { + path: '/stores/:storeId', + element:
Store
, + children: [ + { + path: '/stores/:storeId/products/:productId', + element:
Product
, + }, + ], + }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/stores/:storeId/products/:productId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('works with nested paths with parameters', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + index: true, + element: , + }, + { + path: 'account', + element:
Account Page
, + }, + { + path: 'projects', + children: [ + { + index: true, + element:
Project Index
, + }, + { + path: ':projectId', + element:
Project Page
, + children: [ + { + index: true, + element:
Project Page Root
, + }, + { + element:
Editor
, + children: [ + { + path: 'views/:viewId', + element:
View Canvas
, + }, + { + path: 'spaces/:spaceId', + element:
Space Canvas
, + }, + ], + }, + ], + }, + ], + }, + { + path: '*', + element:
No Match Page
, + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/projects/:projectId/views/:viewId', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('does not add double slashes to URLS', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: ( +
+ +
+ ), + children: [ + { + path: 'tests', + children: [ + { index: true, element:
Main Test
}, + { path: ':testId/*', element:
Test Component
}, + ], + }, + { path: '/', element: }, + { path: '*', element: }, + ], + }, + { + path: '/', + element:
, + children: [ + { path: '404', element:
Error
}, + { path: '*', element: }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + // should be /tests not //tests + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/tests'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it('handles wildcard routes properly', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: ( +
+ +
+ ), + children: [ + { + path: 'tests', + children: [ + { index: true, element:
Main Test
}, + { path: ':testId/*', element:
Test Component
}, + ], + }, + { path: '/', element: }, + { path: '*', element: }, + ], + }, + { + path: '/', + element:
, + children: [ + { path: '404', element:
Error
}, + { path: '*', element: }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/tests/:testId/*'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + }); +}); diff --git a/packages/remix/README.md b/packages/remix/README.md index c51820980e91..ae2dfbeff53a 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -12,27 +12,26 @@ ## General -This package is a wrapper around `@sentry/node` for the server and `@sentry/react` for the client, with added functionality related to Remix. +This package is a wrapper around `@sentry/node` for the server and `@sentry/react` for the client, with added +functionality related to Remix. To use this SDK, initialize Sentry in your Remix entry points for both the client and server. ```ts // entry.client.tsx -import { useLocation, useMatches } from "@remix-run/react"; -import * as Sentry from "@sentry/remix"; -import { useEffect } from "react"; +import { useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { useEffect } from 'react'; Sentry.init({ - dsn: "__DSN__", + dsn: '__DSN__', tracesSampleRate: 1, integrations: [ - new Sentry.BrowserTracing({ - routingInstrumentation: Sentry.remixRouterInstrumentation( - useEffect, - useLocation, - useMatches, - ), + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, }), ], // ... @@ -42,19 +41,20 @@ Sentry.init({ ```ts // entry.server.tsx -import { prisma } from "~/db.server"; +import { prisma } from '~/db.server'; -import * as Sentry from "@sentry/remix"; +import * as Sentry from '@sentry/remix'; Sentry.init({ - dsn: "__DSN__", + dsn: '__DSN__', tracesSampleRate: 1, integrations: [new Sentry.Integrations.Prisma({ client: prisma })], // ... }); ``` -Also, wrap your Remix root with `withSentry` to catch React component errors and to get parameterized router transactions. +Also, wrap your Remix root with `withSentry` to catch React component errors and to get parameterized router +transactions. ```ts // root.tsx @@ -139,8 +139,11 @@ Sentry.captureEvent({ ## Sourcemaps and Releases -The Remix SDK provides a script that automatically creates a release and uploads sourcemaps. To generate sourcemaps with Remix, you need to call `remix build` with the `--sourcemap` option. +The Remix SDK provides a script that automatically creates a release and uploads sourcemaps. To generate sourcemaps with +Remix, you need to call `remix build` with the `--sourcemap` option. -On release, call `sentry-upload-sourcemaps` to upload source maps and create a release. To see more details on how to use the command, call `sentry-upload-sourcemaps --help`. +On release, call `sentry-upload-sourcemaps` to upload source maps and create a release. To see more details on how to +use the command, call `sentry-upload-sourcemaps --help`. -For more advanced configuration, [directly use `sentry-cli` to upload source maps.](https://github.com/getsentry/sentry-cli). +For more advanced configuration, +[directly use `sentry-cli` to upload source maps.](https://github.com/getsentry/sentry-cli). diff --git a/packages/remix/package.json b/packages/remix/package.json index b9e22128ece4..2133813e857a 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -70,11 +70,12 @@ "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", "test": "yarn test:unit", - "test:integration": "run-s test:integration:v1 test:integration:v2", + "test:integration": "run-s test:integration:v1 test:integration:v2 test:integration:tracingIntegration", "test:integration:v1": "run-s test:integration:clean test:integration:prepare test:integration:client test:integration:server", "test:integration:v2": "export REMIX_VERSION=2 && run-s test:integration:v1", + "test:integration:tracingIntegration": "export TRACING_INTEGRATION=true && run-s test:integration:v2", "test:integration:ci": "run-s test:integration:clean test:integration:prepare test:integration:client:ci test:integration:server", - "test:integration:prepare": "(cd test/integration && yarn)", + "test:integration:prepare": "(cd test/integration && yarn install)", "test:integration:clean": "(cd test/integration && rimraf .cache node_modules build)", "test:integration:client": "yarn playwright install-deps && yarn playwright test test/integration/test/client/ --project='chromium'", "test:integration:client:ci": "yarn test:integration:client --reporter='line'", diff --git a/packages/remix/src/client/browserTracingIntegration.ts b/packages/remix/src/client/browserTracingIntegration.ts new file mode 100644 index 000000000000..c0eb1a97148d --- /dev/null +++ b/packages/remix/src/client/browserTracingIntegration.ts @@ -0,0 +1,41 @@ +import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/react'; +import type { Integration } from '@sentry/types'; +import { setGlobals, startPageloadSpan } from './performance'; +import type { RemixBrowserTracingIntegrationOptions } from './performance'; +/** + * Creates a browser tracing integration for Remix applications. + * This integration will create pageload and navigation spans. + */ +export function browserTracingIntegration(options: RemixBrowserTracingIntegrationOptions): Integration { + if (options.instrumentPageLoad === undefined) { + options.instrumentPageLoad = true; + } + + if (options.instrumentNavigation === undefined) { + options.instrumentNavigation = true; + } + + setGlobals({ + useEffect: options.useEffect, + useLocation: options.useLocation, + useMatches: options.useMatches, + instrumentNavigation: options.instrumentNavigation, + }); + + const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ + ...options, + instrumentPageLoad: false, + instrumentNavigation: false, + }); + + return { + ...browserTracingIntegrationInstance, + afterAllSetup(client) { + browserTracingIntegrationInstance.afterAllSetup(client); + + if (options.instrumentPageLoad) { + startPageloadSpan(); + } + }, + }; +} diff --git a/packages/remix/src/client/performance.tsx b/packages/remix/src/client/performance.tsx index fc395e8ddedc..10e91837149d 100644 --- a/packages/remix/src/client/performance.tsx +++ b/packages/remix/src/client/performance.tsx @@ -1,46 +1,58 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import type { ErrorBoundaryProps } from '@sentry/react'; -import { WINDOW, withErrorBoundary } from '@sentry/react'; -import type { Transaction, TransactionContext } from '@sentry/types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan } from '@sentry/core'; +import type { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/react'; +import type { BrowserClient, ErrorBoundaryProps } from '@sentry/react'; +import { + WINDOW, + getClient, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + withErrorBoundary, +} from '@sentry/react'; +import type { Span, StartSpanOptions, Transaction, TransactionContext } from '@sentry/types'; import { isNodeEnv, logger } from '@sentry/utils'; import * as React from 'react'; import { DEBUG_BUILD } from '../utils/debug-build'; import { getFutureFlagsBrowser, readRemixVersionFromLoader } from '../utils/futureFlags'; -const DEFAULT_TAGS = { - 'routing.instrumentation': 'remix-router', -} as const; - -type Params = { +export type Params = { readonly [key in Key]: string | undefined; }; -interface RouteMatch { +export interface RouteMatch { params: Params; pathname: string; id: string; handle: unknown; } +export type UseEffect = (cb: () => void, deps: unknown[]) => void; -type UseEffect = (cb: () => void, deps: unknown[]) => void; -type UseLocation = () => { +export type UseLocation = () => { pathname: string; search?: string; hash?: string; state?: unknown; key?: unknown; }; -type UseMatches = () => RouteMatch[] | null; -let activeTransaction: Transaction | undefined; +export type UseMatches = () => RouteMatch[] | null; + +export type RemixBrowserTracingIntegrationOptions = Partial[0]> & { + useEffect?: UseEffect; + useLocation?: UseLocation; + useMatches?: UseMatches; +}; + +const DEFAULT_TAGS = { + 'routing.instrumentation': 'remix-router', +} as const; -let _useEffect: UseEffect; -let _useLocation: UseLocation; -let _useMatches: UseMatches; +let _useEffect: UseEffect | undefined; +let _useLocation: UseLocation | undefined; +let _useMatches: UseMatches | undefined; -let _customStartTransaction: (context: TransactionContext) => Transaction | undefined; -let _startTransactionOnLocationChange: boolean; +let _customStartTransaction: ((context: TransactionContext) => Span | undefined) | undefined; +let _instrumentNavigation: boolean | undefined; function getInitPathName(): string | undefined { if (WINDOW && WINDOW.location) { @@ -54,7 +66,65 @@ function isRemixV2(remixVersion: number | undefined): boolean { return remixVersion === 2 || getFutureFlagsBrowser()?.v2_errorBoundary || false; } +export function startPageloadSpan(): void { + const initPathName = getInitPathName(); + + if (!initPathName) { + return; + } + + const spanContext: StartSpanOptions = { + name: initPathName, + op: 'pageload', + origin: 'auto.pageload.remix', + tags: DEFAULT_TAGS, + metadata: { + source: 'url', + }, + }; + + // If _customStartTransaction is not defined, we know that we are using the browserTracingIntegration + if (!_customStartTransaction) { + const client = getClient(); + + if (!client) { + return; + } + + startBrowserTracingPageLoadSpan(client, spanContext); + } else { + _customStartTransaction(spanContext); + } +} + +function startNavigationSpan(matches: RouteMatch[]): void { + const spanContext: StartSpanOptions = { + name: matches[matches.length - 1].id, + op: 'navigation', + origin: 'auto.navigation.remix', + tags: DEFAULT_TAGS, + metadata: { + source: 'route', + }, + }; + + // If _customStartTransaction is not defined, we know that we are using the browserTracingIntegration + if (!_customStartTransaction) { + const client = getClient(); + + if (!client) { + return; + } + + startBrowserTracingNavigationSpan(client, spanContext); + } else { + _customStartTransaction(spanContext); + } +} + /** + * @deprecated Use `browserTracingIntegration` instead. + * * Creates a react-router v6 instrumention for Remix applications. * * This implementation is slightly different (and simpler) from the react-router instrumentation @@ -66,25 +136,17 @@ export function remixRouterInstrumentation(useEffect: UseEffect, useLocation: Us startTransactionOnPageLoad = true, startTransactionOnLocationChange = true, ): void => { - const initPathName = getInitPathName(); - if (startTransactionOnPageLoad && initPathName) { - activeTransaction = customStartTransaction({ - name: initPathName, - op: 'pageload', - origin: 'auto.pageload.remix', - tags: DEFAULT_TAGS, - metadata: { - source: 'url', - }, - }); - } - - _useEffect = useEffect; - _useLocation = useLocation; - _useMatches = useMatches; + setGlobals({ + useEffect, + useLocation, + useMatches, + instrumentNavigation: startTransactionOnLocationChange, + customStartTransaction, + }); - _customStartTransaction = customStartTransaction; - _startTransactionOnLocationChange = startTransactionOnLocationChange; + if (startTransactionOnPageLoad) { + startPageloadSpan(); + } }; } @@ -109,7 +171,7 @@ export function withSentry

, R extends React.Co ): R { const SentryRoot: React.FC

= (props: P) => { // Early return when any of the required functions is not available. - if (!_useEffect || !_useLocation || !_useMatches || !_customStartTransaction) { + if (!_useEffect || !_useLocation || !_useMatches) { DEBUG_BUILD && !isNodeEnv() && logger.warn('Remix SDK was unable to wrap your root because of one or more missing parameters.'); @@ -125,37 +187,37 @@ export function withSentry

, R extends React.Co const matches = _useMatches(); _useEffect(() => { - if (activeTransaction && matches && matches.length) { - activeTransaction.updateName(matches[matches.length - 1].id); - activeTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + const activeRootSpan = getActiveSpan(); + + if (activeRootSpan && matches && matches.length) { + const transaction = getRootSpan(activeRootSpan); + + if (transaction) { + transaction.updateName(matches[matches.length - 1].id); + transaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } } isBaseLocation = true; }, []); _useEffect(() => { + const activeRootSpan = getActiveSpan(); + if (isBaseLocation) { - if (activeTransaction) { - activeTransaction.end(); + if (activeRootSpan) { + activeRootSpan.end(); } return; } - if (_startTransactionOnLocationChange && matches && matches.length) { - if (activeTransaction) { - activeTransaction.end(); + if (_instrumentNavigation && matches && matches.length) { + if (activeRootSpan) { + activeRootSpan.end(); } - activeTransaction = _customStartTransaction({ - name: matches[matches.length - 1].id, - op: 'navigation', - origin: 'auto.navigation.remix', - tags: DEFAULT_TAGS, - metadata: { - source: 'route', - }, - }); + startNavigationSpan(matches); } }, [location]); @@ -175,3 +237,23 @@ export function withSentry

, R extends React.Co // will break advanced type inference done by react router params return SentryRoot; } + +export function setGlobals({ + useEffect, + useLocation, + useMatches, + instrumentNavigation, + customStartTransaction, +}: { + useEffect?: UseEffect; + useLocation?: UseLocation; + useMatches?: UseMatches; + instrumentNavigation?: boolean; + customStartTransaction?: (context: TransactionContext) => Span | undefined; +}): void { + _useEffect = useEffect; + _useLocation = useLocation; + _useMatches = useMatches; + _instrumentNavigation = instrumentNavigation; + _customStartTransaction = customStartTransaction; +} diff --git a/packages/remix/src/index.client.tsx b/packages/remix/src/index.client.tsx index 9c619ff1d851..3842e9a3701d 100644 --- a/packages/remix/src/index.client.tsx +++ b/packages/remix/src/index.client.tsx @@ -1,16 +1,26 @@ import { applySdkMetadata } from '@sentry/core'; import { getCurrentScope, init as reactInit } from '@sentry/react'; - import type { RemixOptions } from './utils/remixOptions'; -export { remixRouterInstrumentation, withSentry } from './client/performance'; export { captureRemixErrorBoundaryError } from './client/errors'; +export { + // eslint-disable-next-line deprecation/deprecation + remixRouterInstrumentation, + withSentry, +} from './client/performance'; + +export { browserTracingIntegration } from './client/browserTracingIntegration'; + export * from '@sentry/react'; export function init(options: RemixOptions): void { - applySdkMetadata(options, 'remix', ['remix', 'react']); - options.environment = options.environment || process.env.NODE_ENV; + const opts = { + ...options, + environment: options.environment || process.env.NODE_ENV, + }; + + applySdkMetadata(opts, 'remix', ['remix', 'react']); - reactInit(options); + reactInit(opts); getCurrentScope().setTag('runtime', 'browser'); } diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index da1e794690de..a34250100287 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -53,6 +53,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, withScope, @@ -74,6 +75,17 @@ export { // eslint-disable-next-line deprecation/deprecation deepReadDirSync, Integrations, + consoleIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + modulesIntegration, + contextLinesIntegration, + nodeContextIntegration, + localVariablesIntegration, + requestDataIntegration, + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, Handlers, setMeasurement, getActiveSpan, @@ -88,11 +100,7 @@ export { // eslint-disable-next-line deprecation/deprecation getModuleFromFilename, createGetModuleFromFilename, - functionToStringIntegration, hapiErrorPlugin, - inboundFiltersIntegration, - linkedErrorsIntegration, - requestDataIntegration, runWithAsyncContext, // eslint-disable-next-line deprecation/deprecation enableAnrDetection, @@ -103,8 +111,10 @@ export * from '@sentry/node'; export { captureRemixServerException, wrapRemixHandleError } from './utils/instrumentServer'; export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; +// eslint-disable-next-line deprecation/deprecation export { remixRouterInstrumentation, withSentry } from './client/performance'; export { captureRemixErrorBoundaryError } from './client/errors'; +export { browserTracingIntegration } from './client/browserTracingIntegration'; export { wrapExpressCreateRequestHandler } from './utils/serverAdapters/express'; export type { SentryMetaArgs } from './utils/types'; diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 1ed13e9f28ec..94e8090ac433 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -401,6 +401,7 @@ export function startRequestHandlerTransaction( method: string; }, ): Transaction { + // eslint-disable-next-line deprecation/deprecation const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( request.headers['sentry-trace'], request.headers.baggage, diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/entry.client.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/entry.client.tsx new file mode 100644 index 000000000000..7273433127ac --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { useEffect } from 'react'; +import { hydrate } from 'react-dom'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [Sentry.browserTracingIntegration({ useEffect, useLocation, useMatches })], +}); + +hydrate(, document); diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/entry.server.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/entry.server.tsx new file mode 100644 index 000000000000..bba366801092 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/entry.server.tsx @@ -0,0 +1,30 @@ +import type { EntryContext } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { renderToString } from 'react-dom/server'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + tracePropagationTargets: ['example.org'], + // Disabling to test series of envelopes deterministically. + autoSessionTracking: false, +}); + +export const handleError = Sentry.wrapRemixHandleError; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + let markup = renderToString(); + + responseHeaders.set('Content-Type', 'text/html'); + + return new Response('' + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/root.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/root.tsx new file mode 100644 index 000000000000..15b78b8a6325 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/root.tsx @@ -0,0 +1,73 @@ +import { LoaderFunction, V2_MetaFunction, defer, json, redirect } from '@remix-run/node'; +import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react'; +import { V2_ErrorBoundaryComponent } from '@remix-run/react/dist/routeModules'; +import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; + +export const ErrorBoundary: V2_ErrorBoundaryComponent = () => { + const error = useRouteError(); + + captureRemixErrorBoundaryError(error); + + return

error
; +}; + +export const meta: V2_MetaFunction = ({ data }) => [ + { charset: 'utf-8' }, + { title: 'New Remix App' }, + { name: 'viewport', content: 'width=device-width,initial-scale=1' }, + { name: 'sentry-trace', content: data.sentryTrace }, + { name: 'baggage', content: data.sentryBaggage }, +]; + +export const loader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const type = url.searchParams.get('type'); + + switch (type) { + case 'empty': + return {}; + case 'plain': + return { + data_one: [], + data_two: 'a string', + }; + case 'json': + return json({ data_one: [], data_two: 'a string' }, { headers: { 'Cache-Control': 'max-age=300' } }); + case 'defer': + return defer({ data_one: [], data_two: 'a string' }); + case 'null': + return null; + case 'undefined': + return undefined; + case 'throwRedirect': + throw redirect('/?type=plain'); + case 'returnRedirect': + return redirect('/?type=plain'); + case 'throwRedirectToExternal': + throw redirect('https://example.com'); + case 'returnRedirectToExternal': + return redirect('https://example.com'); + default: { + return {}; + } + } +}; + +function App() { + return ( + + + + + + + + + + + + + ); +} + +export default withSentry(App); diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/action-json-response.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/action-json-response.$id.tsx new file mode 100644 index 000000000000..7a00bfb2bfe7 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/action-json-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/action-json-response.$id'; +export { default } from '../../common/routes/action-json-response.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/capture-exception.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/capture-exception.tsx new file mode 100644 index 000000000000..1ba745d2e63d --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/capture-exception.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-exception'; +export { default } from '../../common/routes/capture-exception'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/capture-message.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/capture-message.tsx new file mode 100644 index 000000000000..9dae2318cc14 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/capture-message.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-message'; +export { default } from '../../common/routes/capture-message'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/error-boundary-capture.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/error-boundary-capture.$id.tsx new file mode 100644 index 000000000000..011f92462069 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/error-boundary-capture.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/error-boundary-capture.$id'; +export { default } from '../../common/routes/error-boundary-capture.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/index.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/index.tsx new file mode 100644 index 000000000000..22c086a4c2cf --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/index.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/index'; +export { default } from '../../common/routes/index'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-defer-response.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-defer-response.$id.tsx new file mode 100644 index 000000000000..69499e594ccc --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-defer-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/loader-defer-response.$id'; +export { default } from '../../common/routes/loader-defer-response.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-json-response.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-json-response.$id.tsx new file mode 100644 index 000000000000..7761875bdb76 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-json-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/loader-json-response.$id'; +export { default } from '../../common/routes/loader-json-response.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-throw-response.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-throw-response.$id.tsx new file mode 100644 index 000000000000..6b9a6a85cbef --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/loader-throw-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/loader-throw-response.$id'; +export { default } from '../../common/routes/loader-throw-response.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/manual-tracing.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/manual-tracing.$id.tsx new file mode 100644 index 000000000000..a7cfebe4ed46 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/manual-tracing.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/manual-tracing.$id'; +export { default } from '../../common/routes/manual-tracing.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/scope-bleed.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/scope-bleed.$id.tsx new file mode 100644 index 000000000000..5ba2376f0339 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/scope-bleed.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/scope-bleed.$id'; +export { default } from '../../common/routes/scope-bleed.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/server-side-unexpected-errors.$id.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/server-side-unexpected-errors.$id.tsx new file mode 100644 index 000000000000..d9571c68ddd5 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/server-side-unexpected-errors.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/server-side-unexpected-errors.$id'; +export { default } from '../../common/routes/server-side-unexpected-errors.$id'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/ssr-error.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/ssr-error.tsx new file mode 100644 index 000000000000..627f7e126871 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/ssr-error.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/ssr-error'; +export { default } from '../../common/routes/ssr-error'; diff --git a/packages/remix/test/integration/app_v2_tracingIntegration/routes/throw-redirect.tsx b/packages/remix/test/integration/app_v2_tracingIntegration/routes/throw-redirect.tsx new file mode 100644 index 000000000000..4425f3432b58 --- /dev/null +++ b/packages/remix/test/integration/app_v2_tracingIntegration/routes/throw-redirect.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/throw-redirect'; +export { default } from '../../common/routes/throw-redirect'; diff --git a/packages/remix/test/integration/remix.config.js b/packages/remix/test/integration/remix.config.js index b4c7ac0837b8..418d3690f696 100644 --- a/packages/remix/test/integration/remix.config.js +++ b/packages/remix/test/integration/remix.config.js @@ -1,8 +1,9 @@ /** @type {import('@remix-run/dev').AppConfig} */ const useV2 = process.env.REMIX_VERSION === '2'; +const useBrowserTracing = process.env.TRACING_INTEGRATION === 'true'; module.exports = { - appDirectory: useV2 ? 'app_v2' : 'app_v1', + appDirectory: useBrowserTracing ? 'app_v2_tracingIntegration' : useV2 ? 'app_v2' : 'app_v1', assetsBuildDirectory: 'public/build', serverBuildPath: 'build/index.js', publicPath: '/build/', diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index b7f4c6a7675a..12eb2aaa5017 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -56,7 +56,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.17.5", - "@sentry-internal/rrweb": "2.9.0" + "@sentry-internal/rrweb": "2.11.0" }, "dependencies": { "@sentry/core": "7.99.0", diff --git a/packages/replay/package.json b/packages/replay/package.json index 9615a3baf8b6..7447e60debb9 100644 --- a/packages/replay/package.json +++ b/packages/replay/package.json @@ -54,8 +54,8 @@ "devDependencies": { "@babel/core": "^7.17.5", "@sentry-internal/replay-worker": "7.99.0", - "@sentry-internal/rrweb": "2.9.0", - "@sentry-internal/rrweb-snapshot": "2.9.0", + "@sentry-internal/rrweb": "2.11.0", + "@sentry-internal/rrweb-snapshot": "2.11.0", "fflate": "^0.8.1", "jsdom-worker": "^0.2.1" }, diff --git a/packages/serverless/src/awslambda.ts b/packages/serverless/src/awslambda.ts index 10f0e0e29c81..240dff84eebc 100644 --- a/packages/serverless/src/awslambda.ts +++ b/packages/serverless/src/awslambda.ts @@ -354,24 +354,23 @@ export function wrapHandler( : undefined; const baggage = eventWithHeaders.headers?.baggage; - const continueTraceContext = continueTrace({ sentryTrace, baggage }); - - return startSpanManual( - { - name: context.functionName, - op: 'function.aws.lambda', - ...continueTraceContext, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', + return continueTrace({ sentryTrace, baggage }, () => { + return startSpanManual( + { + name: context.functionName, + op: 'function.aws.lambda', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', + }, }, - }, - span => { - enhanceScopeWithTransactionData(getCurrentScope(), context); + span => { + enhanceScopeWithTransactionData(getCurrentScope(), context); - return processResult(span); - }, - ); + return processResult(span); + }, + ); + }); } return withScope(async () => { diff --git a/packages/serverless/src/gcpfunction/http.ts b/packages/serverless/src/gcpfunction/http.ts index a90dd3a0423c..e02093acf438 100644 --- a/packages/serverless/src/gcpfunction/http.ts +++ b/packages/serverless/src/gcpfunction/http.ts @@ -77,58 +77,57 @@ function _wrapHttpFunction(fn: HttpFunction, wrapOptions: Partial { + return startSpanManual( + { + name: `${reqMethod} ${reqUrl}`, + op: 'function.gcp.http', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', + }, }, - }, - span => { - getCurrentScope().setSDKProcessingMetadata({ - request: req, - requestDataOptionsFromGCPWrapper: options.addRequestDataToEventOptions, - }); - - if (span instanceof Transaction) { - // We also set __sentry_transaction on the response so people can grab the transaction there to add - // spans to it later. - // TODO(v8): Remove this - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - (res as any).__sentry_transaction = span; - } - - // eslint-disable-next-line @typescript-eslint/unbound-method - const _end = res.end; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - res.end = function (chunk?: any | (() => void), encoding?: string | (() => void), cb?: () => void): any { - if (span) { - setHttpStatus(span, res.statusCode); - span.end(); + span => { + getCurrentScope().setSDKProcessingMetadata({ + request: req, + requestDataOptionsFromGCPWrapper: options.addRequestDataToEventOptions, + }); + + if (span instanceof Transaction) { + // We also set __sentry_transaction on the response so people can grab the transaction there to add + // spans to it later. + // TODO(v8): Remove this + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (res as any).__sentry_transaction = span; } - // eslint-disable-next-line @typescript-eslint/no-floating-promises - flush(options.flushTimeout) - .then(null, e => { - DEBUG_BUILD && logger.error(e); - }) - .then(() => { - _end.call(this, chunk, encoding, cb); - }); - }; - - return handleCallbackErrors( - () => fn(req, res), - err => { - captureException(err, scope => markEventUnhandled(scope)); - }, - ); - }, - ); + // eslint-disable-next-line @typescript-eslint/unbound-method + const _end = res.end; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.end = function (chunk?: any | (() => void), encoding?: string | (() => void), cb?: () => void): any { + if (span) { + setHttpStatus(span, res.statusCode); + span.end(); + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + flush(options.flushTimeout) + .then(null, e => { + DEBUG_BUILD && logger.error(e); + }) + .then(() => { + _end.call(this, chunk, encoding, cb); + }); + }; + + return handleCallbackErrors( + () => fn(req, res), + err => { + captureException(err, scope => markEventUnhandled(scope)); + }, + ); + }, + ); + }); }; } diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index abc135a6b750..24ee21115f0b 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -41,6 +41,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation makeMain, setCurrentClient, @@ -72,6 +73,7 @@ export { // eslint-disable-next-line deprecation/deprecation deepReadDirSync, Handlers, + // eslint-disable-next-line deprecation/deprecation Integrations, setMeasurement, getActiveSpan, @@ -93,4 +95,16 @@ export { // eslint-disable-next-line deprecation/deprecation extractTraceparentData, runWithAsyncContext, + consoleIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + modulesIntegration, + contextLinesIntegration, + nodeContextIntegration, + localVariablesIntegration, + anrIntegration, + hapiIntegration, + httpIntegration, + nativeNodeFetchintegration, + spotlightIntegration, } from '@sentry/node'; diff --git a/packages/serverless/test/awslambda.test.ts b/packages/serverless/test/awslambda.test.ts index 57feede5a102..772502057a34 100644 --- a/packages/serverless/test/awslambda.test.ts +++ b/packages/serverless/test/awslambda.test.ts @@ -247,7 +247,6 @@ describe('AWSLambda', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', }, - metadata: {}, }; expect(rv).toStrictEqual(42); @@ -277,7 +276,6 @@ describe('AWSLambda', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', }, - metadata: {}, }; expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); @@ -301,42 +299,6 @@ describe('AWSLambda', () => { await wrappedHandler(fakeEvent, fakeContext, fakeCallback); }); - test('incoming trace headers are correctly parsed and used', async () => { - expect.assertions(1); - - fakeEvent.headers = { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', - baggage: 'sentry-release=2.12.1,maisey=silly,charlie=goofy', - }; - - const handler: Handler = (_event, _context, callback) => { - expect(mockStartSpanManual).toBeCalledWith( - expect.objectContaining({ - parentSpanId: '1121201211212012', - parentSampled: false, - op: 'function.aws.lambda', - name: 'functionName', - traceId: '12312012123120121231201212312012', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', - }, - metadata: { - dynamicSamplingContext: { - release: '2.12.1', - }, - }, - }), - expect.any(Function), - ); - - callback(undefined, { its: 'fine' }); - }; - - const wrappedHandler = wrapHandler(handler); - await wrappedHandler(fakeEvent, fakeContext, fakeCallback); - }); - test('capture error', async () => { expect.assertions(10); @@ -347,20 +309,15 @@ describe('AWSLambda', () => { const wrappedHandler = wrapHandler(handler); try { - fakeEvent.headers = { 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0' }; await wrappedHandler(fakeEvent, fakeContext, fakeCallback); } catch (e) { const fakeTransactionContext = { name: 'functionName', op: 'function.aws.lambda', - traceId: '12312012123120121231201212312012', - parentSpanId: '1121201211212012', - parentSampled: false, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', }, - metadata: { dynamicSamplingContext: {} }, }; expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); @@ -390,7 +347,6 @@ describe('AWSLambda', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', }, - metadata: {}, }; expect(rv).toStrictEqual(42); @@ -431,7 +387,6 @@ describe('AWSLambda', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', }, - metadata: {}, }; expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); @@ -474,7 +429,6 @@ describe('AWSLambda', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', }, - metadata: {}, }; expect(rv).toStrictEqual(42); @@ -515,7 +469,6 @@ describe('AWSLambda', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless', }, - metadata: {}, }; expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); diff --git a/packages/serverless/test/gcpfunction.test.ts b/packages/serverless/test/gcpfunction.test.ts index 1fc58c37fdce..cde69e6b22d2 100644 --- a/packages/serverless/test/gcpfunction.test.ts +++ b/packages/serverless/test/gcpfunction.test.ts @@ -164,7 +164,6 @@ describe('GCPFunction', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', }, - metadata: {}, }; expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); @@ -172,38 +171,6 @@ describe('GCPFunction', () => { expect(mockFlush).toBeCalledWith(2000); }); - test('incoming trace headers are correctly parsed and used', async () => { - const handler: HttpFunction = (_req, res) => { - res.statusCode = 200; - res.end(); - }; - const wrappedHandler = wrapHttpFunction(handler); - const traceHeaders = { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', - baggage: 'sentry-release=2.12.1,maisey=silly,charlie=goofy', - }; - await handleHttp(wrappedHandler, traceHeaders); - - const fakeTransactionContext = { - name: 'POST /path', - op: 'function.gcp.http', - traceId: '12312012123120121231201212312012', - parentSpanId: '1121201211212012', - parentSampled: false, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', - }, - metadata: { - dynamicSamplingContext: { - release: '2.12.1', - }, - }, - }; - - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); - }); - test('capture error', async () => { const error = new Error('wat'); const handler: HttpFunction = (_req, _res) => { @@ -211,23 +178,15 @@ describe('GCPFunction', () => { }; const wrappedHandler = wrapHttpFunction(handler); - const trace_headers: { [key: string]: string } = { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', - }; - - await handleHttp(wrappedHandler, trace_headers); + await handleHttp(wrappedHandler); const fakeTransactionContext = { name: 'POST /path', op: 'function.gcp.http', - traceId: '12312012123120121231201212312012', - parentSpanId: '1121201211212012', - parentSampled: false, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', }, - metadata: { dynamicSamplingContext: {} }, }; expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); diff --git a/packages/sveltekit/src/client/browserTracingIntegration.ts b/packages/sveltekit/src/client/browserTracingIntegration.ts index 9968f8b6de5f..5e80cdc92f28 100644 --- a/packages/sveltekit/src/client/browserTracingIntegration.ts +++ b/packages/sveltekit/src/client/browserTracingIntegration.ts @@ -1,14 +1,164 @@ -import { BrowserTracing as OriginalBrowserTracing } from '@sentry/svelte'; +import { navigating, page } from '$app/stores'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + BrowserTracing as OriginalBrowserTracing, + WINDOW, + browserTracingIntegration as originalBrowserTracingIntegration, + getActiveSpan, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + startInactiveSpan, +} from '@sentry/svelte'; +import type { Client, Integration, Span } from '@sentry/types'; import { svelteKitRoutingInstrumentation } from './router'; /** * A custom BrowserTracing integration for Sveltekit. + * + * @deprecated use `browserTracingIntegration()` instead. The new `browserTracingIntegration()` + * includes SvelteKit-specific routing instrumentation out of the box. Therefore there's no need + * to pass in `svelteKitRoutingInstrumentation` anymore. */ +// eslint-disable-next-line deprecation/deprecation export class BrowserTracing extends OriginalBrowserTracing { + // eslint-disable-next-line deprecation/deprecation public constructor(options?: ConstructorParameters[0]) { super({ + // eslint-disable-next-line deprecation/deprecation routingInstrumentation: svelteKitRoutingInstrumentation, ...options, }); } } + +/** + * A custom `BrowserTracing` integration for SvelteKit. + */ +export function browserTracingIntegration( + options: Parameters[0] = {}, +): Integration { + const integration = { + ...originalBrowserTracingIntegration({ + ...options, + instrumentNavigation: false, + instrumentPageLoad: false, + }), + }; + + return { + ...integration, + afterAllSetup: client => { + integration.afterAllSetup(client); + + if (options.instrumentPageLoad !== false) { + _instrumentPageload(client); + } + + if (options.instrumentNavigation !== false) { + _instrumentNavigations(client); + } + }, + }; +} + +function _instrumentPageload(client: Client): void { + const initialPath = WINDOW && WINDOW.location && WINDOW.location.pathname; + + startBrowserTracingPageLoadSpan(client, { + name: initialPath, + op: 'pageload', + description: initialPath, + tags: { + 'routing.instrumentation': '@sentry/sveltekit', + }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + + const pageloadSpan = getActiveSpan(); + + page.subscribe(page => { + if (!page) { + return; + } + + const routeId = page.route && page.route.id; + + if (pageloadSpan && routeId) { + pageloadSpan.updateName(routeId); + pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } + }); +} + +/** + * Use the `navigating` store to start a transaction on navigations. + */ +function _instrumentNavigations(client: Client): void { + let routingSpan: Span | undefined; + let activeSpan: Span | undefined; + + navigating.subscribe(navigation => { + if (!navigation) { + // `navigating` emits a 'null' value when the navigation is completed. + // So in this case, we can finish the routing span. If the transaction was an IdleTransaction, + // it will finish automatically and if it was user-created users also need to finish it. + if (routingSpan) { + routingSpan.end(); + routingSpan = undefined; + } + return; + } + + const from = navigation.from; + const to = navigation.to; + + // for the origin we can fall back to window.location.pathname because in this emission, it still is set to the origin path + const rawRouteOrigin = (from && from.url.pathname) || (WINDOW && WINDOW.location && WINDOW.location.pathname); + + const rawRouteDestination = to && to.url.pathname; + + // We don't want to create transactions for navigations of same origin and destination. + // We need to look at the raw URL here because parameterized routes can still differ in their raw parameters. + if (rawRouteOrigin === rawRouteDestination) { + return; + } + + const parameterizedRouteOrigin = from && from.route.id; + const parameterizedRouteDestination = to && to.route.id; + + activeSpan = getActiveSpan(); + + if (!activeSpan) { + startBrowserTracingNavigationSpan(client, { + name: parameterizedRouteDestination || rawRouteDestination || 'unknown', + op: 'navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedRouteDestination ? 'route' : 'url', + }, + tags: { + 'routing.instrumentation': '@sentry/sveltekit', + }, + }); + activeSpan = getActiveSpan(); + } + + if (activeSpan) { + if (routingSpan) { + // If a routing span is still open from a previous navigation, we finish it. + routingSpan.end(); + } + routingSpan = startInactiveSpan({ + op: 'ui.sveltekit.routing', + name: 'SvelteKit Route Change', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', + }, + }); + activeSpan.setAttribute('sentry.sveltekit.navigation.from', parameterizedRouteOrigin || undefined); + } + }); +} diff --git a/packages/sveltekit/src/client/index.ts b/packages/sveltekit/src/client/index.ts index f60a353d8b1d..558526b1f318 100644 --- a/packages/sveltekit/src/client/index.ts +++ b/packages/sveltekit/src/client/index.ts @@ -3,3 +3,4 @@ export * from '@sentry/svelte'; export { init } from './sdk'; export { handleErrorWithSentry } from './handleError'; export { wrapLoadWithSentry } from './load'; +export { browserTracingIntegration } from './browserTracingIntegration'; diff --git a/packages/sveltekit/src/client/router.ts b/packages/sveltekit/src/client/router.ts index 2b36d4adb4f2..593eeb97b1a2 100644 --- a/packages/sveltekit/src/client/router.ts +++ b/packages/sveltekit/src/client/router.ts @@ -17,6 +17,9 @@ const DEFAULT_TAGS = { * @param startTransactionFn the function used to start (idle) transactions * @param startTransactionOnPageLoad controls if pageload transactions should be created (defaults to `true`) * @param startTransactionOnLocationChange controls if navigation transactions should be created (defauls to `true`) + * + * @deprecated use `browserTracingIntegration()` instead which includes SvelteKit-specific routing instrumentation out of the box. + * Therefore, this function will be removed in v8. */ export function svelteKitRoutingInstrumentation( startTransactionFn: (context: TransactionContext) => T | undefined, diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index 920b2db75193..b0dc7ee6af2d 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -4,7 +4,10 @@ import { getDefaultIntegrations as getDefaultSvelteIntegrations } from '@sentry/ import { WINDOW, getCurrentScope, init as initSvelteSdk } from '@sentry/svelte'; import type { Integration } from '@sentry/types'; -import { BrowserTracing } from './browserTracingIntegration'; +import { + BrowserTracing, + browserTracingIntegration as svelteKitBrowserTracingIntegration, +} from './browserTracingIntegration'; type WindowWithSentryFetchProxy = typeof WINDOW & { _sentryFetchProxy?: typeof fetch; @@ -64,6 +67,7 @@ function fixBrowserTracingIntegration(options: BrowserOptions): void { function isNewBrowserTracingIntegration( integration: Integration, ): integration is Integration & { options?: Parameters[0] } { + // eslint-disable-next-line deprecation/deprecation return !!integration.afterAllSetup && !!(integration as BrowserTracing).options; } @@ -77,15 +81,19 @@ function maybeUpdateBrowserTracingIntegration(integrations: Integration[]): Inte // If `browserTracingIntegration()` was added, we need to force-convert it to our custom one if (isNewBrowserTracingIntegration(browserTracing)) { const { options } = browserTracing; + // eslint-disable-next-line deprecation/deprecation integrations[integrations.indexOf(browserTracing)] = new BrowserTracing(options); } // If BrowserTracing was added, but it is not our forked version, // replace it with our forked version with the same options + // eslint-disable-next-line deprecation/deprecation if (!(browserTracing instanceof BrowserTracing)) { + // eslint-disable-next-line deprecation/deprecation const options: ConstructorParameters[0] = (browserTracing as BrowserTracing).options; // This option is overwritten by the custom integration delete options.routingInstrumentation; + // eslint-disable-next-line deprecation/deprecation integrations[integrations.indexOf(browserTracing)] = new BrowserTracing(options); } @@ -97,7 +105,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] | undefi // will get treeshaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { if (hasTracingEnabled(options)) { - return [...getDefaultSvelteIntegrations(options), new BrowserTracing()]; + return [...getDefaultSvelteIntegrations(options), svelteKitBrowserTracingIntegration()]; } } diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 32fcec426df4..7a886334cfbc 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -47,6 +47,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, withScope, @@ -68,6 +69,17 @@ export { // eslint-disable-next-line deprecation/deprecation deepReadDirSync, Integrations, + consoleIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + modulesIntegration, + contextLinesIntegration, + nodeContextIntegration, + localVariablesIntegration, + requestDataIntegration, + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, Handlers, setMeasurement, getActiveSpan, @@ -82,11 +94,7 @@ export { // eslint-disable-next-line deprecation/deprecation getModuleFromFilename, createGetModuleFromFilename, - functionToStringIntegration, hapiErrorPlugin, - inboundFiltersIntegration, - linkedErrorsIntegration, - requestDataIntegration, metrics, runWithAsyncContext, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/sveltekit/src/server/utils.ts b/packages/sveltekit/src/server/utils.ts index 4106f7f4a09c..1f3719745bca 100644 --- a/packages/sveltekit/src/server/utils.ts +++ b/packages/sveltekit/src/server/utils.ts @@ -10,9 +10,11 @@ import { DEBUG_BUILD } from '../common/debug-build'; * * Sets propagation context as a side effect. */ +// eslint-disable-next-line deprecation/deprecation export function getTracePropagationData(event: RequestEvent): ReturnType { const sentryTraceHeader = event.request.headers.get('sentry-trace') || ''; const baggageHeader = event.request.headers.get('baggage'); + // eslint-disable-next-line deprecation/deprecation return tracingContextFromHeaders(sentryTraceHeader, baggageHeader); } diff --git a/packages/sveltekit/test/client/browserTracingIntegration.test.ts b/packages/sveltekit/test/client/browserTracingIntegration.test.ts new file mode 100644 index 000000000000..83984c0b19f5 --- /dev/null +++ b/packages/sveltekit/test/client/browserTracingIntegration.test.ts @@ -0,0 +1,285 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { Span } from '@sentry/types'; +import { writable } from 'svelte/store'; +import { vi } from 'vitest'; + +import { navigating, page } from '$app/stores'; + +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { browserTracingIntegration } from '../../src/client'; + +import * as SentrySvelte from '@sentry/svelte'; + +// we have to overwrite the global mock from `vitest.setup.ts` here to reset the +// `navigating` store for each test. +vi.mock('$app/stores', async () => { + return { + get navigating() { + return navigatingStore; + }, + page: writable(), + }; +}); + +let navigatingStore = writable(); + +describe('browserTracingIntegration', () => { + const svelteBrowserTracingIntegrationSpy = vi.spyOn(SentrySvelte, 'browserTracingIntegration'); + + let createdRootSpan: Partial | undefined; + + // @ts-expect-error - only returning a partial span here, that's fine + vi.spyOn(SentrySvelte, 'getActiveSpan').mockImplementation(() => { + return createdRootSpan; + }); + + const startBrowserTracingPageLoadSpanSpy = vi + .spyOn(SentrySvelte, 'startBrowserTracingPageLoadSpan') + .mockImplementation((_client, txnCtx) => { + createdRootSpan = { + ...txnCtx, + updateName: vi.fn(), + setAttribute: vi.fn(), + startChild: vi.fn().mockImplementation(ctx => { + return { ...mockedRoutingSpan, ...ctx }; + }), + setTag: vi.fn(), + }; + }); + + const startBrowserTracingNavigationSpanSpy = vi + .spyOn(SentrySvelte, 'startBrowserTracingNavigationSpan') + .mockImplementation((_client, txnCtx) => { + createdRootSpan = { + ...txnCtx, + updateName: vi.fn(), + setAttribute: vi.fn(), + setTag: vi.fn(), + }; + }); + + const fakeClient = { getOptions: () => undefined }; + + const mockedRoutingSpan = { + end: () => {}, + }; + + const routingSpanEndSpy = vi.spyOn(mockedRoutingSpan, 'end'); + + // @ts-expect-error - mockedRoutingSpan is not a complete Span, that's fine + const startInactiveSpanSpy = vi.spyOn(SentrySvelte, 'startInactiveSpan').mockImplementation(() => mockedRoutingSpan); + + beforeEach(() => { + createdRootSpan = undefined; + navigatingStore = writable(); + vi.clearAllMocks(); + }); + + it('implements required hooks', () => { + const integration = browserTracingIntegration(); + expect(integration.name).toEqual('BrowserTracing'); + expect(integration.setupOnce).toBeDefined(); + expect(integration.afterAllSetup).toBeDefined(); + }); + + it('passes on the options to the original integration', () => { + browserTracingIntegration({ enableLongTask: true, idleTimeout: 4242 }); + expect(svelteBrowserTracingIntegrationSpy).toHaveBeenCalledTimes(1); + expect(svelteBrowserTracingIntegrationSpy).toHaveBeenCalledWith({ + enableLongTask: true, + idleTimeout: 4242, + instrumentNavigation: false, + instrumentPageLoad: false, + }); + }); + + it('always disables `instrumentNavigation` and `instrumentPageLoad` in the original integration', () => { + browserTracingIntegration({ instrumentNavigation: true, instrumentPageLoad: true }); + expect(svelteBrowserTracingIntegrationSpy).toHaveBeenCalledTimes(1); + // This is fine and expected because we don't want to start the default instrumentation + // SvelteKit's browserTracingIntegration takes care of instrumenting pageloads and navigations on its own. + expect(svelteBrowserTracingIntegrationSpy).toHaveBeenCalledWith({ + instrumentNavigation: false, + instrumentPageLoad: false, + }); + }); + + it("starts a pageload span when it's called with default params", () => { + const integration = browserTracingIntegration(); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + + expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledTimes(1); + expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledWith(fakeClient, { + name: '/', + op: 'pageload', + description: '/', + tags: { + 'routing.instrumentation': '@sentry/sveltekit', + }, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + + // We emit an update to the `page` store to simulate the SvelteKit router lifecycle + // @ts-expect-error - page is a writable but the types say it's just readable + page.set({ route: { id: 'testRoute' } }); + + // This should update the transaction name with the parameterized route: + expect(createdRootSpan?.updateName).toHaveBeenCalledTimes(1); + expect(createdRootSpan?.updateName).toHaveBeenCalledWith('testRoute'); + expect(createdRootSpan?.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + + it("doesn't start a pageload span if `instrumentPageLoad` is false", () => { + const integration = browserTracingIntegration({ + instrumentPageLoad: false, + }); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + + expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledTimes(0); + }); + + it("doesn't start a navigation span when `instrumentNavigation` is false", () => { + const integration = browserTracingIntegration({ + instrumentNavigation: false, + }); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + + // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle + // @ts-expect-error - page is a writable but the types say it's just readable + navigating.set({ + from: { route: { id: '/users' }, url: { pathname: '/users' } }, + to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, + }); + + // This should update the transaction name with the parameterized route: + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(0); + }); + + it('starts a navigation span when `startTransactionOnLocationChange` is true', () => { + const integration = browserTracingIntegration({ + instrumentPageLoad: false, + }); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + + // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle + // @ts-expect-error - page is a writable but the types say it's just readable + navigating.set({ + from: { route: { id: '/users' }, url: { pathname: '/users' } }, + to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, + }); + + // This should update the transaction name with the parameterized route: + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledWith(fakeClient, { + name: '/users/[id]', + op: 'navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }, + tags: { + 'routing.instrumentation': '@sentry/sveltekit', + }, + }); + + // eslint-disable-next-line deprecation/deprecation + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + op: 'ui.sveltekit.routing', + name: 'SvelteKit Route Change', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', + }, + }); + + // eslint-disable-next-line deprecation/deprecation + expect(createdRootSpan?.setAttribute).toHaveBeenCalledWith('sentry.sveltekit.navigation.from', '/users'); + + // We emit `null` here to simulate the end of the navigation lifecycle + // @ts-expect-error - page is a writable but the types say it's just readable + navigating.set(null); + + expect(routingSpanEndSpy).toHaveBeenCalledTimes(1); + }); + + describe('handling same origin and destination navigations', () => { + it("doesn't start a navigation span if the raw navigation origin and destination are equal", () => { + const integration = browserTracingIntegration({ + instrumentPageLoad: false, + }); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + + // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle + // @ts-expect-error - page is a writable but the types say it's just readable + navigating.set({ + from: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, + to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, + }); + + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(0); + }); + + it('starts a navigation transaction if the raw navigation origin and destination are not equal', () => { + const integration = browserTracingIntegration({ + instrumentPageLoad: false, + }); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + + // @ts-expect-error - page is a writable but the types say it's just readable + navigating.set({ + from: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, + to: { route: { id: '/users/[id]' }, url: { pathname: '/users/223412' } }, + }); + + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledWith(fakeClient, { + name: '/users/[id]', + op: 'navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + }, + tags: { + 'routing.instrumentation': '@sentry/sveltekit', + }, + }); + + // eslint-disable-next-line deprecation/deprecation + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + op: 'ui.sveltekit.routing', + name: 'SvelteKit Route Change', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', + }, + }); + + // eslint-disable-next-line deprecation/deprecation + expect(createdRootSpan?.setAttribute).toHaveBeenCalledWith('sentry.sveltekit.navigation.from', '/users/[id]'); + }); + + it('falls back to `window.location.pathname` to determine the raw origin', () => { + const integration = browserTracingIntegration({ + instrumentPageLoad: false, + }); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(fakeClient); + + // window.location.pathame is "/" in tests + + // @ts-expect-error - page is a writable but the types say it's just readable + navigating.set({ + to: { route: {}, url: { pathname: '/' } }, + }); + + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/sveltekit/test/client/router.test.ts b/packages/sveltekit/test/client/router.test.ts index 29037c28461f..a359a9dedbf0 100644 --- a/packages/sveltekit/test/client/router.test.ts +++ b/packages/sveltekit/test/client/router.test.ts @@ -49,6 +49,7 @@ describe('sveltekitRoutingInstrumentation', () => { }); it("starts a pageload transaction when it's called with default params", () => { + // eslint-disable-next-line deprecation/deprecation svelteKitRoutingInstrumentation(mockedStartTransaction); expect(mockedStartTransaction).toHaveBeenCalledTimes(1); @@ -66,7 +67,6 @@ describe('sveltekitRoutingInstrumentation', () => { }); // We emit an update to the `page` store to simulate the SvelteKit router lifecycle - // @ts-expect-error This is fine because we testUtils/stores.ts defines `page` as a writable store page.set({ route: { id: 'testRoute' } }); // This should update the transaction name with the parameterized route: @@ -76,15 +76,16 @@ describe('sveltekitRoutingInstrumentation', () => { }); it("doesn't start a pageload transaction if `startTransactionOnPageLoad` is false", () => { + // eslint-disable-next-line deprecation/deprecation svelteKitRoutingInstrumentation(mockedStartTransaction, false); expect(mockedStartTransaction).toHaveBeenCalledTimes(0); }); it("doesn't start a navigation transaction when `startTransactionOnLocationChange` is false", () => { + // eslint-disable-next-line deprecation/deprecation svelteKitRoutingInstrumentation(mockedStartTransaction, false, false); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle - // @ts-expect-error This is fine because we testUtils/stores.ts defines `navigating` as a writable store navigating.set({ from: { route: { id: '/users' }, url: { pathname: '/users' } }, to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, @@ -95,10 +96,10 @@ describe('sveltekitRoutingInstrumentation', () => { }); it('starts a navigation transaction when `startTransactionOnLocationChange` is true', () => { + // eslint-disable-next-line deprecation/deprecation svelteKitRoutingInstrumentation(mockedStartTransaction, false, true); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle - // @ts-expect-error This is fine because we testUtils/stores.ts defines `navigating` as a writable store navigating.set({ from: { route: { id: '/users' }, url: { pathname: '/users' } }, to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, @@ -127,7 +128,6 @@ describe('sveltekitRoutingInstrumentation', () => { expect(returnedTransaction?.setTag).toHaveBeenCalledWith('from', '/users'); // We emit `null` here to simulate the end of the navigation lifecycle - // @ts-expect-error this is fine navigating.set(null); expect(routingSpanFinishSpy).toHaveBeenCalledTimes(1); @@ -135,10 +135,10 @@ describe('sveltekitRoutingInstrumentation', () => { describe('handling same origin and destination navigations', () => { it("doesn't start a navigation transaction if the raw navigation origin and destination are equal", () => { + // eslint-disable-next-line deprecation/deprecation svelteKitRoutingInstrumentation(mockedStartTransaction, false, true); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle - // @ts-expect-error This is fine because we testUtils/stores.ts defines `navigating` as a writable store navigating.set({ from: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, @@ -148,9 +148,9 @@ describe('sveltekitRoutingInstrumentation', () => { }); it('starts a navigation transaction if the raw navigation origin and destination are not equal', () => { + // eslint-disable-next-line deprecation/deprecation svelteKitRoutingInstrumentation(mockedStartTransaction, false, true); - // @ts-expect-error This is fine navigating.set({ from: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762' } }, to: { route: { id: '/users/[id]' }, url: { pathname: '/users/223412' } }, @@ -179,11 +179,11 @@ describe('sveltekitRoutingInstrumentation', () => { }); it('falls back to `window.location.pathname` to determine the raw origin', () => { + // eslint-disable-next-line deprecation/deprecation svelteKitRoutingInstrumentation(mockedStartTransaction, false, true); // window.location.pathame is "/" in tests - // @ts-expect-error This is fine navigating.set({ to: { route: {}, url: { pathname: '/' } }, }); diff --git a/packages/sveltekit/test/client/sdk.test.ts b/packages/sveltekit/test/client/sdk.test.ts index 4b0afb85bcd8..bc863af99897 100644 --- a/packages/sveltekit/test/client/sdk.test.ts +++ b/packages/sveltekit/test/client/sdk.test.ts @@ -82,7 +82,6 @@ describe('Sentry client SDK', () => { // This is the closest we can get to unit-testing the `__SENTRY_TRACING__` tree-shaking guard // IRL, the code to add the integration would most likely be removed by the bundler. - // @ts-expect-error this is fine in the test globalThis.__SENTRY_TRACING__ = false; init({ @@ -93,17 +92,18 @@ describe('Sentry client SDK', () => { const browserTracing = getClient()?.getIntegrationByName('BrowserTracing'); expect(browserTracing).toBeUndefined(); - // @ts-expect-error this is fine in the test delete globalThis.__SENTRY_TRACING__; }); it('Merges a user-provided BrowserTracing integration with the automatically added one', () => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', + // eslint-disable-next-line deprecation/deprecation integrations: [new BrowserTracing({ finalTimeout: 10 })], enableTracing: true, }); + // eslint-disable-next-line deprecation/deprecation const browserTracing = getClient()?.getIntegrationByName('BrowserTracing') as BrowserTracing; const options = browserTracing.options; @@ -113,6 +113,7 @@ describe('Sentry client SDK', () => { expect(options.finalTimeout).toEqual(10); // But we force the routing instrumentation to be ours + // eslint-disable-next-line deprecation/deprecation expect(options.routingInstrumentation).toEqual(svelteKitRoutingInstrumentation); }); @@ -123,6 +124,7 @@ describe('Sentry client SDK', () => { enableTracing: true, }); + // eslint-disable-next-line deprecation/deprecation const browserTracing = getClient()?.getIntegrationByName('BrowserTracing') as BrowserTracing; const options = browserTracing.options; @@ -132,6 +134,7 @@ describe('Sentry client SDK', () => { expect(options.finalTimeout).toEqual(10); // But we force the routing instrumentation to be ours + // eslint-disable-next-line deprecation/deprecation expect(options.routingInstrumentation).toEqual(svelteKitRoutingInstrumentation); }); }); diff --git a/packages/tracing-internal/src/browser/browserTracingIntegration.ts b/packages/tracing-internal/src/browser/browserTracingIntegration.ts index 34fe4a7b13d2..31660eff00a7 100644 --- a/packages/tracing-internal/src/browser/browserTracingIntegration.ts +++ b/packages/tracing-internal/src/browser/browserTracingIntegration.ts @@ -23,7 +23,7 @@ import { browserPerformanceTimeOrigin, getDomElement, logger, - tracingContextFromHeaders, + propagationContextFromHeaders, } from '@sentry/utils'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -203,21 +203,23 @@ export const browserTracingIntegration = ((_options: Partial[0], baggage: Parameters[0], @@ -83,6 +86,34 @@ export function tracingContextFromHeaders( } } +/** + * Create a propagation context from incoming headers. + */ +export function propagationContextFromHeaders( + sentryTrace: string | undefined, + baggage: string | number | boolean | string[] | null | undefined, +): PropagationContext { + const traceparentData = extractTraceparentData(sentryTrace); + const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggage); + + const { traceId, parentSpanId, parentSampled } = traceparentData || {}; + + if (!traceparentData) { + return { + traceId: traceId || uuid4(), + spanId: uuid4().substring(16), + }; + } else { + return { + traceId: traceId || uuid4(), + parentSpanId: parentSpanId || uuid4().substring(16), + spanId: uuid4().substring(16), + sampled: parentSampled, + dsc: dynamicSamplingContext || {}, // If we have traceparent data but no DSC it means we are not head of trace and we must freeze it + }; + } +} + /** * Create sentry-trace header from span context values. */ diff --git a/packages/utils/test/tracing.test.ts b/packages/utils/test/tracing.test.ts index 2e7cc4d3d5a5..ee3790322460 100644 --- a/packages/utils/test/tracing.test.ts +++ b/packages/utils/test/tracing.test.ts @@ -1,9 +1,66 @@ -import { tracingContextFromHeaders } from '../src/tracing'; +import { propagationContextFromHeaders, tracingContextFromHeaders } from '../src/tracing'; + +const EXAMPLE_SENTRY_TRACE = '12312012123120121231201212312012-1121201211212012-1'; +const EXAMPLE_BAGGAGE = 'sentry-release=1.2.3,sentry-foo=bar,other=baz'; describe('tracingContextFromHeaders()', () => { it('should produce a frozen baggage (empty object) when there is an incoming trace but no baggage header', () => { + // eslint-disable-next-line deprecation/deprecation const tracingContext = tracingContextFromHeaders('12312012123120121231201212312012-1121201211212012-1', undefined); expect(tracingContext.dynamicSamplingContext).toEqual({}); expect(tracingContext.propagationContext.dsc).toEqual({}); }); }); + +describe('propagationContextFromHeaders()', () => { + it('returns a completely new propagation context when no sentry-trace data is given but baggage data is given', () => { + const result = propagationContextFromHeaders(undefined, undefined); + expect(result).toEqual({ + traceId: expect.any(String), + spanId: expect.any(String), + }); + }); + + it('returns a completely new propagation context when no sentry-trace data is given', () => { + const result = propagationContextFromHeaders(undefined, EXAMPLE_BAGGAGE); + expect(result).toEqual({ + traceId: expect.any(String), + spanId: expect.any(String), + }); + }); + + it('returns the correct traceparent data within the propagation context when sentry trace data is given', () => { + const result = propagationContextFromHeaders(EXAMPLE_SENTRY_TRACE, undefined); + expect(result).toEqual( + expect.objectContaining({ + traceId: '12312012123120121231201212312012', + parentSpanId: '1121201211212012', + spanId: expect.any(String), + sampled: true, + }), + ); + }); + + it('returns a frozen dynamic sampling context (empty object) when there is an incoming trace but no baggage header', () => { + const result = propagationContextFromHeaders(EXAMPLE_SENTRY_TRACE, undefined); + expect(result).toEqual( + expect.objectContaining({ + dsc: {}, + }), + ); + }); + + it('returns the correct trace parent data when both sentry-trace and baggage are given', () => { + const result = propagationContextFromHeaders(EXAMPLE_SENTRY_TRACE, EXAMPLE_BAGGAGE); + expect(result).toEqual({ + traceId: '12312012123120121231201212312012', + parentSpanId: '1121201211212012', + spanId: expect.any(String), + sampled: true, + dsc: { + release: '1.2.3', + foo: 'bar', + }, + }); + }); +}); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 2ff971fde287..8288c8ca5374 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -68,6 +68,7 @@ export { // eslint-disable-next-line deprecation/deprecation spanStatusfromHttpCode, getSpanStatusFromHttpCode, + setHttpStatus, // eslint-disable-next-line deprecation/deprecation trace, withScope, @@ -99,12 +100,12 @@ export { import { Integrations as CoreIntegrations, RequestData } from '@sentry/core'; import { WinterCGFetch } from './integrations/wintercg-fetch'; +export { winterCGFetchIntegration } from './integrations/wintercg-fetch'; -const INTEGRATIONS = { +/** @deprecated Import the integration function directly, e.g. `inboundFiltersIntegration()` instead of `new Integrations.InboundFilter(). */ +export const Integrations = { // eslint-disable-next-line deprecation/deprecation ...CoreIntegrations, WinterCGFetch, RequestData, }; - -export { INTEGRATIONS as Integrations }; diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index 5d3d662e5b4f..507a34aedab4 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -1,14 +1,34 @@ import { instrumentFetchRequest } from '@sentry-internal/tracing'; -import { addBreadcrumb, getClient, isSentryRequestUrl } from '@sentry/core'; -import type { FetchBreadcrumbData, FetchBreadcrumbHint, HandlerDataFetch, Integration, Span } from '@sentry/types'; +import { + addBreadcrumb, + convertIntegrationFnToClass, + defineIntegration, + getClient, + isSentryRequestUrl, +} from '@sentry/core'; +import type { + Client, + FetchBreadcrumbData, + FetchBreadcrumbHint, + HandlerDataFetch, + Integration, + IntegrationClass, + IntegrationFn, + Span, +} from '@sentry/types'; import { LRUMap, addFetchInstrumentationHandler, stringMatchesSomePattern } from '@sentry/utils'; +const INTEGRATION_NAME = 'WinterCGFetch'; + +const HAS_CLIENT_MAP = new WeakMap(); + export interface Options { /** * Whether breadcrumbs should be recorded for requests * Defaults to true */ breadcrumbs: boolean; + /** * Function determining whether or not to create spans to track outgoing requests to the given URL. * By default, spans will be created for all outgoing requests. @@ -16,63 +36,17 @@ export interface Options { shouldCreateSpanForRequest?: (url: string) => boolean; } -/** - * Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes. - */ -export class WinterCGFetch implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'WinterCGFetch'; - - /** - * @inheritDoc - */ - public name: string = WinterCGFetch.id; - - private readonly _options: Options; +const _winterCGFetch = ((options: Partial = {}) => { + const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; + const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - private readonly _createSpanUrlMap: LRUMap = new LRUMap(100); - private readonly _headersUrlMap: LRUMap = new LRUMap(100); + const _createSpanUrlMap = new LRUMap(100); + const _headersUrlMap = new LRUMap(100); - public constructor(_options: Partial = {}) { - this._options = { - breadcrumbs: _options.breadcrumbs === undefined ? true : _options.breadcrumbs, - shouldCreateSpanForRequest: _options.shouldCreateSpanForRequest, - }; - } - - /** - * @inheritDoc - */ - public setupOnce(): void { - const spans: Record = {}; - - addFetchInstrumentationHandler(handlerData => { - if (!getClient()?.getIntegrationByName?.('WinterCGFetch')) { - return; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, getClient())) { - return; - } - - instrumentFetchRequest( - handlerData, - this._shouldCreateSpan.bind(this), - this._shouldAttachTraceData.bind(this), - spans, - 'auto.http.wintercg_fetch', - ); - - if (this._options.breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - } + const spans: Record = {}; /** Decides whether to attach trace data to the outgoing fetch request */ - private _shouldAttachTraceData(url: string): boolean { + function _shouldAttachTraceData(url: string): boolean { const client = getClient(); if (!client) { @@ -85,32 +59,86 @@ export class WinterCGFetch implements Integration { return true; } - const cachedDecision = this._headersUrlMap.get(url); + const cachedDecision = _headersUrlMap.get(url); if (cachedDecision !== undefined) { return cachedDecision; } const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); - this._headersUrlMap.set(url, decision); + _headersUrlMap.set(url, decision); return decision; } /** Helper that wraps shouldCreateSpanForRequest option */ - private _shouldCreateSpan(url: string): boolean { - if (this._options.shouldCreateSpanForRequest === undefined) { + function _shouldCreateSpan(url: string): boolean { + if (shouldCreateSpanForRequest === undefined) { return true; } - const cachedDecision = this._createSpanUrlMap.get(url); + const cachedDecision = _createSpanUrlMap.get(url); if (cachedDecision !== undefined) { return cachedDecision; } - const decision = this._options.shouldCreateSpanForRequest(url); - this._createSpanUrlMap.set(url, decision); + const decision = shouldCreateSpanForRequest(url); + _createSpanUrlMap.set(url, decision); return decision; } -} + + return { + name: INTEGRATION_NAME, + // TODO v8: Remove this again + // eslint-disable-next-line @typescript-eslint/no-empty-function + setupOnce() { + addFetchInstrumentationHandler(handlerData => { + const client = getClient(); + if (!client || !HAS_CLIENT_MAP.get(client)) { + return; + } + + if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + return; + } + + instrumentFetchRequest( + handlerData, + _shouldCreateSpan, + _shouldAttachTraceData, + spans, + 'auto.http.wintercg_fetch', + ); + + if (breadcrumbs) { + createBreadcrumb(handlerData); + } + }); + }, + setup(client) { + HAS_CLIENT_MAP.set(client, true); + }, + }; +}) satisfies IntegrationFn; + +export const winterCGFetchIntegration = defineIntegration(_winterCGFetch); + +/** + * Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes. + * + * @deprecated Use `winterCGFetchIntegration()` instead. + */ +// eslint-disable-next-line deprecation/deprecation +export const WinterCGFetch = convertIntegrationFnToClass( + INTEGRATION_NAME, + winterCGFetchIntegration, +) as IntegrationClass void }> & { + new (options?: { + breadcrumbs: boolean; + shouldCreateSpanForRequest?: (url: string) => boolean; + }): Integration; +}; + +// eslint-disable-next-line deprecation/deprecation +export type WinterCGFetch = typeof WinterCGFetch; function createBreadcrumb(handlerData: HandlerDataFetch): void { const { startTimestamp, endTimestamp } = handlerData; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index ae263b7b01b7..fe806ccfc282 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,17 +1,17 @@ import { - FunctionToString, - InboundFilters, - LinkedErrors, - RequestData, + functionToStringIntegration, getIntegrationsToSetup, + inboundFiltersIntegration, initAndBind, + linkedErrorsIntegration, + requestDataIntegration, } from '@sentry/core'; import type { Integration, Options } from '@sentry/types'; import { GLOBAL_OBJ, createStackParser, nodeStackLineParser, stackParserFromStackParserOptions } from '@sentry/utils'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import { VercelEdgeClient } from './client'; -import { WinterCGFetch } from './integrations/wintercg-fetch'; +import { winterCGFetchIntegration } from './integrations/wintercg-fetch'; import { makeEdgeTransport } from './transports'; import type { VercelEdgeClientOptions, VercelEdgeOptions } from './types'; import { getVercelEnv } from './utils/vercel'; @@ -24,12 +24,10 @@ const nodeStackParser = createStackParser(nodeStackLineParser()); /** @deprecated Use `getDefaultIntegrations(options)` instead. */ export const defaultIntegrations = [ - /* eslint-disable deprecation/deprecation */ - new InboundFilters(), - new FunctionToString(), - new LinkedErrors(), - /* eslint-enable deprecation/deprecation */ - new WinterCGFetch(), + inboundFiltersIntegration(), + functionToStringIntegration(), + linkedErrorsIntegration(), + winterCGFetchIntegration(), ]; /** Get the default integrations for the browser SDK. */ @@ -37,8 +35,7 @@ export function getDefaultIntegrations(options: Options): Integration[] { return [ // eslint-disable-next-line deprecation/deprecation ...defaultIntegrations, - // eslint-disable-next-line deprecation/deprecation - ...(options.sendDefaultPii ? [new RequestData()] : []), + ...(options.sendDefaultPii ? [requestDataIntegration()] : []), ]; } diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index e690e6785c79..2121f037e479 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -5,11 +5,11 @@ import * as sentryUtils from '@sentry/utils'; import { createStackParser } from '@sentry/utils'; import { VercelEdgeClient } from '../src/index'; -import { WinterCGFetch } from '../src/integrations/wintercg-fetch'; +import { winterCGFetchIntegration } from '../src/integrations/wintercg-fetch'; class FakeClient extends VercelEdgeClient { public getIntegrationByName(name: string): T | undefined { - return name === 'WinterCGFetch' ? (new WinterCGFetch() as unknown as T) : undefined; + return name === 'WinterCGFetch' ? (winterCGFetchIntegration() as Integration as T) : undefined; } } @@ -17,31 +17,34 @@ const addFetchInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addFetchInstr const instrumentFetchRequestSpy = jest.spyOn(internalTracing, 'instrumentFetchRequest'); const addBreadcrumbSpy = jest.spyOn(sentryCore, 'addBreadcrumb'); -beforeEach(() => { - jest.clearAllMocks(); - - const client = new FakeClient({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - enableTracing: true, - tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: () => Promise.resolve(undefined), - flush: () => Promise.resolve(true), - }), - tracePropagationTargets: ['http://my-website.com/'], - stackParser: createStackParser(), - }); +describe('WinterCGFetch instrumentation', () => { + let client: FakeClient; + + beforeEach(() => { + jest.clearAllMocks(); + + client = new FakeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableTracing: true, + tracesSampleRate: 1, + integrations: [], + transport: () => ({ + send: () => Promise.resolve(undefined), + flush: () => Promise.resolve(true), + }), + tracePropagationTargets: ['http://my-website.com/'], + stackParser: createStackParser(), + }); - jest.spyOn(sentryCore, 'getClient').mockImplementation(() => client); -}); + jest.spyOn(sentryCore, 'getClient').mockImplementation(() => client); + }); -describe('WinterCGFetch instrumentation', () => { it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { - const integration = new WinterCGFetch(); addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + const integration = winterCGFetchIntegration(); integration.setupOnce(); + integration.setup!(client); const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); @@ -70,11 +73,32 @@ describe('WinterCGFetch instrumentation', () => { expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); }); + it('should not instrument if client is not setup', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = winterCGFetchIntegration(); + integration.setupOnce(); + // integration.setup!(client) is not called! + + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + }); + it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { - const integration = new WinterCGFetch(); addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + const integration = winterCGFetchIntegration(); integration.setupOnce(); + integration.setup!(client); const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); @@ -90,14 +114,15 @@ describe('WinterCGFetch instrumentation', () => { }); it('should properly apply the `shouldCreateSpanForRequest` option', () => { - const integration = new WinterCGFetch({ + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = winterCGFetchIntegration({ shouldCreateSpanForRequest(url) { return url === 'http://only-acceptable-url.com/'; }, }); - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - integration.setupOnce(); + integration.setup!(client); const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); @@ -117,10 +142,11 @@ describe('WinterCGFetch instrumentation', () => { }); it('should create a breadcrumb for an outgoing request', () => { - const integration = new WinterCGFetch(); addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + const integration = winterCGFetchIntegration(); integration.setupOnce(); + integration.setup!(client); const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); @@ -153,12 +179,11 @@ describe('WinterCGFetch instrumentation', () => { }); it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { - const integration = new WinterCGFetch({ - breadcrumbs: false, - }); addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + const integration = winterCGFetchIntegration({ breadcrumbs: false }); integration.setupOnce(); + integration.setup!(client); const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]; expect(fetchInstrumentationHandlerCallback).toBeDefined(); diff --git a/packages/vue/README.md b/packages/vue/README.md index c8fd91af2b24..378b15eafd0d 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -28,6 +28,10 @@ const app = createApp({ Sentry.init({ app, dsn: '__PUBLIC_DSN__', + integrations: [ + // Or omit `router` if you're not using vue-router + Sentry.browserTracingIntegration({ router }), + ], }); ``` @@ -42,12 +46,16 @@ import * as Sentry from '@sentry/vue' Sentry.init({ Vue: Vue, dsn: '__PUBLIC_DSN__', -}) + integrations: [ + // Or omit `router` if you're not using vue-router + Sentry.browserTracingIntegration({ router }), + ], +}); new Vue({ el: '#app', router, components: { App }, template: '' -}) +}); ``` diff --git a/packages/vue/src/browserTracingIntegration.ts b/packages/vue/src/browserTracingIntegration.ts new file mode 100644 index 000000000000..d78bdd992d6b --- /dev/null +++ b/packages/vue/src/browserTracingIntegration.ts @@ -0,0 +1,74 @@ +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, +} from '@sentry/browser'; +import type { Integration, StartSpanOptions } from '@sentry/types'; +import { instrumentVueRouter } from './router'; + +// The following type is an intersection of the Route type from VueRouter v2, v3, and v4. +// This is not great, but kinda necessary to make it work with all versions at the same time. +export type Route = { + /** Unparameterized URL */ + path: string; + /** + * Query params (keys map to null when there is no value associated, e.g. "?foo" and to an array when there are + * multiple query params that have the same key, e.g. "?foo&foo=bar") + */ + query: Record; + /** Route name (VueRouter provides a way to give routes individual names) */ + name?: string | symbol | null | undefined; + /** Evaluated parameters */ + params: Record; + /** All the matched route objects as defined in VueRouter constructor */ + matched: { path: string }[]; +}; + +interface VueRouter { + onError: (fn: (err: Error) => void) => void; + beforeEach: (fn: (to: Route, from: Route, next?: () => void) => void) => void; +} + +type VueBrowserTracingIntegrationOptions = Parameters[0] & { + /** + * If a router is specified, navigation spans will be created based on the router. + */ + router?: VueRouter; + + /** + * What to use for route labels. + * By default, we use route.name (if set) and else the path. + * + * Default: 'name' + */ + routeLabel?: 'name' | 'path'; +}; + +/** + * A custom BrowserTracing integration for Vue. + */ +export function browserTracingIntegration(options: VueBrowserTracingIntegrationOptions = {}): Integration { + // If router is not passed, we just use the normal implementation + if (!options.router) { + return originalBrowserTracingIntegration(options); + } + + const integration = originalBrowserTracingIntegration({ + ...options, + instrumentNavigation: false, + }); + + const { router, instrumentNavigation = true, instrumentPageLoad = true, routeLabel = 'name' } = options; + + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup(client); + + const startNavigationSpan = (options: StartSpanOptions): void => { + startBrowserTracingNavigationSpan(client, options); + }; + + instrumentVueRouter(router, { routeLabel, instrumentNavigation, instrumentPageLoad }, startNavigationSpan); + }, + }; +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index d89359530043..0b9626ee185d 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,7 +1,13 @@ export * from '@sentry/browser'; export { init } from './sdk'; +// eslint-disable-next-line deprecation/deprecation export { vueRouterInstrumentation } from './router'; +export { browserTracingIntegration } from './browserTracingIntegration'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; -export { vueIntegration, VueIntegration } from './integration'; +export { + vueIntegration, + // eslint-disable-next-line deprecation/deprecation + VueIntegration, +} from './integration'; diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index 5065e1486400..8150ea6b95d6 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -35,6 +35,8 @@ export const vueIntegration = defineIntegration(_vueIntegration); /** * Initialize Vue error & performance tracking. + * + * @deprecated Use `vueIntegration()` instead. */ // eslint-disable-next-line deprecation/deprecation export const VueIntegration = convertIntegrationFnToClass( diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index 98c18ae80691..b7f3fd0466b0 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -1,6 +1,6 @@ import { WINDOW, captureException } from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; -import type { Transaction, TransactionContext, TransactionSource } from '@sentry/types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; +import type { SpanAttributes, Transaction, TransactionContext, TransactionSource } from '@sentry/types'; import { getActiveTransaction } from './tracing'; @@ -50,6 +50,8 @@ interface VueRouter { * * `routeLabel`: Set this to `route` to opt-out of using `route.name` for transaction names. * * @param router The Vue Router instance that is used + * + * @deprecated Use `browserTracingIntegration()` from `@sentry/vue` instead - this includes the vue router instrumentation. */ export function vueRouterInstrumentation( router: VueRouter, @@ -60,10 +62,6 @@ export function vueRouterInstrumentation( startTransactionOnPageLoad: boolean = true, startTransactionOnLocationChange: boolean = true, ) => { - const tags = { - 'routing.instrumentation': 'vue-router', - }; - // We have to start the pageload transaction as early as possible (before the router's `beforeEach` hook // is called) to not miss child spans of the pageload. // We check that window & window.location exists in order to not run this code in SSR environments. @@ -71,77 +69,107 @@ export function vueRouterInstrumentation( startTransaction({ name: WINDOW.location.pathname, op: 'pageload', - origin: 'auto.pageload.vue', - tags, - data: { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }, }); } - router.onError(error => captureException(error, { mechanism: { handled: false } })); - - router.beforeEach((to, from, next) => { - // According to docs we could use `from === VueRouter.START_LOCATION` but I couldnt get it working for Vue 2 - // https://router.vuejs.org/api/#router-start-location - // https://next.router.vuejs.org/api/#start-location - - // from.name: - // - Vue 2: null - // - Vue 3: undefined - // hence only '==' instead of '===', because `undefined == null` evaluates to `true` - const isPageLoadNavigation = from.name == null && from.matched.length === 0; - - const data: Record = { - params: to.params, - query: to.query, - }; - - // Determine a name for the routing transaction and where that name came from - let transactionName: string = to.path; - let transactionSource: TransactionSource = 'url'; - if (to.name && options.routeLabel !== 'path') { - transactionName = to.name.toString(); - transactionSource = 'custom'; - } else if (to.matched[0] && to.matched[0].path) { - transactionName = to.matched[0].path; - transactionSource = 'route'; - } + instrumentVueRouter( + router, + { + routeLabel: options.routeLabel || 'name', + instrumentNavigation: startTransactionOnLocationChange, + instrumentPageLoad: startTransactionOnPageLoad, + }, + startTransaction, + ); + }; +} - if (startTransactionOnPageLoad && isPageLoadNavigation) { - // eslint-disable-next-line deprecation/deprecation - const pageloadTransaction = getActiveTransaction(); - if (pageloadTransaction) { - const attributes = spanToJSON(pageloadTransaction).data || {}; - if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom') { - pageloadTransaction.updateName(transactionName); - pageloadTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource); - } - // TODO: We need to flatten these to make them attributes - // eslint-disable-next-line deprecation/deprecation - pageloadTransaction.setData('params', data.params); - // eslint-disable-next-line deprecation/deprecation - pageloadTransaction.setData('query', data.query); - } +/** + * Instrument the Vue router to create navigation spans. + */ +export function instrumentVueRouter( + router: VueRouter, + options: { + routeLabel: 'name' | 'path'; + instrumentPageLoad: boolean; + instrumentNavigation: boolean; + }, + startNavigationSpanFn: (context: TransactionContext) => void, +): void { + router.onError(error => captureException(error, { mechanism: { handled: false } })); + + router.beforeEach((to, from, next) => { + // According to docs we could use `from === VueRouter.START_LOCATION` but I couldnt get it working for Vue 2 + // https://router.vuejs.org/api/#router-start-location + // https://next.router.vuejs.org/api/#start-location + + // from.name: + // - Vue 2: null + // - Vue 3: undefined + // hence only '==' instead of '===', because `undefined == null` evaluates to `true` + const isPageLoadNavigation = from.name == null && from.matched.length === 0; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + }; + + for (const key of Object.keys(to.params)) { + attributes[`params.${key}`] = to.params[key]; + } + for (const key of Object.keys(to.query)) { + const value = to.query[key]; + if (value) { + attributes[`query.${key}`] = value; } + } - if (startTransactionOnLocationChange && !isPageLoadNavigation) { - data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = transactionSource; - startTransaction({ - name: transactionName, - op: 'navigation', - origin: 'auto.navigation.vue', - tags, - data, + // Determine a name for the routing transaction and where that name came from + let transactionName: string = to.path; + let transactionSource: TransactionSource = 'url'; + if (to.name && options.routeLabel !== 'path') { + transactionName = to.name.toString(); + transactionSource = 'custom'; + } else if (to.matched[0] && to.matched[0].path) { + transactionName = to.matched[0].path; + transactionSource = 'route'; + } + + if (options.instrumentPageLoad && isPageLoadNavigation) { + // eslint-disable-next-line deprecation/deprecation + const pageloadTransaction = getActiveTransaction(); + if (pageloadTransaction) { + const existingAttributes = spanToJSON(pageloadTransaction).data || {}; + if (existingAttributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom') { + pageloadTransaction.updateName(transactionName); + pageloadTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource); + } + // Set router attributes on the existing pageload transaction + // This will the origin, and add params & query attributes + pageloadTransaction.setAttributes({ + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', }); } + } - // Vue Router 4 no longer exposes the `next` function, so we need to - // check if it's available before calling it. - // `next` needs to be called in Vue Router 3 so that the hook is resolved. - if (next) { - next(); - } - }); - }; + if (options.instrumentNavigation && !isPageLoadNavigation) { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = transactionSource; + startNavigationSpanFn({ + name: transactionName, + op: 'navigation', + attributes, + }); + } + + // Vue Router 4 no longer exposes the `next` function, so we need to + // check if it's available before calling it. + // `next` needs to be called in Vue Router 3 so that the hook is resolved. + if (next) { + next(); + } + }); } diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index a5ade00e8ec0..8e2c743db064 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -1,4 +1,4 @@ -import { getCurrentScope } from '@sentry/browser'; +import { getActiveSpan, getCurrentScope, startInactiveSpan } from '@sentry/browser'; import type { Span, Transaction } from '@sentry/types'; import { logger, timestampInSeconds } from '@sentry/utils'; @@ -78,14 +78,12 @@ export const createTracingMixins = (options: TracingOptions): Mixins => { const isRoot = this.$root === this; if (isRoot) { - // eslint-disable-next-line deprecation/deprecation - const activeTransaction = getActiveTransaction(); - if (activeTransaction) { + const activeSpan = getActiveSpan(); + if (activeSpan) { this.$_sentryRootSpan = this.$_sentryRootSpan || - // eslint-disable-next-line deprecation/deprecation - activeTransaction.startChild({ - description: 'Application Render', + startInactiveSpan({ + name: 'Application Render', op: `${VUE_OP}.render`, origin: 'auto.ui.vue', }); @@ -108,9 +106,8 @@ export const createTracingMixins = (options: TracingOptions): Mixins => { // Start a new span if current hook is a 'before' hook. // Otherwise, retrieve the current span and finish it. if (internalHook == internalHooks[0]) { - // eslint-disable-next-line deprecation/deprecation - const activeTransaction = (this.$root && this.$root.$_sentryRootSpan) || getActiveTransaction(); - if (activeTransaction) { + const activeSpan = (this.$root && this.$root.$_sentryRootSpan) || getActiveSpan(); + if (activeSpan) { // Cancel old span for this hook operation in case it didn't get cleaned up. We're not actually sure if it // will ever be the case that cleanup hooks re not called, but we had users report that spans didn't get // finished so we finish the span before starting a new one, just to be sure. @@ -119,9 +116,8 @@ export const createTracingMixins = (options: TracingOptions): Mixins => { oldSpan.end(); } - // eslint-disable-next-line deprecation/deprecation - this.$_sentrySpans[operation] = activeTransaction.startChild({ - description: `Vue <${name}>`, + this.$_sentrySpans[operation] = startInactiveSpan({ + name: `Vue <${name}>`, op: `${VUE_OP}.${operation}`, origin: 'auto.ui.vue', }); diff --git a/packages/vue/test/integration/VueIntegration.test.ts b/packages/vue/test/integration/VueIntegration.test.ts index 08af038676d0..aeea0ebf1451 100644 --- a/packages/vue/test/integration/VueIntegration.test.ts +++ b/packages/vue/test/integration/VueIntegration.test.ts @@ -36,7 +36,7 @@ describe('Sentry.VueIntegration', () => { }); // This would normally happen through client.addIntegration() - const integration = new Sentry.VueIntegration({ app }); + const integration = Sentry.vueIntegration({ app }); integration['setup']?.(Sentry.getClient() as Client); app.mount(el); @@ -58,7 +58,7 @@ describe('Sentry.VueIntegration', () => { app.mount(el); // This would normally happen through client.addIntegration() - const integration = new Sentry.VueIntegration({ app }); + const integration = Sentry.vueIntegration({ app }); integration['setup']?.(Sentry.getClient() as Client); expect(warnings).toEqual([ diff --git a/packages/vue/test/integration/init.test.ts b/packages/vue/test/integration/init.test.ts index 6a117427e2c8..c0652ad37485 100644 --- a/packages/vue/test/integration/init.test.ts +++ b/packages/vue/test/integration/init.test.ts @@ -1,6 +1,5 @@ import { createApp } from 'vue'; -import { VueIntegration } from '../../src/integration'; import type { Options } from '../../src/types'; import * as Sentry from './../../src'; @@ -104,7 +103,7 @@ Update your \`Sentry.init\` call with an appropriate config option: }); function runInit(options: Partial): void { - const integration = new VueIntegration(); + const integration = Sentry.vueIntegration(); Sentry.init({ dsn: PUBLIC_DSN, diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 061bcdd3e1f9..7d45889be864 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -1,9 +1,9 @@ import * as SentryBrowser from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import type { Transaction } from '@sentry/types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import type { SpanAttributes, Transaction } from '@sentry/types'; -import { vueRouterInstrumentation } from '../src'; import type { Route } from '../src/router'; +import { instrumentVueRouter, vueRouterInstrumentation } from '../src/router'; import * as vueTracing from '../src/tracing'; const captureExceptionSpy = jest.spyOn(SentryBrowser, 'captureException'); @@ -13,7 +13,6 @@ const mockVueRouter = { beforeEach: jest.fn void) => void]>(), }; -const mockStartTransaction = jest.fn(); const mockNext = jest.fn(); const testRoutes: Record = { @@ -52,7 +51,10 @@ const testRoutes: Record = { }, }; +/* eslint-disable deprecation/deprecation */ describe('vueRouterInstrumentation()', () => { + const mockStartTransaction = jest.fn(); + afterEach(() => { jest.clearAllMocks(); }); @@ -101,16 +103,12 @@ describe('vueRouterInstrumentation()', () => { expect(mockStartTransaction).toHaveBeenCalledTimes(2); expect(mockStartTransaction).toHaveBeenCalledWith({ name: transactionName, - data: { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource, - params: to.params, - query: to.query, + ...getAttributesForRoute(to), }, op: 'navigation', - origin: 'auto.navigation.vue', - tags: { - 'routing.instrumentation': 'vue-router', - }, }); expect(mockNext).toHaveBeenCalledTimes(1); @@ -128,6 +126,7 @@ describe('vueRouterInstrumentation()', () => { updateName: jest.fn(), setData: jest.fn(), setAttribute: jest.fn(), + setAttributes: jest.fn(), metadata: {}, }; const customMockStartTxn = { ...mockStartTransaction }.mockImplementation(_ => { @@ -145,14 +144,11 @@ describe('vueRouterInstrumentation()', () => { expect(customMockStartTxn).toHaveBeenCalledTimes(1); expect(customMockStartTxn).toHaveBeenCalledWith({ name: '/', - data: { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }, op: 'pageload', - origin: 'auto.pageload.vue', - tags: { - 'routing.instrumentation': 'vue-router', - }, }); const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; @@ -165,8 +161,10 @@ describe('vueRouterInstrumentation()', () => { expect(mockedTxn.updateName).toHaveBeenCalledWith(transactionName); expect(mockedTxn.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource); - expect(mockedTxn.setData).toHaveBeenNthCalledWith(1, 'params', to.params); - expect(mockedTxn.setData).toHaveBeenNthCalledWith(2, 'query', to.query); + expect(mockedTxn.setAttributes).toHaveBeenCalledWith({ + ...getAttributesForRoute(to), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', + }); expect(mockNext).toHaveBeenCalledTimes(1); }, @@ -189,16 +187,12 @@ describe('vueRouterInstrumentation()', () => { // first startTx call happens when the instrumentation is initialized (for pageloads) expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/login', - data: { + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - params: to.params, - query: to.query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + ...getAttributesForRoute(to), }, op: 'navigation', - origin: 'auto.navigation.vue', - tags: { - 'routing.instrumentation': 'vue-router', - }, }); }); @@ -219,16 +213,12 @@ describe('vueRouterInstrumentation()', () => { // first startTx call happens when the instrumentation is initialized (for pageloads) expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: 'login-screen', - data: { + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - params: to.params, - query: to.query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + ...getAttributesForRoute(to), }, op: 'navigation', - origin: 'auto.navigation.vue', - tags: { - 'routing.instrumentation': 'vue-router', - }, }); }); @@ -237,6 +227,7 @@ describe('vueRouterInstrumentation()', () => { updateName: jest.fn(), setData: jest.fn(), setAttribute: jest.fn(), + setAttributes: jest.fn(), name: '', toJSON: () => ({ data: { @@ -259,14 +250,11 @@ describe('vueRouterInstrumentation()', () => { expect(customMockStartTxn).toHaveBeenCalledTimes(1); expect(customMockStartTxn).toHaveBeenCalledWith({ name: '/', - data: { + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', }, op: 'pageload', - origin: 'auto.pageload.vue', - tags: { - 'routing.instrumentation': 'vue-router', - }, }); // now we give the transaction a custom name, thereby simulating what would @@ -278,13 +266,20 @@ describe('vueRouterInstrumentation()', () => { }, }); + const to = testRoutes['normalRoute1']; + const from = testRoutes['initialPageloadRoute']; + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; - beforeEachCallback(testRoutes['normalRoute1'], testRoutes['initialPageloadRoute'], mockNext); + beforeEachCallback(to, from, mockNext); expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1); expect(mockedTxn.updateName).not.toHaveBeenCalled(); expect(mockedTxn.setAttribute).not.toHaveBeenCalled(); + expect(mockedTxn.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', + ...getAttributesForRoute(to), + }); expect(mockedTxn.name).toEqual('customTxnName'); }); @@ -346,16 +341,338 @@ describe('vueRouterInstrumentation()', () => { // first startTx call happens when the instrumentation is initialized (for pageloads) expect(mockStartTransaction).toHaveBeenLastCalledWith({ name: '/login', - data: { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + ...getAttributesForRoute(to), + }, + op: 'navigation', + }); + }); +}); +/* eslint-enable deprecation/deprecation */ + +describe('instrumentVueRouter()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return instrumentation that instruments VueRouter.onError', () => { + const mockStartSpan = jest.fn(); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + // check + expect(mockVueRouter.onError).toHaveBeenCalledTimes(1); + + const onErrorCallback = mockVueRouter.onError.mock.calls[0][0]; + + const testError = new Error(); + onErrorCallback(testError); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(testError, { mechanism: { handled: false } }); + }); + + it.each([ + ['normalRoute1', 'normalRoute2', '/accounts/:accountId', 'route'], + ['normalRoute2', 'namedRoute', 'login-screen', 'custom'], + ['normalRoute2', 'unmatchedRoute', '/e8733846-20ac-488c-9871-a5cbcb647294', 'url'], + ])( + 'should return instrumentation that instruments VueRouter.beforeEach(%s, %s) for navigations', + (fromKey, toKey, transactionName, transactionSource) => { + const mockStartSpan = jest.fn(); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + // check + expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1); + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + + const from = testRoutes[fromKey]; + const to = testRoutes[toKey]; + beforeEachCallback(to, from, mockNext); + + expect(mockStartSpan).toHaveBeenCalledTimes(1); + expect(mockStartSpan).toHaveBeenCalledWith({ + name: transactionName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource, + ...getAttributesForRoute(to), + }, + op: 'navigation', + }); + + expect(mockNext).toHaveBeenCalledTimes(1); + }, + ); + + it.each([ + ['initialPageloadRoute', 'normalRoute1', '/books/:bookId/chapter/:chapterId', 'route'], + ['initialPageloadRoute', 'namedRoute', 'login-screen', 'custom'], + ['initialPageloadRoute', 'unmatchedRoute', '/e8733846-20ac-488c-9871-a5cbcb647294', 'url'], + ])( + 'should return instrumentation that instruments VueRouter.beforeEach(%s, %s) for pageloads', + (fromKey, toKey, transactionName, transactionSource) => { + const mockedTxn = { + updateName: jest.fn(), + setData: jest.fn(), + setAttribute: jest.fn(), + setAttributes: jest.fn(), + metadata: {}, + }; + + jest.spyOn(vueTracing, 'getActiveTransaction').mockImplementation(() => mockedTxn as unknown as Transaction); + + const mockStartSpan = jest.fn().mockImplementation(_ => { + return mockedTxn; + }); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + // no span is started for page load + expect(mockStartSpan).not.toHaveBeenCalled(); + + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + + const from = testRoutes[fromKey]; + const to = testRoutes[toKey]; + + beforeEachCallback(to, from, mockNext); + expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1); + + expect(mockedTxn.updateName).toHaveBeenCalledWith(transactionName); + expect(mockedTxn.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource); + expect(mockedTxn.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', + ...getAttributesForRoute(to), + }); + + expect(mockNext).toHaveBeenCalledTimes(1); + }, + ); + + it('allows to configure routeLabel=path', () => { + const mockStartSpan = jest.fn(); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'path', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + // check + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + + const from = testRoutes.normalRoute1; + const to = testRoutes.namedRoute; + beforeEachCallback(to, from, mockNext); + + // first startTx call happens when the instrumentation is initialized (for pageloads) + expect(mockStartSpan).toHaveBeenLastCalledWith({ + name: '/login', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - params: to.params, - query: to.query, + ...getAttributesForRoute(to), }, op: 'navigation', - origin: 'auto.navigation.vue', - tags: { - 'routing.instrumentation': 'vue-router', + }); + }); + + it('allows to configure routeLabel=name', () => { + const mockStartSpan = jest.fn(); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + // check + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + + const from = testRoutes.normalRoute1; + const to = testRoutes.namedRoute; + beforeEachCallback(to, from, mockNext); + + // first startTx call happens when the instrumentation is initialized (for pageloads) + expect(mockStartSpan).toHaveBeenLastCalledWith({ + name: 'login-screen', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + ...getAttributesForRoute(to), }, + op: 'navigation', + }); + }); + + it("doesn't overwrite a pageload transaction name it was set to custom before the router resolved the route", () => { + const mockedTxn = { + updateName: jest.fn(), + setData: jest.fn(), + setAttribute: jest.fn(), + setAttributes: jest.fn(), + name: '', + toJSON: () => ({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }), + }; + const mockStartSpan = jest.fn().mockImplementation(_ => { + return mockedTxn; + }); + jest.spyOn(vueTracing, 'getActiveTransaction').mockImplementation(() => mockedTxn as unknown as Transaction); + + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + // check for transaction start + expect(mockStartSpan).not.toHaveBeenCalled(); + + // now we give the transaction a custom name, thereby simulating what would + // happen when users use the `beforeNavigate` hook + mockedTxn.name = 'customTxnName'; + mockedTxn.toJSON = () => ({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + }, + }); + + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + + const to = testRoutes['normalRoute1']; + const from = testRoutes['initialPageloadRoute']; + + beforeEachCallback(to, from, mockNext); + + expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1); + + expect(mockedTxn.updateName).not.toHaveBeenCalled(); + expect(mockedTxn.setAttribute).not.toHaveBeenCalled(); + expect(mockedTxn.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', + ...getAttributesForRoute(to), + }); + expect(mockedTxn.name).toEqual('customTxnName'); + }); + + test.each([ + [false, 0], + [true, 1], + ])( + 'should return instrumentation that considers the instrumentPageLoad = %p', + (instrumentPageLoad, expectedCallsAmount) => { + const mockedTxn = { + updateName: jest.fn(), + setData: jest.fn(), + setAttribute: jest.fn(), + setAttributes: jest.fn(), + name: '', + toJSON: () => ({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }), + }; + jest.spyOn(vueTracing, 'getActiveTransaction').mockImplementation(() => mockedTxn as unknown as Transaction); + + const mockStartSpan = jest.fn(); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad, instrumentNavigation: true }, + mockStartSpan, + ); + + // check + expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1); + + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + beforeEachCallback(testRoutes['normalRoute1'], testRoutes['initialPageloadRoute'], mockNext); + + expect(mockedTxn.updateName).toHaveBeenCalledTimes(expectedCallsAmount); + expect(mockStartSpan).not.toHaveBeenCalled(); + }, + ); + + test.each([ + [false, 0], + [true, 1], + ])( + 'should return instrumentation that considers the instrumentNavigation = %p', + (instrumentNavigation, expectedCallsAmount) => { + const mockStartSpan = jest.fn(); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation }, + mockStartSpan, + ); + + // check + expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1); + + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + beforeEachCallback(testRoutes['normalRoute2'], testRoutes['normalRoute1'], mockNext); + + expect(mockStartSpan).toHaveBeenCalledTimes(expectedCallsAmount); + }, + ); + + it("doesn't throw when `next` is not available in the beforeEach callback (Vue Router 4)", () => { + const mockStartSpan = jest.fn(); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'path', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0]; + + const from = testRoutes.normalRoute1; + const to = testRoutes.namedRoute; + beforeEachCallback(to, from, undefined); + + // first startTx call happens when the instrumentation is initialized (for pageloads) + expect(mockStartSpan).toHaveBeenLastCalledWith({ + name: '/login', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + ...getAttributesForRoute(to), + }, + op: 'navigation', }); }); }); + +// Small helper function to get flattened attributes for test comparison +function getAttributesForRoute(route: Route): SpanAttributes { + const { params, query } = route; + + const attributes: SpanAttributes = {}; + + for (const key of Object.keys(params)) { + attributes[`params.${key}`] = params[key]; + } + for (const key of Object.keys(query)) { + const value = query[key]; + if (value) { + attributes[`query.${key}`] = value; + } + } + + return attributes; +} diff --git a/scripts/node-unit-tests.ts b/scripts/node-unit-tests.ts index 567224854baa..9c9312e76571 100644 --- a/scripts/node-unit-tests.ts +++ b/scripts/node-unit-tests.ts @@ -19,6 +19,7 @@ const DEFAULT_SKIP_TESTS_PACKAGES = [ '@sentry/react', '@sentry/angular', '@sentry/svelte', + '@sentry/profiling-node', '@sentry/replay', '@sentry-internal/replay-canvas', '@sentry-internal/feedback', diff --git a/yarn.lock b/yarn.lock index bcbeead21f52..f3e927b72635 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4693,10 +4693,10 @@ dependencies: "@opentelemetry/context-base" "^0.14.0" -"@opentelemetry/api@1.6.0", "@opentelemetry/api@^1.6.0", "@opentelemetry/api@~1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.6.0.tgz#de2c6823203d6f319511898bb5de7e70f5267e19" - integrity sha512-OWlrQAnWn9577PhVgqjUvMr1pg57Bc4jv0iL4w0PRuOSRvq67rvHW9Ie/dZVMvCzhSCB+UxhcY/PmCmFj33Q+g== +"@opentelemetry/api@1.7.0", "@opentelemetry/api@^1.6.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.7.0.tgz#b139c81999c23e3c8d3c0a7234480e945920fc40" + integrity sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw== "@opentelemetry/api@^0.12.0": version "0.12.0" @@ -4705,10 +4705,10 @@ dependencies: "@opentelemetry/context-base" "^0.12.0" -"@opentelemetry/context-async-hooks@1.17.1", "@opentelemetry/context-async-hooks@^1.17.1", "@opentelemetry/context-async-hooks@~1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-1.17.1.tgz#4eba80bd66f8cd367e9ba94b5fec5f5acf5d7b25" - integrity sha512-up5I+RiQEkGrVEHtbAtmRgS+ZOnFh3shaDNHqZPBlGy+O92auL6yMmjzYpSKmJOGWowvs3fhVHePa8Exb5iHUg== +"@opentelemetry/context-async-hooks@1.21.0", "@opentelemetry/context-async-hooks@^1.17.1": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-1.21.0.tgz#a56fa461e7786605bcbde2ff66f21b2392afacda" + integrity sha512-t0iulGPiMjG/NrSjinPQoIf8ST/o9V0dGOJthfrFporJlNdlKIQPfC7lkrV+5s2dyBThfmSbJlp/4hO1eOcDXA== "@opentelemetry/context-base@^0.12.0": version "0.12.0" @@ -4720,19 +4720,19 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/context-base/-/context-base-0.14.0.tgz#c67fc20a4d891447ca1a855d7d70fa79a3533001" integrity sha512-sDOAZcYwynHFTbLo6n8kIbLiVF3a3BLkrmehJUyEbT9F+Smbi47kLGS2gG2g0fjBLR/Lr1InPD7kXL7FaTqEkw== -"@opentelemetry/core@1.17.0": - version "1.17.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.17.0.tgz#6a72425f5f953dc68b4c7c66d947c018173d30d2" - integrity sha512-tfnl3h+UefCgx1aeN2xtrmr6BmdWGKXypk0pflQR0urFS40aE88trnkOMc2HTJZbMrqEEl4HsaBeFhwLVXsrJg== +"@opentelemetry/core@1.20.0": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.20.0.tgz#ab1a8204ed10cc11e17bb61db658da0f3686d4ac" + integrity sha512-lSRvk5AIdD6CtgYJcJXh0wGibQ3S/8bC2qbqKs9wK8e0K1tsWV6YkGFOqVc+jIRlCbZoIBeZzDe5UI+vb94uvg== dependencies: - "@opentelemetry/semantic-conventions" "1.17.0" + "@opentelemetry/semantic-conventions" "1.20.0" -"@opentelemetry/core@1.17.1", "@opentelemetry/core@^1.1.0", "@opentelemetry/core@^1.17.1", "@opentelemetry/core@^1.8.0", "@opentelemetry/core@~1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.17.1.tgz#10c5e09c63aeb1836b34d80baf7113760fb19d96" - integrity sha512-I6LrZvl1FF97FQXPR0iieWQmKnGxYtMbWA1GrAXnLUR+B1Hn2m8KqQNEIlZAucyv00GBgpWkpllmULmZfG8P3g== +"@opentelemetry/core@1.21.0", "@opentelemetry/core@^1.1.0", "@opentelemetry/core@^1.17.1", "@opentelemetry/core@^1.8.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.21.0.tgz#8c16faf16edf861b073c03c9d45977b3f4003ee1" + integrity sha512-KP+OIweb3wYoP7qTYL/j5IpOlu52uxBv5M4+QhSmmUfLyTgu1OIS71msK3chFo1D6Y61BIH3wMiMYRCxJCQctA== dependencies: - "@opentelemetry/semantic-conventions" "1.17.1" + "@opentelemetry/semantic-conventions" "1.21.0" "@opentelemetry/core@^0.12.0": version "0.12.0" @@ -4743,123 +4743,143 @@ "@opentelemetry/context-base" "^0.12.0" semver "^7.1.3" -"@opentelemetry/instrumentation-express@0.33.2": - version "0.33.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.33.2.tgz#e5bd14be5814e24b257cd093220d32d5e9261c5a" - integrity sha512-FR05iNosZL42haYang6vpmcuLfXLngJs/0gAgqXk8vwqGGwilOFak1PjoRdO4PAoso0FI+3zhV3Tz7jyDOmSyA== +"@opentelemetry/instrumentation-express@0.35.0": + version "0.35.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.35.0.tgz#4391c46f4ce00d840633fd79391028c38eca01bc" + integrity sha512-ZmSB4WMd88sSecOL7DlghzdBl56/8ymb02n+xEJ/6zUgONuw/1uoTh1TAaNPKfEWdNLoLKXQm+Gd2zBrUVOX0w== dependencies: "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" - "@types/express" "4.17.18" -"@opentelemetry/instrumentation-fastify@0.32.3": - version "0.32.3" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.32.3.tgz#2c0640c986018d1a41dfff3d9c3bfe3b5b1cf62d" - integrity sha512-vRFVoEJXcu6nNpJ61H5syDb84PirOd4b3u8yl8Bcorrr6firGYBQH4pEIVB4PkQWlmi3sLOifqS3VAO2VRloEQ== +"@opentelemetry/instrumentation-fastify@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.33.0.tgz#4f4013b2677c94d7f8f34e0aeab77bca16524d8e" + integrity sha512-sl3q9Mt+yM6GlZJKhfLUIRrVEYqfmI0hqYLha5OFG5rLrgnZCCZVy8ra4+Pa40ecH1409cvwwBPf7k9AHEQBTw== dependencies: "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" -"@opentelemetry/instrumentation-graphql@0.35.2": - version "0.35.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.35.2.tgz#67b0c173cff1191cfa66aa26f67c6752c365edf2" - integrity sha512-lJv7BbHFK0ExwogdQMtVHfnWhCBMDQEz8KYvhShXfRPiSStU5aVwa3TmT0O00KiJFpATSKJNZMv1iZNHbF6z1g== +"@opentelemetry/instrumentation-graphql@0.37.0": + version "0.37.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.37.0.tgz#0bafda083065074dacce9bd4b9d0f3183379d3ca" + integrity sha512-WL5Qn1aRudJDxVN0Ao73/yzXBGBJAH1Fd2tteuGXku/Qw9hYQ936CgoO66GWmSiq2lyjsojAk1t5f+HF9j3NXw== dependencies: - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" -"@opentelemetry/instrumentation-hapi@0.33.1": - version "0.33.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.33.1.tgz#9327f15a0d075153f61d338400b3db618dd3902e" - integrity sha512-8gwPrIgppbj/prCTK31mGmcBvYESE5J2El6badbCvcUHg6ZSA/i8zo80NrJ6812imtD06Dvm6kfnK5UzlC+smQ== +"@opentelemetry/instrumentation-hapi@0.34.0": + version "0.34.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.34.0.tgz#edab0a9175ca141cd289d28c7d1677a2da80d993" + integrity sha512-qUENVxwCYbRbJ8HBY54ZL1Z9q1guCEurW6tCFFJJKQFu/MKEw7GSFImy5DR2Mp8b5ggZO36lYFcx0QUxfy4GJg== dependencies: "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" "@types/hapi__hapi" "20.0.13" -"@opentelemetry/instrumentation-http@0.44.0": - version "0.44.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.44.0.tgz#5a3e4b91073f737f054fe42ef591c39c5b3e6394" - integrity sha512-Nlvj3Y2n9q6uIcQq9f33HbcB4Dr62erSwYA37+vkorYnzI2j9PhxKitocRTZnbYsrymYmQJW9mdq/IAfbtVnNg== +"@opentelemetry/instrumentation-http@0.48.0": + version "0.48.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.48.0.tgz#88266dfcd2dddb45f755a0f1fc882472e6e30a87" + integrity sha512-uXqOsLhW9WC3ZlGm6+PSX0xjSDTCfy4CMjfYj6TPWusOO8dtdx040trOriF24y+sZmS3M+5UQc6/3/ZxBJh4Mw== dependencies: - "@opentelemetry/core" "1.17.1" - "@opentelemetry/instrumentation" "0.44.0" - "@opentelemetry/semantic-conventions" "1.17.1" + "@opentelemetry/core" "1.21.0" + "@opentelemetry/instrumentation" "0.48.0" + "@opentelemetry/semantic-conventions" "1.21.0" semver "^7.5.2" -"@opentelemetry/instrumentation-mongodb@0.37.1": - version "0.37.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.37.1.tgz#5957565a74a4fe39fb72ab29f3b72a20223ef3df" - integrity sha512-UE+5B/MDfB5MUlJfjj8uo/fMnJPpqeUesJZ/loAWuCLCTDDyEJM7wnAvtH+2c4QoukkkIT1lDe5q9aiXwLEr5g== +"@opentelemetry/instrumentation-koa@0.37.0": + version "0.37.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.37.0.tgz#f12e608afb7b58cee0f27abb3c2a166ea8596c68" + integrity sha512-EfuGv1RJCSZh77dDc3PtvZXGwcsTufn9tU6T9VOTFcxovpyJ6w0og73eD0D02syR8R+kzv6rg1TeS8+lj7pyrQ== + dependencies: + "@opentelemetry/core" "^1.8.0" + "@opentelemetry/instrumentation" "^0.48.0" + "@opentelemetry/semantic-conventions" "^1.0.0" + "@types/koa" "2.14.0" + "@types/koa__router" "12.0.3" + +"@opentelemetry/instrumentation-mongodb@0.39.0": + version "0.39.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.39.0.tgz#31bc92c137b578219bfaf4d15c7f247bc8d3b2c6" + integrity sha512-m9dMj39pcCshzlfCEn2lGrlNo7eV5fb9pGBnPyl/Am9Crh7Or8vOqvByCNd26Dgf5J978zTdLGF+6tM8j1WOew== dependencies: - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/sdk-metrics" "^1.9.1" "@opentelemetry/semantic-conventions" "^1.0.0" -"@opentelemetry/instrumentation-mongoose@0.33.2": - version "0.33.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.33.2.tgz#99f235df66009e0b73953a58f3f6b9f28e6a31b1" - integrity sha512-JXhhn8vkGKbev6aBPkQ6dL5rDImQfucrub8mU7dknPPpCL850fSQ2qt2qLvyDXfawF5my6KWW0fkKJCeRA+ECw== +"@opentelemetry/instrumentation-mongoose@0.35.0": + version "0.35.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.35.0.tgz#b101dea4a47a6ed7b5e760208917ebbb2597e53c" + integrity sha512-gReBMWD2Oa/wBGRWyg6B2dbPHhgkpOqDio31gE3DbC4JaqCsMByyeix75rZSzZ71RQmVh3d4jRLsqUtNVBzcyg== dependencies: "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" -"@opentelemetry/instrumentation-mysql2@0.34.2": - version "0.34.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.34.2.tgz#f59f03c3135a8b50bad9cb3d5b55403008a8d0ba" - integrity sha512-Ac/KAHHtTz087P7I6JapBs+ofNOM+RPTDGwSe1ddnTj0xTAO0F6ITmRC1firnMdzDidI/wI+vmgnWclCB81xKQ== +"@opentelemetry/instrumentation-mysql2@0.35.0": + version "0.35.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.35.0.tgz#aea66385ad4ae8a19896be718e03849d34bfdd53" + integrity sha512-DI9NXYJBbQ72rjz1KCKerQFQE+Z4xRDoyYek6JpITv5BlhPboA8zKkltxyQLL06Y2RTFYslw1gvg+x9CWlRzJw== dependencies: - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" "@opentelemetry/sql-common" "^0.40.0" -"@opentelemetry/instrumentation-mysql@0.34.2": - version "0.34.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.34.2.tgz#3372dc11010dce2f357a89a1e3f32359c4d34079" - integrity sha512-3OEhW1CB7b93PHIbQ5t8Aoj/dCqNWQBDBbyUXGy2zFbhEcJBVcLeBpy3w8VEjzNTfRC6cVwASuHRP0aLBIPNjQ== +"@opentelemetry/instrumentation-mysql@0.35.0": + version "0.35.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.35.0.tgz#d344dbd831b0d49c395f04ea419b352d2701e908" + integrity sha512-QKRHd3aFA2vKOPzIZ9Q3UIxYeNPweB62HGlX2l3shOKrUhrtTg2/BzaKpHQBy2f2nO2mxTF/mOFeVEDeANnhig== dependencies: - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" "@types/mysql" "2.15.22" -"@opentelemetry/instrumentation-nestjs-core@0.33.2": - version "0.33.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.33.2.tgz#fb87031097a96c761db0823c2eff8deba452abbf" - integrity sha512-jrX/355K+myc5V/EQFouqQzBfy5qj+SyVMHIKqVymOx/zWFCvz1p9ChNiPOKzl2il3o/P/aOqBUN/qnRaGowlw== +"@opentelemetry/instrumentation-nestjs-core@0.34.0": + version "0.34.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.34.0.tgz#4bbbbf08d62fc78ca319f0c966a054e718f9da91" + integrity sha512-HvbcCVAMZEIFrJ0Si9AfjxOr14KcH5h/lq5zLQ8AjZJpW0WaeO/ox5UgFi3J73Br91WbZHRgbXxMeodNycJJuA== dependencies: - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" -"@opentelemetry/instrumentation-pg@0.36.2": - version "0.36.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.36.2.tgz#45947d19bbafabf5b350a76350ef4523deac13a5" - integrity sha512-KUjI8OGi7kicml2Sd/PR/M8otZoZEdPArMfhznS6OQKit+RxFo0p5x6RVeka/cLQlmoc3eeGBizDeZetssbHgw== +"@opentelemetry/instrumentation-pg@0.38.0": + version "0.38.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.38.0.tgz#19d49cc301ab63124a0482f21f64be3fbb81321c" + integrity sha512-Q7V/OJ1OZwaWYNOP/E9S6sfS03Z+PNU1SAjdAoXTj5j4u4iJSMSieLRWXFaHwsbefIOMkYvA00EBKF9IgbgbLA== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.44.0" + "@opentelemetry/instrumentation" "^0.48.0" "@opentelemetry/semantic-conventions" "^1.0.0" "@opentelemetry/sql-common" "^0.40.0" "@types/pg" "8.6.1" "@types/pg-pool" "2.0.4" -"@opentelemetry/instrumentation@0.43.0", "@opentelemetry/instrumentation@^0.43.0": - version "0.43.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.43.0.tgz#749521415df03396f969bf42341fcb4acd2e9c7b" - integrity sha512-S1uHE+sxaepgp+t8lvIDuRgyjJWisAb733198kwQTUc9ZtYQ2V2gmyCtR1x21ePGVLoMiX/NWY7WA290hwkjJQ== +"@opentelemetry/instrumentation@0.47.0": + version "0.47.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.47.0.tgz#1eaa26f2dd5a6ce8cadde9f86bd70f1e47df3d47" + integrity sha512-ZFhphFbowWwMahskn6BBJgMm8Z+TUx98IM+KpLIX3pwCK/zzgbCgwsJXRnjF9edDkc5jEhA7cEz/mP0CxfQkLA== dependencies: "@types/shimmer" "^1.0.2" - import-in-the-middle "1.4.2" + import-in-the-middle "^1.7.2" require-in-the-middle "^7.1.1" semver "^7.5.2" shimmer "^1.2.1" -"@opentelemetry/instrumentation@0.44.0", "@opentelemetry/instrumentation@^0.44.0": - version "0.44.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.44.0.tgz#194f16fc96671575b6bd73d3fadffb5aa4497e67" - integrity sha512-B6OxJTRRCceAhhnPDBshyQO7K07/ltX3quOLu0icEvPK9QZ7r9P1y0RQX8O5DxB4vTv4URRkxkg+aFU/plNtQw== +"@opentelemetry/instrumentation@0.48.0", "@opentelemetry/instrumentation@^0.48.0": + version "0.48.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.48.0.tgz#a6dee936e973f1270c464657a55bb570807194aa" + integrity sha512-sjtZQB5PStIdCw5ovVTDGwnmQC+GGYArJNgIcydrDSqUTdYBnMrN9P4pwQZgS3vTGIp+TU1L8vMXGe51NVmIKQ== + dependencies: + "@types/shimmer" "^1.0.2" + import-in-the-middle "1.7.1" + require-in-the-middle "^7.1.1" + semver "^7.5.2" + shimmer "^1.2.1" + +"@opentelemetry/instrumentation@^0.43.0": + version "0.43.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.43.0.tgz#749521415df03396f969bf42341fcb4acd2e9c7b" + integrity sha512-S1uHE+sxaepgp+t8lvIDuRgyjJWisAb733198kwQTUc9ZtYQ2V2gmyCtR1x21ePGVLoMiX/NWY7WA290hwkjJQ== dependencies: "@types/shimmer" "^1.0.2" import-in-the-middle "1.4.2" @@ -4867,35 +4887,35 @@ semver "^7.5.2" shimmer "^1.2.1" -"@opentelemetry/propagator-b3@1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-b3/-/propagator-b3-1.17.1.tgz#65dbddf3763db82632ddd7ad1735e597ab7b2dc4" - integrity sha512-XEbXYb81AM3ayJLlbJqITPIgKBQCuby45ZHiB9mchnmQOffh6ZJOmXONdtZAV7TWzmzwvAd28vGSUk57Aw/5ZA== +"@opentelemetry/propagator-b3@1.21.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-b3/-/propagator-b3-1.21.0.tgz#72fadc4a07afb2c83f0830b8a06071e0361eacb2" + integrity sha512-3ZTobj2VDIOzLsIvvYCdpw6tunxUVElPxDvog9lS49YX4hohHeD84A8u9Ns/6UYUcaN5GSoEf891lzhcBFiOLA== dependencies: - "@opentelemetry/core" "1.17.1" + "@opentelemetry/core" "1.21.0" -"@opentelemetry/propagator-jaeger@1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.17.1.tgz#31cc43045a059d1ed3651b9f21d0fd6db817b02f" - integrity sha512-p+P4lf2pbqd3YMfZO15QCGsDwR2m1ke2q5+dq6YBLa/q0qiC2eq4cD/qhYBBed5/X4PtdamaVGHGsp+u3GXHDA== +"@opentelemetry/propagator-jaeger@1.21.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.21.0.tgz#bfc1fa3a050496ec67a253040dfdec4d16339225" + integrity sha512-8TQSwXjBmaDx7JkxRD7hdmBmRK2RGRgzHX1ArJfJhIc5trzlVweyorzqQrXOvqVEdEg+zxUMHkL5qbGH/HDTPA== dependencies: - "@opentelemetry/core" "1.17.1" + "@opentelemetry/core" "1.21.0" -"@opentelemetry/resources@1.17.0": - version "1.17.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.17.0.tgz#ee29144cfd7d194c69698c8153dbadec7fe6819f" - integrity sha512-+u0ciVnj8lhuL/qGRBPeVYvk7fL+H/vOddfvmOeJaA1KC+5/3UED1c9KoZQlRsNT5Kw1FaK8LkY2NVLYfOVZQw== +"@opentelemetry/resources@1.20.0": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.20.0.tgz#7165c39837e6e41b695f0088e40d15a5793f1469" + integrity sha512-nOpV0vGegSq+9ze2cEDvO3BMA5pGBhmhKZiAlj+xQZjiEjPmJtdHIuBLRvptu2ahcbFJw85gIB9BYHZOvZK1JQ== dependencies: - "@opentelemetry/core" "1.17.0" - "@opentelemetry/semantic-conventions" "1.17.0" + "@opentelemetry/core" "1.20.0" + "@opentelemetry/semantic-conventions" "1.20.0" -"@opentelemetry/resources@1.17.1", "@opentelemetry/resources@~1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.17.1.tgz#932f70f58c0e03fb1d38f0cba12672fd70804d99" - integrity sha512-M2e5emqg5I7qRKqlzKx0ROkcPyF8PbcSaWEdsm72od9txP7Z/Pl8PDYOyu80xWvbHAWk5mDxOF6v3vNdifzclA== +"@opentelemetry/resources@1.21.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.21.0.tgz#e773e918cc8ca26493a987dfbfc6b8a315a2ab45" + integrity sha512-1Z86FUxPKL6zWVy2LdhueEGl9AHDJcx+bvHStxomruz6Whd02mE3lNUMjVJ+FGRoktx/xYQcxccYb03DiUP6Yw== dependencies: - "@opentelemetry/core" "1.17.1" - "@opentelemetry/semantic-conventions" "1.17.1" + "@opentelemetry/core" "1.21.0" + "@opentelemetry/semantic-conventions" "1.21.0" "@opentelemetry/resources@^0.12.0": version "0.12.0" @@ -4906,53 +4926,53 @@ "@opentelemetry/core" "^0.12.0" "@opentelemetry/sdk-metrics@^1.9.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-1.17.1.tgz#9c4d13d845bcc82be8684050d9db7cce10f61580" - integrity sha512-eHdpsMCKhKhwznxvEfls8Wv3y4ZBWkkXlD3m7vtHIiWBqsMHspWSfie1s07mM45i/bBCf6YBMgz17FUxIXwmZA== + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-1.21.0.tgz#40d71aaec5b696e58743889ce6d5bf2593f9a23d" + integrity sha512-on1jTzIHc5DyWhRP+xpf+zrgrREXcHBH4EDAfaB5mIG7TWpKxNXooQ1JCylaPsswZUv4wGnVTinr4HrBdGARAQ== dependencies: - "@opentelemetry/core" "1.17.1" - "@opentelemetry/resources" "1.17.1" + "@opentelemetry/core" "1.21.0" + "@opentelemetry/resources" "1.21.0" lodash.merge "^4.6.2" -"@opentelemetry/sdk-trace-base@1.17.0": - version "1.17.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.17.0.tgz#05a21763c9efa72903c20b8930293cdde344b681" - integrity sha512-2T5HA1/1iE36Q9eg6D4zYlC4Y4GcycI1J6NsHPKZY9oWfAxWsoYnRlkPfUqyY5XVtocCo/xHpnJvGNHwzT70oQ== +"@opentelemetry/sdk-trace-base@1.20.0": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.20.0.tgz#1771bf7a214924fe1f27ef50395f763b65aae220" + integrity sha512-BAIZ0hUgnhdb3OBQjn1FKGz/Iwie4l+uOMKklP7FGh7PTqEAbbzDNMJKaZQh6KepF7Fq+CZDRKslD3yrYy2Tzw== dependencies: - "@opentelemetry/core" "1.17.0" - "@opentelemetry/resources" "1.17.0" - "@opentelemetry/semantic-conventions" "1.17.0" + "@opentelemetry/core" "1.20.0" + "@opentelemetry/resources" "1.20.0" + "@opentelemetry/semantic-conventions" "1.20.0" -"@opentelemetry/sdk-trace-base@1.17.1", "@opentelemetry/sdk-trace-base@^1.17.1", "@opentelemetry/sdk-trace-base@~1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.17.1.tgz#8ede213df8b0c957028a869c66964e535193a4fd" - integrity sha512-pfSJJSjZj5jkCJUQZicSpzN8Iz9UKMryPWikZRGObPnJo6cUSoKkjZh6BM3j+D47G4olMBN+YZKYqkFM1L6zNA== +"@opentelemetry/sdk-trace-base@1.21.0", "@opentelemetry/sdk-trace-base@^1.17.1": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.21.0.tgz#ffad912e453a92044fb220bd5d2f6743bf37bb8a" + integrity sha512-yrElGX5Fv0umzp8Nxpta/XqU71+jCAyaLk34GmBzNcrW43nqbrqvdPs4gj4MVy/HcTjr6hifCDCYA3rMkajxxA== dependencies: - "@opentelemetry/core" "1.17.1" - "@opentelemetry/resources" "1.17.1" - "@opentelemetry/semantic-conventions" "1.17.1" + "@opentelemetry/core" "1.21.0" + "@opentelemetry/resources" "1.21.0" + "@opentelemetry/semantic-conventions" "1.21.0" "@opentelemetry/sdk-trace-node@^1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.17.1.tgz#746c197ad54a8e0cdb24a4b257d33dc3a04493c1" - integrity sha512-J56DaG4cusjw5crpI7x9rv4bxDF27DtKYGxXJF56KIvopbNKpdck5ZWXBttEyqgAVPDwHMAXWDL1KchHzF0a3A== - dependencies: - "@opentelemetry/context-async-hooks" "1.17.1" - "@opentelemetry/core" "1.17.1" - "@opentelemetry/propagator-b3" "1.17.1" - "@opentelemetry/propagator-jaeger" "1.17.1" - "@opentelemetry/sdk-trace-base" "1.17.1" + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.21.0.tgz#20599f42a6b59bf71c64ef8630d28464e6e18f2a" + integrity sha512-1pdm8jnqs+LuJ0Bvx6sNL28EhC8Rv7NYV8rnoXq3GIQo7uOHBDAFSj7makAfbakrla7ecO1FRfI8emnR4WvhYA== + dependencies: + "@opentelemetry/context-async-hooks" "1.21.0" + "@opentelemetry/core" "1.21.0" + "@opentelemetry/propagator-b3" "1.21.0" + "@opentelemetry/propagator-jaeger" "1.21.0" + "@opentelemetry/sdk-trace-base" "1.21.0" semver "^7.5.2" -"@opentelemetry/semantic-conventions@1.17.0": - version "1.17.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.17.0.tgz#af10baa9f05ce1e64a14065fc138b5739bfb65f6" - integrity sha512-+fguCd2d8d2qruk0H0DsCEy2CTK3t0Tugg7MhZ/UQMvmewbZLNnJ6heSYyzIZWG5IPfAXzoj4f4F/qpM7l4VBA== +"@opentelemetry/semantic-conventions@1.20.0": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.20.0.tgz#4d9b88188e18056a218644ea30fae130a7857766" + integrity sha512-3zLJJCgTKYpbqFX8drl8hOCHtdchELC+kGqlVcV4mHW1DiElTtv1Nt9EKBptTd1IfL56QkuYnWJ3DeHd2Gtu/A== -"@opentelemetry/semantic-conventions@1.17.1", "@opentelemetry/semantic-conventions@^1.0.0", "@opentelemetry/semantic-conventions@^1.17.0", "@opentelemetry/semantic-conventions@^1.17.1", "@opentelemetry/semantic-conventions@~1.17.1": - version "1.17.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.17.1.tgz#93d219935e967fbb9aa0592cc96b2c0ec817a56f" - integrity sha512-xbR2U+2YjauIuo42qmE8XyJK6dYeRMLJuOlUP5SO4auET4VtOHOzgkRVOq+Ik18N+Xf3YPcqJs9dZMiDddz1eQ== +"@opentelemetry/semantic-conventions@1.21.0", "@opentelemetry/semantic-conventions@^1.0.0", "@opentelemetry/semantic-conventions@^1.17.0", "@opentelemetry/semantic-conventions@^1.17.1": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.21.0.tgz#83f7479c524ab523ac2df702ade30b9724476c72" + integrity sha512-lkC8kZYntxVKr7b8xmjCVUgE0a8xgDakPyDo9uSWavXPyYqLgYYGdEd2j8NxihRyb6UwpX3G/hFUF4/9q2V+/g== "@opentelemetry/semantic-conventions@^0.12.0": version "0.12.0" @@ -5014,14 +5034,14 @@ resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.15.1-1.461d6a05159055555eb7dfb337c9fb271cbd4d7e.tgz#bf5e2373ca68ce7556b967cb4965a7095e93fe53" integrity sha512-e3k2Vd606efd1ZYy2NQKkT4C/pn31nehyLhVug6To/q8JT8FpiMrDy7zmm3KLF0L98NOQQcutaVtAPhzKhzn9w== -"@prisma/instrumentation@5.4.2": - version "5.4.2" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-5.4.2.tgz#e1615cb50485f029a47e79378d3edac483d6a5f3" - integrity sha512-VSBfo0VS6aY1fIuMBbeLBaTmmgZxszMn2DvHRnGzEnqD/B9/Yfiu96+c0SKuYr7VkuXlbmt5dpbkJutvuJzZBQ== +"@prisma/instrumentation@5.9.0": + version "5.9.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-5.9.0.tgz#b36751a965a320a099f5665854340c5739f9bbe0" + integrity sha512-VjLZQM/Gv5EgN8l7T+VH5nbSYbl25tkkQJCMyrV+ajY6wRYwsUY3WPEzqdYe/eB3zcfr6+rUN+Cp919scUYt/A== dependencies: - "@opentelemetry/api" "1.6.0" - "@opentelemetry/instrumentation" "0.43.0" - "@opentelemetry/sdk-trace-base" "1.17.0" + "@opentelemetry/api" "1.7.0" + "@opentelemetry/instrumentation" "0.47.0" + "@opentelemetry/sdk-trace-base" "1.20.0" "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" @@ -5407,39 +5427,48 @@ semver "7.3.2" semver-intersect "1.4.0" -"@sentry-internal/rrdom@2.9.0": - version "2.9.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.9.0.tgz#dbb30c00a859156e9bfdfe701af85477fa082cbf" - integrity sha512-8jULvAmXunPfNChUCOhKSr4rRg7govoH7L/8XuRsK4++wJryjOJDO/zMnway5c3u03PKbFcZFcqCyKjaQQKcHg== +"@sentry-internal/rrdom@2.11.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.11.0.tgz#f7c8f54705ad84ece0e97e53f12e87c687749b32" + integrity sha512-BZnkTrbLm9Y3R70W1+8TnImys0RbKsgyB70WQoFdUervGvPw1kLcWJOJrPcDWgVe7nlbG+bEWb6iQrvLqldycw== dependencies: - "@sentry-internal/rrweb-snapshot" "2.9.0" + "@sentry-internal/rrweb-snapshot" "2.11.0" -"@sentry-internal/rrweb-snapshot@2.9.0": - version "2.9.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.9.0.tgz#f7b682992e70174547c495a4a6deae39136cecf2" - integrity sha512-oK8L3g41PFli1MpItYIFYCisCB+XjpqbEup0lVyTa/6wvKe0SOxZK9aUb/y03/2onSMmQ+FRkKLL6Kd0gHYJOA== +"@sentry-internal/rrweb-snapshot@2.11.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-snapshot/-/rrweb-snapshot-2.11.0.tgz#1af79130604afea989d325465b209ac015b27c9a" + integrity sha512-1nP22QlplMNooSNvTh+L30NSZ+E3UcfaJyxXSMLxUjQHTGPyM1VkndxZMmxlKhyR5X+rLbxi/+RvuAcpM43VoA== -"@sentry-internal/rrweb-types@2.9.0": - version "2.9.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.9.0.tgz#a70450ab7ca9884fd8d70bdb45dc214ed554956e" - integrity sha512-s3YhCvXzMM7byAfjHyCWmSOUBDbzUpWHWZj7FR6G8xa3nIrIePceziMc9wxEdqi7nCcmDHPc+kZ2GzDaZIrebA== +"@sentry-internal/rrweb-types@2.11.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb-types/-/rrweb-types-2.11.0.tgz#e598c133b87be1fb04d31d09773b86142b095072" + integrity sha512-foCf9DGfN5ffzwykEtIXsV1P5d+XLDVGaQUnKF5ecGn+g5JzKTe/rPC92rL8/gEy2unL5sCTvlYL3DQvUFM4dA== dependencies: - "@sentry-internal/rrweb-snapshot" "2.9.0" + "@sentry-internal/rrweb-snapshot" "2.11.0" -"@sentry-internal/rrweb@2.9.0": - version "2.9.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.9.0.tgz#a41af914baaf69c7a1e76d22d1780d50c3dfed0e" - integrity sha512-fDPYXWHOwt/PZzOklS17xPsjMsZ6D0K7CX3tvaDE4IkHCHM1PmGJhrXo05NL86WHhRKNKeRT3WQaokrrZzU5zA== +"@sentry-internal/rrweb@2.11.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/rrweb/-/rrweb-2.11.0.tgz#be8e8dfff2acf64d418b625d35a20fdcd7daeb96" + integrity sha512-QuEqpKmRDb0xQe9fhJ3j/JHO6uxFMWBowADJBA4rvVU5HbExIg9gor1tZ0b3gDuChXnnx7pxFj9/QXZjQQ75zg== dependencies: - "@sentry-internal/rrdom" "2.9.0" - "@sentry-internal/rrweb-snapshot" "2.9.0" - "@sentry-internal/rrweb-types" "2.9.0" + "@sentry-internal/rrdom" "2.11.0" + "@sentry-internal/rrweb-snapshot" "2.11.0" + "@sentry-internal/rrweb-types" "2.11.0" "@types/css-font-loading-module" "0.0.7" "@xstate/fsm" "^1.4.0" base64-arraybuffer "^1.0.1" fflate "^0.4.4" mitt "^3.0.0" +"@sentry-internal/tracing@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.93.0.tgz#8cee8b610695d828af75edd2929b64b7caf0385d" + integrity sha512-DjuhmQNywPp+8fxC9dvhGrqgsUb6wI/HQp25lS2Re7VxL1swCasvpkg8EOYP4iBniVQ86QK0uITkOIRc5tdY1w== + dependencies: + "@sentry/core" "7.93.0" + "@sentry/types" "7.93.0" + "@sentry/utils" "7.93.0" + "@sentry/bundler-plugin-core@0.6.1": version "0.6.1" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-0.6.1.tgz#6c6a2ff3cdc98cd0ff1c30c59408cee9f067adf2" @@ -5534,6 +5563,37 @@ "@sentry/cli-win32-i686" "2.26.0" "@sentry/cli-win32-x64" "2.26.0" +"@sentry/core@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.93.0.tgz#50a14bf305130dfef51810e4c97fcba4972a57ef" + integrity sha512-vZQSUiDn73n+yu2fEcH+Wpm4GbRmtxmnXnYCPgM6IjnXqkVm3awWAkzrheADblx3kmxrRiOlTXYHw9NTWs56fg== + dependencies: + "@sentry/types" "7.93.0" + "@sentry/utils" "7.93.0" + +"@sentry/node@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.93.0.tgz#7786d05d1e3e984207a866b07df1bf891355892e" + integrity sha512-nUXPCZQm5Y9Ipv7iWXLNp5dbuyi1VvbJ3RtlwD7utgsNkRYB4ixtKE9w2QU8DZZAjaEF6w2X94OkYH6C932FWw== + dependencies: + "@sentry-internal/tracing" "7.93.0" + "@sentry/core" "7.93.0" + "@sentry/types" "7.93.0" + "@sentry/utils" "7.93.0" + https-proxy-agent "^5.0.0" + +"@sentry/types@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.93.0.tgz#d76d26259b40cd0688e1d634462fbff31476c1ec" + integrity sha512-UnzUccNakhFRA/esWBWP+0v7cjNg+RilFBQC03Mv9OEMaZaS29zSbcOGtRzuFOXXLBdbr44BWADqpz3VW0XaNw== + +"@sentry/utils@7.93.0": + version "7.93.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.93.0.tgz#36225038661fe977baf01e4695ef84794d591e45" + integrity sha512-Iovj7tUnbgSkh/WrAaMrd5UuYjW7AzyzZlFDIUrwidsyIdUficjCG2OIxYzh76H6nYIx9SxewW0R54Q6XoB4uA== + dependencies: + "@sentry/types" "7.93.0" + "@sentry/vite-plugin@^0.6.1": version "0.6.1" resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-0.6.1.tgz#31eb744e8d87b1528eed8d41433647727a62e7c0" @@ -5834,6 +5894,13 @@ "@tufjs/canonical-json" "1.0.0" minimatch "^9.0.0" +"@types/accepts@*": + version "1.3.7" + resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.7.tgz#3b98b1889d2b2386604c2bbbe62e4fb51e95b265" + integrity sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ== + dependencies: + "@types/node" "*" + "@types/accepts@^1.3.5": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" @@ -5953,6 +6020,11 @@ dependencies: "@types/node" "*" +"@types/content-disposition@*": + version "0.5.8" + resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.8.tgz#6742a5971f490dc41e59d277eee71361fea0b537" + integrity sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg== + "@types/cookie@0.5.2": version "0.5.2" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.2.tgz#9bf9d62c838c85a07c92fdf2334c2c14fd9c59a9" @@ -5968,6 +6040,16 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== +"@types/cookies@*": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.9.0.tgz#a2290cfb325f75f0f28720939bee854d4142aee2" + integrity sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q== + dependencies: + "@types/connect" "*" + "@types/express" "*" + "@types/keygrip" "*" + "@types/node" "*" + "@types/cors@2.8.12", "@types/cors@^2.8.12": version "2.8.12" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" @@ -6241,32 +6323,32 @@ "@types/range-parser" "*" "@types/express-serve-static-core@^4.17.33": - version "4.17.36" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.36.tgz#baa9022119bdc05a4adfe740ffc97b5f9360e545" - integrity sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q== + version "4.17.42" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.42.tgz#2a276952acc73d1b8dc63fd4210647abbc553a71" + integrity sha512-ckM3jm2bf/MfB3+spLPWYPUH573plBFwpOhqQ2WottxYV85j1HQFlxmnTq57X1yHY9awZPig06hL/cLMgNWHIQ== dependencies: "@types/node" "*" "@types/qs" "*" "@types/range-parser" "*" "@types/send" "*" -"@types/express@4.17.14", "@types/express@^4.17.14", "@types/express@^4.17.2": - version "4.17.14" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.14.tgz#143ea0557249bc1b3b54f15db4c81c3d4eb3569c" - integrity sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg== +"@types/express@*": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== dependencies: "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.18" + "@types/express-serve-static-core" "^4.17.33" "@types/qs" "*" "@types/serve-static" "*" -"@types/express@4.17.18": - version "4.17.18" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95" - integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ== +"@types/express@4.17.14", "@types/express@^4.17.14", "@types/express@^4.17.2": + version "4.17.14" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.14.tgz#143ea0557249bc1b3b54f15db4c81c3d4eb3569c" + integrity sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg== dependencies: "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.33" + "@types/express-serve-static-core" "^4.17.18" "@types/qs" "*" "@types/serve-static" "*" @@ -6383,6 +6465,16 @@ resolved "https://registry.yarnpkg.com/@types/htmlbars-inline-precompile/-/htmlbars-inline-precompile-1.0.1.tgz#de564513fabb165746aecd76369c87bd85e5bbb4" integrity sha512-sVD2e6QAAHW0Y6Btse+tTA9k9g0iKm87wjxRsgZRU5EwSooz80tenbV+fA+f2BI2g0G2CqxsS1rIlwQCtPRQow== +"@types/http-assert@*": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.5.tgz#dfb1063eb7c240ee3d3fe213dac5671cfb6a8dbf" + integrity sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g== + +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -6441,6 +6533,39 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/keygrip@*": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.6.tgz#1749535181a2a9b02ac04a797550a8787345b740" + integrity sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ== + +"@types/koa-compose@*": + version "3.2.8" + resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.8.tgz#dec48de1f6b3d87f87320097686a915f1e954b57" + integrity sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA== + dependencies: + "@types/koa" "*" + +"@types/koa@*", "@types/koa@2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.14.0.tgz#8939e8c3b695defc12f2ef9f38064509e564be18" + integrity sha512-DTDUyznHGNHAl+wd1n0z1jxNajduyTh8R53xoewuerdBzGo6Ogj6F2299BFtrexJw4NtgjsI5SMPCmV9gZwGXA== + dependencies: + "@types/accepts" "*" + "@types/content-disposition" "*" + "@types/cookies" "*" + "@types/http-assert" "*" + "@types/http-errors" "*" + "@types/keygrip" "*" + "@types/koa-compose" "*" + "@types/node" "*" + +"@types/koa__router@12.0.3": + version "12.0.3" + resolved "https://registry.yarnpkg.com/@types/koa__router/-/koa__router-12.0.3.tgz#3fb74ea1991cadd6c6712b6106657aa6e64afca4" + integrity sha512-5YUJVv6NwM1z7m6FuYpKfNLTZ932Z6EF6xy2BbtpJSyn13DKNQEkXVffFVSnJHxvwwWh2SAeumpjAYUELqgjyw== + dependencies: + "@types/koa" "*" + "@types/long@^4.0.0", "@types/long@^4.0.1": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" @@ -6531,6 +6656,11 @@ dependencies: "@types/unist" "^2" +"@types/node-abi@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/node-abi/-/node-abi-3.0.3.tgz#a8334d75fe45ccd4cdb2a6c1ae82540a7a76828c" + integrity sha512-5oos6sivyXcDEuVC5oX3+wLwfgrGZu4NIOn826PGAjPCHsqp2zSPTGU7H1Tv+GZBOiDUY3nBXY1MdaofSEt4fw== + "@types/node-fetch@^2.6.0": version "2.6.2" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da" @@ -6544,6 +6674,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947" integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g== +"@types/node@16.18.70": + version "16.18.70" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.70.tgz#d4c819be1e9f8b69a794d6f2fd929d9ff76f6d4b" + integrity sha512-8eIk20G5VVVQNZNouHjLA2b8utE2NvGybLjMaF4lyhA9uhGwnmXF8o+icdXKGSQSNANJewXva/sFUoZLwAaYAg== + "@types/node@20.8.2": version "20.8.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4" @@ -6746,9 +6881,9 @@ integrity sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw== "@types/send@*": - version "0.17.1" - resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301" - integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q== + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== dependencies: "@types/mime" "^1" "@types/node" "*" @@ -11226,6 +11361,15 @@ cjs-module-lexer@^1.2.2: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== +clang-format@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/clang-format/-/clang-format-1.8.0.tgz#7779df1c5ce1bc8aac1b0b02b4e479191ef21d46" + integrity sha512-pK8gzfu55/lHzIpQ1givIbWfn3eXnU7SfxqIwVgnn5jEM6j4ZJYjpFqFs4iSBPNedzRMmfjYjuQhu657WAXHXw== + dependencies: + async "^3.2.3" + glob "^7.0.0" + resolve "^1.1.6" + class-utils@^0.3.5: version "0.3.6" resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" @@ -12148,6 +12292,13 @@ cron@^3.1.6: "@types/luxon" "~3.3.0" luxon "~3.4.0" +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -12159,7 +12310,7 @@ cross-spawn@^6.0.0, cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -15308,6 +15459,11 @@ expect@^27.5.1: jest-matcher-utils "^27.5.1" jest-message-util "^27.5.1" +exponential-backoff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" + integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== + express@^4.10.7, express@^4.16.4, express@^4.17.1, express@^4.17.3, express@^4.18.1: version "4.18.2" resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" @@ -16753,7 +16909,7 @@ globby@10.0.0: merge2 "^1.2.3" slash "^3.0.0" -globby@11.1.0, globby@^11.0.1, globby@^11.0.3, globby@^11.1.0: +globby@11, globby@11.1.0, globby@^11.0.1, globby@^11.0.3, globby@^11.1.0: version "11.1.0" resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -17926,6 +18082,26 @@ import-in-the-middle@1.4.2: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" +import-in-the-middle@1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.7.1.tgz#3e111ff79c639d0bde459bd7ba29dd9fdf357364" + integrity sha512-1LrZPDtW+atAxH42S6288qyDFNQ2YCty+2mxEPRtfazH6Z5QwkaBSTS2ods7hnVJioF6rkRfNoA6A/MstpFXLg== + dependencies: + acorn "^8.8.2" + acorn-import-assertions "^1.9.0" + cjs-module-lexer "^1.2.2" + module-details-from-path "^1.0.3" + +import-in-the-middle@^1.7.2: + version "1.7.3" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.7.3.tgz#ffa784cdd57a47d2b68d2e7dd33070ff06baee43" + integrity sha512-R2I11NRi0lI3jD2+qjqyVlVEahsejw7LDnYEbGb47QEFjczE3bZYsmWheCTQA+LFs2DzOQxR7Pms7naHW1V4bQ== + dependencies: + acorn "^8.8.2" + acorn-import-assertions "^1.9.0" + cjs-module-lexer "^1.2.2" + module-details-from-path "^1.0.3" + import-lazy@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" @@ -22887,6 +23063,13 @@ node-abi@^3.3.0: dependencies: semver "^7.3.5" +node-abi@^3.52.0: + version "3.54.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.54.0.tgz#f6386f7548817acac6434c6cba02999c9aebcc69" + integrity sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA== + dependencies: + semver "^7.3.5" + node-abort-controller@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" @@ -22986,6 +23169,23 @@ node-gyp@^9.0.0: tar "^6.1.2" which "^2.0.2" +node-gyp@^9.4.1: + version "9.4.1" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-9.4.1.tgz#8a1023e0d6766ecb52764cc3a734b36ff275e185" + integrity sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ== + dependencies: + env-paths "^2.2.0" + exponential-backoff "^3.1.1" + glob "^7.1.4" + graceful-fs "^4.2.6" + make-fetch-happen "^10.0.3" + nopt "^6.0.0" + npmlog "^6.0.0" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.2" + which "^2.0.2" + node-html-parser@1.4.9: version "1.4.9" resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-1.4.9.tgz#3c8f6cac46479fae5800725edb532e9ae8fd816c" @@ -30793,7 +30993,7 @@ typescript@4.3.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== -typescript@4.9.5: +typescript@4.9.5, typescript@^4.9.5: version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==