diff --git a/.craft.yml b/.craft.yml index c5055acf329c..f2ffca132f23 100644 --- a/.craft.yml +++ b/.craft.yml @@ -4,14 +4,14 @@ preReleaseCommand: bash scripts/craft-pre-release.sh targets: # NPM Targets ## 1. Base Packages, node or browser SDKs depend on - ## 1.1 Types - - name: npm - id: '@sentry/types' - includeNames: /^sentry-types-\d.*\.tgz$/ - ## 1.2 Core SDKs + ## 1.1 Core SDKs - name: npm id: '@sentry/core' includeNames: /^sentry-core-\d.*\.tgz$/ + ## 1.2 Types + - name: npm + id: '@sentry/types' + includeNames: /^sentry-types-\d.*\.tgz$/ - name: npm id: '@sentry/node-core' includeNames: /^sentry-node-core-\d.*\.tgz$/ @@ -153,6 +153,7 @@ targets: - nodejs18.x - nodejs20.x - nodejs22.x + - nodejs24.x license: MIT # CDN Bundle Target diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index d70f36ff6c94..891b91d50f90 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -32,7 +32,11 @@ Do not flag the issues below if they appear in tests. - When calling any `startSpan` API (`startInactiveSpan`, `startSpanManual`, etc), always ensure that the following span attributes are set: - `SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN` (`'sentry.origin'`) with a proper span origin + - a proper origin must only contain [a-z], [A-Z], [0-9], `_` and `.` characters. + - flag any non-conforming origin values as invalid and link to the trace origin specification (https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/) - `SEMANTIC_ATTRIBUTE_SENTRY_OP` (`'sentry.op'`) with a proper span op + - Span ops should be lower case only, and use snake_case. The `.` character is used to delimit op parts. + - flag any non-conforming origin values as invalid and link to the span op specification (https://develop.sentry.dev/sdk/telemetry/traces/span-operations/) - When calling `captureException`, always make sure that the `mechanism` is set: - `handled`: must be set to `true` or `false` - `type`: must be set to a proper origin (i.e. identify the integration and part in the integration that caught the exception). diff --git a/.cursor/commands/bump_otel_instrumentations.md b/.cursor/commands/bump_otel_instrumentations.md new file mode 100644 index 000000000000..ff1e6cfcbcc8 --- /dev/null +++ b/.cursor/commands/bump_otel_instrumentations.md @@ -0,0 +1,32 @@ +# Bump OpenTelemetry instrumentations + +1. Ensure you're on the `develop` branch with the latest changes: + - If you have unsaved changes, stash them with `git stash -u`. + - If you're on a different branch than `develop`, check out the develop branch using `git checkout develop`. + - Pull the latest updates from the remote repository by running `git pull origin develop`. + +2. Create a new branch `bump-otel-{yyyy-mm-dd}`, e.g. `bump-otel-2025-03-03` + +3. Create a new empty commit with the commit message `feat(deps): Bump OpenTelemetry instrumentations` + +4. Push the branch and create a draft PR, note down the PR number as {PR_NUMBER} + +5. Create a changelog entry in `CHANGELOG.md` under + `- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott` with the following format: + `- feat(deps): Bump OpenTelemetry instrumentations ([#{PR_NUMBER}](https://github.com/getsentry/sentry-javascript/pull/{PR_NUMBER}))` + +6. Find the "Upgrade OpenTelemetry instrumentations" rule in `.cursor/rules/upgrade_opentelemetry_instrumentations` and + follow those complete instructions step by step. + - Create one commit per package in `packages/**` with the commit message + `Bump OpenTelemetry instrumentations for {SDK}`, e.g. `Bump OpenTelemetry instrumentation for @sentry/node` + + - For each OpenTelemetry dependency bump, record an entry in the changelog with the format indented under the main + entry created in step 5: `- Bump @opentelemetry/{instrumentation} from {previous_version} to {new_version}`, e.g. + `- Bump @opentelemetry/instrumentation from 0.204.0 to 0.207.0` **CRITICAL**: Avoid duplicated entries, e.g. if we + bump @opentelemetry/instrumentation in two packages, keep a single changelog entry. + +7. Regenerate the yarn lockfile and run `yarn yarn-deduplicate` + +8. Run `yarn fix` to fix all formatting issues + +9. Finally update the PR description to list all dependency bumps diff --git a/.cursor/rules/publishing_release.mdc b/.cursor/rules/publishing_release.mdc index 4d6fecca5d2a..f50a5ea57f93 100644 --- a/.cursor/rules/publishing_release.mdc +++ b/.cursor/rules/publishing_release.mdc @@ -12,13 +12,18 @@ Use these guidelines when publishing a new Sentry JavaScript SDK release. The release process is outlined in [publishing-a-release.md](mdc:docs/publishing-a-release.md). -1. Make sure you are on the latest version of the `develop` branch. To confirm this, run `git pull origin develop` to get the latest changes from the repo. +1. Ensure you're on the `develop` branch with the latest changes: + - If you have unsaved changes, stash them with `git stash -u`. + - If you're on a different branch than `develop`, check out the develop branch using `git checkout develop`. + - Pull the latest updates from the remote repository by running `git pull origin develop`. + 2. Run `yarn changelog` on the `develop` branch and copy the output. You can use `yarn changelog | pbcopy` to copy the output of `yarn changelog` into your clipboard. 3. Decide on a version for the release based on [semver](mdc:https://semver.org). The version should be decided based on what is in included in the release. For example, if the release includes a new feature, we should increment the minor version. If it includes only bug fixes, we should increment the patch version. You can find the latest version in [CHANGELOG.md](mdc:CHANGELOG.md) at the very top. 4. Create a branch `prepare-release/VERSION`, eg. `prepare-release/8.1.0`, off `develop`. -5. Update [CHANGELOG.md](mdc:CHANGELOG.md) to add an entry for the next release number and a list of changes since the last release from the output of `yarn changelog`. See the `Updating the Changelog` section in [publishing-a-release.md](mdc:docs/publishing-a-release.md) for more details. If you remove changelog entries because they are not applicable, please let the user know. +5. Update [CHANGELOG.md](mdc:CHANGELOG.md) to add an entry for the next release number and a list of changes since the last release from the output of `yarn changelog`. See the `Updating the Changelog` section in [publishing-a-release.md](mdc:docs/publishing-a-release.md) for more details. Do not remove any changelog entries. 6. Commit the changes to [CHANGELOG.md](mdc:CHANGELOG.md) with `meta(changelog): Update changelog for VERSION` where `VERSION` is the version of the release, e.g. `meta(changelog): Update changelog for 8.1.0` 7. Push the `prepare-release/VERSION` branch to origin and remind the user that the release PR needs to be opened from the `master` branch. +8. In case you were working on a different branch, you can checkout back to the branch you were working on and continue your work by unstashing the changes you stashed earlier with the command `git stash pop` (only if you stashed changes). ## Key Commands diff --git a/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc b/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc new file mode 100644 index 000000000000..b650ae1f5041 --- /dev/null +++ b/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc @@ -0,0 +1,33 @@ +--- +description: Use this rule if you are looking to grade OpenTelemetry instrumentations for the Sentry JavaScript SDKs +globs: * +alwaysApply: false +--- + +# Upgrading OpenTelemetry instrumentations + +1. For every package in packages/\*\*: + - When upgrading dependencies for OpenTelemetry instrumentations we need to first upgrade `@opentelemetry/instrumentation` to the latest version. + **CRITICAL**: `@opentelemetry/instrumentation` MUST NOT include any breaking changes. + Read through the changelog of `@opentelemetry/instrumentation` to figure out if breaking changes are included and fail with the reason if it does include breaking changes. + You can find the changelog at `https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/CHANGELOG.md` + + - After successfully upgrading `@opentelemetry/instrumentation` upgrade all `@opentelemetry/instrumentation-{instrumentation}` packages, e.g. `@opentelemetry/instrumentation-pg` + **CRITICAL**: `@opentelemetry/instrumentation-{instrumentation}` MUST NOT include any breaking changes. + Read through the changelog of `@opentelemetry/instrumentation-{instrumentation}` to figure out if breaking changes are included and fail with the reason if it does including breaking changes. + You can find the changelogs at `https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-{instrumentation}/CHANGELOG.md`. + + - Finally, upgrade third party instrumentations to their latest versions, these are currently: + - @prisma/instrumentation + + **CRITICAL**: Upgrades to third party instrumentations MUST NOT include breaking changes. + Read through the changelog of each third party instrumentation to figure out if breaking changes are included and fail with the reason if it does include breaking changes. + +2. For packages and apps in dev-packages/\*\*: + - If an app depends on `@opentelemetry/instrumentation` >= 0.200.x upgrade it to the latest version. + **CRITICAL**: `@opentelemetry/instrumentation` MUST NOT include any breaking changes. + + - If an app depends on `@opentelemetry/instrumentation-http` >= 0.200.x upgrade it to the latest version. + **CRITICAL**: `@opentelemetry/instrumentation-http` MUST NOT include any breaking changes. + +3. Generate a new yarn lock file. diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 8acac6fd2709..c09984de5c3b 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -136,13 +136,14 @@ body: id: additional attributes: label: Additional Context - description: - Add any other context here. Please keep the pre-filled text, which helps us manage issue prioritization. - value: |- - Tip: React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding `+1` or `me too`, to help us triage it. + description: Add any other context here. validations: required: false - - type: markdown + - type: dropdown attributes: - value: |- - ## Thanks 🙏 + label: 'Priority' + description: Please keep the pre-filled option, which helps us manage issue prioritization. + default: 0 + options: + - React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding `+1` + or `me too`, to help us triage it. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 2859c10d2dc0..3809730ade4c 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -27,14 +27,14 @@ body: id: additional attributes: label: Additional Context - description: - Add any other context here. Please keep the pre-filled text, which helps us manage feature prioritization. - value: |- - Tip: React with 👍 to help prioritize this improvement. Please use comments to provide useful context, avoiding `+1` or `me too`, to help us triage it. + description: Add any other context here. validations: required: false - - type: markdown + - type: dropdown attributes: - value: |- - ## Thanks 🙏 - Check our [triage docs](https://open.sentry.io/triage/) for what to expect next. + label: 'Priority' + description: Please keep the pre-filled option, which helps us manage issue prioritization. + default: 0 + options: + - React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding `+1` + or `me too`, to help us triage it. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1df50881932d..66d551fabef8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,9 +14,15 @@ updates: interval: 'weekly' allow: - dependency-name: '@sentry/*' - - dependency-name: '@opentelemetry/*' - - dependency-name: '@prisma/instrumentation' - dependency-name: '@playwright/test' + - dependency-name: '@opentelemetry/*' + ignore: + - dependency-name: '@opentelemetry/instrumentation' + - dependency-name: '@opentelemetry/instrumentation-*' + groups: + opentelemetry: + patterns: + - '@opentelemetry/*' versioning-strategy: increase commit-message: prefix: feat diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml index 1a8f76e430d1..8608d2381ace 100644 --- a/.github/dependency-review-config.yml +++ b/.github/dependency-review-config.yml @@ -9,3 +9,5 @@ allow-ghsas: - GHSA-v784-fjjh-f8r4 # Next.js Cache poisoning - We require a vulnerable version for E2E testing - GHSA-gp8f-8m3g-qvj9 + # devalue vulnerability - this is just used by nuxt & astro as transitive dependency + - GHSA-vj54-72f3-p5jv diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 0507fe879c27..e1f22cff2f64 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 @@ -42,7 +42,7 @@ jobs: echo "version=$version" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46d6e7d4fac9..b351bdc647a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,7 +71,7 @@ jobs: pull-requests: read steps: - name: Check out current commit - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} # We need to check out not only the fake merge commit between the PR and the base branch which GH creates, but @@ -131,18 +131,18 @@ jobs: (needs.job_get_metadata.outputs.is_gitflow_sync == 'false' && needs.job_get_metadata.outputs.has_gitflow_label == 'false') steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: 'Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})' - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' @@ -181,7 +181,7 @@ jobs: run: yarn build - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: build-output path: ${{ env.CACHED_BUILD_PATHS }} @@ -238,11 +238,11 @@ jobs: needs.job_get_metadata.outputs.is_release == 'true' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -267,11 +267,11 @@ jobs: needs.job_get_metadata.outputs.is_base_branch == 'true' || needs.job_get_metadata.outputs.is_release == 'true' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -296,11 +296,11 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -325,12 +325,12 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' @@ -348,11 +348,11 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -370,11 +370,11 @@ jobs: if: needs.job_get_metadata.outputs.is_release == 'true' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -386,7 +386,7 @@ jobs: run: yarn build:tarball - name: Archive artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: ${{ github.sha }} retention-days: 90 @@ -405,17 +405,17 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -452,11 +452,11 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Set up Bun @@ -477,11 +477,11 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Set up Deno @@ -509,16 +509,16 @@ jobs: node: [18, 20, 22, 24] steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -603,11 +603,11 @@ jobs: steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -629,7 +629,7 @@ jobs: format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: @@ -667,11 +667,11 @@ jobs: steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -692,7 +692,7 @@ jobs: yarn test:loader - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: playwright-traces-job_browser_loader_tests-${{ matrix.bundle}} @@ -715,11 +715,11 @@ jobs: timeout-minutes: 5 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -753,11 +753,11 @@ jobs: typescript: '3.8' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -789,11 +789,11 @@ jobs: timeout-minutes: 15 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -810,18 +810,18 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_remix == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: node: [18, 20, 22, 24] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -864,16 +864,16 @@ jobs: matrix-optional: ${{ steps.matrix-optional.outputs.matrix }} steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -934,14 +934,14 @@ jobs: matrix: ${{ fromJson(needs.job_e2e_prepare.outputs.matrix) }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - uses: pnpm/action-setup@v4 with: version: 9.15.9 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun @@ -992,6 +992,8 @@ jobs: working-directory: ${{ runner.temp }}/test-application timeout-minutes: 7 run: ${{ matrix.build-command || 'pnpm test:build' }} + env: + SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Install Playwright uses: ./.github/actions/install-playwright @@ -1003,9 +1005,11 @@ jobs: working-directory: ${{ runner.temp }}/test-application timeout-minutes: 10 run: ${{ matrix.assert-command || 'pnpm test:assert' }} + env: + SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: playwright-traces-job_e2e_playwright_tests-${{ matrix.test-application}} @@ -1019,7 +1023,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) @@ -1064,14 +1068,14 @@ jobs: steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - uses: pnpm/action-setup@v4 with: version: 9.15.9 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Restore caches @@ -1131,7 +1135,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 29814ffea09c..1e71125ddad2 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -31,11 +31,11 @@ jobs: timeout-minutes: 30 steps: - name: Check out current commit - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Check canary cache @@ -117,7 +117,7 @@ jobs: steps: - name: Check out current commit - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - uses: pnpm/action-setup@v4 @@ -125,7 +125,7 @@ jobs: version: 9.15.9 - name: Set up Node if: matrix.test-application != 'angular-20' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' diff --git a/.github/workflows/cleanup-pr-caches.yml b/.github/workflows/cleanup-pr-caches.yml index 2c9bba513605..eb65d9a642c1 100644 --- a/.github/workflows/cleanup-pr-caches.yml +++ b/.github/workflows/cleanup-pr-caches.yml @@ -14,7 +14,7 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Cleanup run: | diff --git a/.github/workflows/clear-cache.yml b/.github/workflows/clear-cache.yml index 97aeb53365e7..3c76486cdbe2 100644 --- a/.github/workflows/clear-cache.yml +++ b/.github/workflows/clear-cache.yml @@ -23,10 +23,10 @@ jobs: name: Delete all caches runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8c042c5aa44f..00e6203b6b55 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -46,11 +46,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: config-file: ./.github/codeql/codeql-config.yml queries: security-extended @@ -63,7 +63,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions @@ -77,4 +77,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/create-issue-for-unreferenced-prs.yml b/.github/workflows/create-issue-for-unreferenced-prs.yml new file mode 100644 index 000000000000..a47df32738d7 --- /dev/null +++ b/.github/workflows/create-issue-for-unreferenced-prs.yml @@ -0,0 +1,129 @@ +# This GitHub Action workflow checks if a new or updated pull request +# references a GitHub issue in its title or body. If no reference is found, +# it automatically creates a new issue. This helps ensure all work is +# tracked, especially when syncing with tools like Linear. + +name: Create issue for unreferenced PR + +# This action triggers on pull request events +on: + pull_request: + types: [opened, edited, reopened, synchronize, ready_for_review] + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check_for_issue_reference: + runs-on: ubuntu-latest + if: | + !contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') + && !startsWith(github.event.pull_request.head.ref, 'external-contributor/') + && !startsWith(github.event.pull_request.head.ref, 'prepare-release/') + && !startsWith(github.event.pull_request.head.ref, 'dependabot/') + steps: + - name: Check PR Body and Title for Issue Reference + uses: actions/github-script@v8 + with: + script: | + const pr = context.payload.pull_request; + if (!pr) { + core.setFailed('Could not get PR from context.'); + return; + } + + // Don't create an issue for draft PRs + if (pr.draft) { + console.log(`PR #${pr.number} is a draft, skipping issue creation.`); + return; + } + + // Bail if this edit was made by the GitHub Actions bot (this workflow) + // This prevents infinite loops when we update the PR body with the new issue reference + // We check login specifically to not skip edits from other legitimate bots + if (context.payload.sender && context.payload.sender.login === 'github-actions[bot]') { + console.log(`PR #${pr.number} was edited by github-actions[bot] (this workflow), skipping.`); + return; + } + + // Check if the PR is already approved + const reviewsResponse = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }); + + if (reviewsResponse.data.some(review => review.state === 'APPROVED')) { + console.log(`PR #${pr.number} is already approved, skipping issue creation.`); + return; + } + + const prBody = pr.body || ''; + const prTitle = pr.title || ''; + const prAuthor = pr.user.login; + const prUrl = pr.html_url; + const prNumber = pr.number; + + // Regex for GitHub issue references (e.g., #123, fixes #456) + // https://regex101.com/r/eDiGrQ/1 + const issueRegexGitHub = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?(#\d+|https:\/\/github\.com\/getsentry\/[\w-]+\/issues\/\d+)/i; + + // Regex for Linear issue references (e.g., ENG-123, resolves ENG-456) + const issueRegexLinear = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?[A-Z]+-\d+/i; + + const contentToCheck = `${prTitle} ${prBody}`; + const hasIssueReference = issueRegexGitHub.test(contentToCheck) || issueRegexLinear.test(contentToCheck); + + if (hasIssueReference) { + console.log(`PR #${prNumber} contains a valid issue reference.`); + return; + } + + // Check if there's already an issue created by this automation for this PR + // Search for issues that mention this PR and were created by github-actions bot + const existingIssuesResponse = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open author:app/github-actions "${prUrl}" in:title in:body`, + }); + + if (existingIssuesResponse.data.total_count > 0) { + const existingIssue = existingIssuesResponse.data.items[0]; + console.log(`An issue (#${existingIssue.number}) already exists for PR #${prNumber}, skipping creation.`); + return; + } + + core.warning(`PR #${prNumber} does not have an issue reference. Creating a new issue so it can be tracked in Linear.`); + + // Construct the title and body for the new issue + const issueTitle = `${prTitle}`; + const issueBody = `> [!NOTE] + > The pull request "[${prTitle}](${prUrl})" was created by @${prAuthor} but did not reference an issue. Therefore this issue was created for better visibility in external tools like Linear. + + ${prBody} + `; + + // Create the issue using the GitHub API + const newIssue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + assignees: [prAuthor] + }); + + const issueID = newIssue.data.number; + console.log(`Created issue #${issueID}.`); + + // Update the PR body to reference the new issue + const updatedPrBody = `${prBody}\n\nCloses #${issueID} (added automatically)`; + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + body: updatedPrBody + }); + + console.log(`Updated PR #${prNumber} to reference newly created issue #${issueID}.`); diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 1735a89a5446..1566299d67e9 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -20,9 +20,9 @@ jobs: && github.event.pull_request.author_association != 'OWNER' && endsWith(github.event.pull_request.user.login, '[bot]') == false steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' @@ -36,7 +36,7 @@ jobs: author_association: ${{ github.event.pull_request.author_association }} - name: Create PR with changes - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 with: # This token is scoped to Daniel Griesser # If we used the default GITHUB_TOKEN, the resulting PR would not trigger CI :( diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 5103f1f43a2d..bb3169ecb410 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -30,9 +30,9 @@ jobs: if: ${{ github.base_ref != 'master' && github.ref != 'refs/heads/master' }} steps: - name: Check out current branch - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' cache: 'yarn' @@ -71,7 +71,7 @@ jobs: TEST_RUN_COUNT: 'AUTO' - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() && steps.test.outcome == 'failure' with: name: playwright-test-results diff --git a/.github/workflows/gitflow-merge-conflict.yml b/.github/workflows/gitflow-merge-conflict.yml new file mode 100644 index 000000000000..8b31d07afd6a --- /dev/null +++ b/.github/workflows/gitflow-merge-conflict.yml @@ -0,0 +1,112 @@ +name: 'Gitflow: Merge Conflict Issue' + +on: + pull_request: + types: [opened] + branches: + - develop + +jobs: + check-merge-conflicts: + name: Detect merge conflicts in gitflow PRs + runs-on: ubuntu-24.04 + if: | + ${{ contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') }} + permissions: + issues: write + steps: + - name: Check for merge conflicts with retry + uses: actions/github-script@v8 + with: + script: | + const initialDelay = 60_000; // Wait 1 minute before first check to let CI start + const retryInterval = 30_000; + const maxRetries = 10; // (30 seconds * 10 retries) = 5 minutes + + async function isMergeable() { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + return pr.mergeable; + } + + async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + let attempt = 0; + let mergeable = null; + + // Wait before first check to give CI time to start + console.log(`Waiting ${initialDelay/1000} seconds before first check to let CI start...`); + await sleep(initialDelay); + + while (attempt < maxRetries) { + attempt++; + console.log(`Attempt ${attempt}/${maxRetries}: Checking if PR is mergeable...`); + + mergeable = await isMergeable(); + console.log(`Mergeable: ${mergeable}`); + + // If mergeable is not null, GitHub has finished computing merge state + if (mergeable !== null) { + break; + } + + if (attempt < maxRetries) { + console.log(`Waiting ${retryInterval/1000} seconds before retry...`); + await sleep(retryInterval); + } + } + + // Check if we have merge conflicts + if (mergeable === false) { + const issueTitle = '[Gitflow] Merge Conflict'; + + // Check for existing open issues with the same title + const { data: existingIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'Dev: Gitflow' + }); + + const existingOpenIssue = existingIssues.find(issue => + issue.title === issueTitle && !issue.pull_request + ); + + if (!existingOpenIssue) { + const issueBody = [ + '## Gitflow Merge Conflict Detected', + '', + `The automated gitflow PR #${context.payload.pull_request.number} has merge conflicts and cannot be merged automatically.`, + '', + '### How to resolve', + '', + `Follow the steps documented in [docs/gitflow.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/develop/docs/gitflow.md#what-to-do-if-there-is-a-merge-conflict):`, + '', + `1. Close the automated PR #${context.payload.pull_request.number}`, + '2. Create a new branch on top of `master` (e.g., `manual-develop-sync`)', + '3. Merge `develop` into this branch with a **merge commit** (fix any merge conflicts)', + '4. Create a PR against `develop` from your branch', + '5. Merge that PR with a **merge commit**' + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: ['Dev: Gitflow'] + }); + + console.log('Created new issue for merge conflict'); + } + } else if (mergeable === null) { + console.log('Could not determine mergeable state after maximum retries'); + } else { + console.log('No merge conflicts detected - PR can be merged'); + } diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index 96c69d952264..ff649d6ee204 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -23,7 +23,7 @@ jobs: contents: write steps: - name: git checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 # https://github.com/marketplace/actions/github-pull-request-action - name: Create Pull Request diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05c465036ce4..4a0278ae85a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,16 +19,16 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Prepare release diff --git a/.size-limit.js b/.size-limit.js index 7106f2e29b03..6e6ee0f68303 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -5,14 +5,14 @@ module.exports = [ // Browser SDK (ESM) { name: '@sentry/browser', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, limit: '25 KB', }, { name: '@sentry/browser - with treeshaking flags', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, limit: '24.1 KB', @@ -35,28 +35,28 @@ module.exports = [ }, { name: '@sentry/browser (incl. Tracing)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41 KB', + limit: '42 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), gzip: true, limit: '48 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, limit: '80 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, limit: '75 KB', @@ -79,35 +79,35 @@ module.exports = [ }, { name: '@sentry/browser (incl. Tracing, Replay with Canvas)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, limit: '85 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, limit: '97 KB', }, { name: '@sentry/browser (incl. Feedback)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, limit: '42 KB', }, { name: '@sentry/browser (incl. sendFeedback)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, limit: '30 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', - path: 'packages/browser/build/npm/esm/index.js', + path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, limit: '35 KB', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43 KB', + limit: '44 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43 KB', + limit: '44 KB', }, // Svelte SDK (ESM) { @@ -157,13 +157,13 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '27 KB', + limit: '27.5 KB', }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -183,21 +183,21 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '80 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '124 KB', + limit: '127 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '240 KB', + limit: '245 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', @@ -231,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '51 KB', + limit: '52 KB', }, // Node SDK (ESM) { @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '158 KB', + limit: '160 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index d91a753f6544..d967a7c39408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,407 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.30.0 + +- feat(nextjs): Deprecate Webpack top-level options ([#18343](https://github.com/getsentry/sentry-javascript/pull/18343)) +- feat(node): Capture scope when event loop blocked ([#18040](https://github.com/getsentry/sentry-javascript/pull/18040)) +- fix(aws-serverless): Remove hyphens from AWS-lambda origins ([#18353](https://github.com/getsentry/sentry-javascript/pull/18353)) +- fix(core): Parse method from Request object in fetch ([#18453](https://github.com/getsentry/sentry-javascript/pull/18453)) +- fix(react): Add transaction name guards for rapid lazy-route navigations ([#18346](https://github.com/getsentry/sentry-javascript/pull/18346)) + +
+ Internal Changes + +- chore(ci): Fix double issue creation for unreferenced PRs ([#18442](https://github.com/getsentry/sentry-javascript/pull/18442)) +- chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15 ([#18411](https://github.com/getsentry/sentry-javascript/pull/18411)) +- chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15-intl ([#18400](https://github.com/getsentry/sentry-javascript/pull/18400)) +- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16 ([#18399](https://github.com/getsentry/sentry-javascript/pull/18399)) +- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents ([#18427](https://github.com/getsentry/sentry-javascript/pull/18427)) +- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-tunnel ([#18439](https://github.com/getsentry/sentry-javascript/pull/18439)) +- chore(publish): Fix publish order for `@sentry/types` ([#18429](https://github.com/getsentry/sentry-javascript/pull/18429)) +- ci(deps): bump actions/create-github-app-token from 2.1.4 to 2.2.0 ([#18362](https://github.com/getsentry/sentry-javascript/pull/18362)) + +
+ +## 10.29.0 + +### Important Changes + +- **feat(solid|solidstart): Bump accepted @solidjs/router range ([#18395](https://github.com/getsentry/sentry-javascript/pull/18395))** + +We expanded the supported version range for `@solidjs/router` to include `0.14.x` and `0.15.x` versions. + +### Other Changes + +- fix(logs): Add support for `msg` in pino integration ([#18389](https://github.com/getsentry/sentry-javascript/pull/18389)) +- fix(node): Include system message in anthropic-ai messages span ([#18332](https://github.com/getsentry/sentry-javascript/pull/18332)) +- fix(tracing): Add missing attributes in vercel-ai spans ([#18333](https://github.com/getsentry/sentry-javascript/pull/18333)) + +
+ Internal Changes + +- chore(tanstackstart-react): clean up re-exported types ([#18393](https://github.com/getsentry/sentry-javascript/pull/18393)) +- ref(core): Avoid looking up openai integration options ([#17695](https://github.com/getsentry/sentry-javascript/pull/17695)) +- test(nuxt): Relax captured unhandled error assertion ([#18397](https://github.com/getsentry/sentry-javascript/pull/18397)) +- test(tanstackstart-react): Set up E2E test application ([#18358](https://github.com/getsentry/sentry-javascript/pull/18358)) + +
+ +## 10.28.0 + +### Important Changes + +- **feat(core): Make `matcher` parameter optional in `makeMultiplexedTransport` ([#10798](https://github.com/getsentry/sentry-javascript/pull/10798))** + +The `matcher` parameter in `makeMultiplexedTransport` is now optional with a sensible default. This makes it much easier to use the multiplexed transport for sending events to multiple DSNs based on runtime configuration. + +**Before:** + +```javascript +import { makeFetchTransport, makeMultiplexedTransport } from '@sentry/browser'; + +const EXTRA_KEY = 'ROUTE_TO'; + +const transport = makeMultiplexedTransport(makeFetchTransport, args => { + const event = args.getEvent(); + if (event?.extra?.[EXTRA_KEY] && Array.isArray(event.extra[EXTRA_KEY])) { + return event.extra[EXTRA_KEY]; + } + return []; +}); + +Sentry.init({ + transport, + // ... other options +}); + +// Capture events with routing info +Sentry.captureException(error, { + extra: { + [EXTRA_KEY]: [ + { dsn: 'https://key1@sentry.io/project1', release: 'v1.0.0' }, + { dsn: 'https://key2@sentry.io/project2' }, + ], + }, +}); +``` + +**After:** + +```javascript +import { makeFetchTransport, makeMultiplexedTransport, MULTIPLEXED_TRANSPORT_EXTRA_KEY } from '@sentry/browser'; + +// Just pass the transport generator - the default matcher handles the rest! +Sentry.init({ + transport: makeMultiplexedTransport(makeFetchTransport), + // ... other options +}); + +// Capture events with routing info using the exported constant +Sentry.captureException(error, { + extra: { + [MULTIPLEXED_TRANSPORT_EXTRA_KEY]: [ + { dsn: 'https://key1@sentry.io/project1', release: 'v1.0.0' }, + { dsn: 'https://key2@sentry.io/project2' }, + ], + }, +}); +``` + +The default matcher looks for routing information in `event.extra[MULTIPLEXED_TRANSPORT_EXTRA_KEY]`. You can still provide a custom matcher function for advanced use cases. + +- **feat(nextjs): Support cacheComponents on turbopack ([#18304](https://github.com/getsentry/sentry-javascript/pull/18304))** + +This release adds support for `cacheComponents` on turbopack builds. We are working on adding support for this feature in webpack builds as well. + +### Other Changes + +- feat: Publish AWS Lambda Layer for Node 24 ([#18327](https://github.com/getsentry/sentry-javascript/pull/18327)) +- feat(browser): Expose langchain instrumentation ([#18342](https://github.com/getsentry/sentry-javascript/pull/18342)) +- feat(browser): Expose langgraph instrumentation ([#18345](https://github.com/getsentry/sentry-javascript/pull/18345)) +- feat(cloudflare): Allow specifying a custom fetch in Cloudflare transport options ([#18335](https://github.com/getsentry/sentry-javascript/pull/18335)) +- feat(core): Add `isolateTrace` option to `Sentry.withMonitor()` ([#18079](https://github.com/getsentry/sentry-javascript/pull/18079)) +- feat(deps): bump @sentry/webpack-plugin from 4.3.0 to 4.6.1 ([#18272](https://github.com/getsentry/sentry-javascript/pull/18272)) +- feat(nextjs): Add cloudflare `waitUntil` detection ([#18336](https://github.com/getsentry/sentry-javascript/pull/18336)) +- feat(node): Add LangChain v1 support ([#18306](https://github.com/getsentry/sentry-javascript/pull/18306)) +- feat(remix): Add parameterized transaction naming for routes ([#17951](https://github.com/getsentry/sentry-javascript/pull/17951)) +- fix(cloudflare): Keep http root span alive until streaming responses are consumed ([#18087](https://github.com/getsentry/sentry-javascript/pull/18087)) +- fix(cloudflare): Wait for async events to finish ([#18334](https://github.com/getsentry/sentry-javascript/pull/18334)) +- fix(core): `continueTrace` doesn't propagate given trace ID if active span exists ([#18328](https://github.com/getsentry/sentry-javascript/pull/18328)) +- fix(node-core): Handle custom scope in log messages without parameters ([#18322](https://github.com/getsentry/sentry-javascript/pull/18322)) +- fix(opentelemetry): Ensure Sentry spans don't leak when tracing is disabled ([#18337](https://github.com/getsentry/sentry-javascript/pull/18337)) +- fix(react-router): Use underscores in trace origin values ([#18351](https://github.com/getsentry/sentry-javascript/pull/18351)) +- chore(tanstackstart-react): Export custom inits from tanstackstart-react ([#18369](https://github.com/getsentry/sentry-javascript/pull/18369)) +- chore(tanstackstart-react)!: Remove empty placeholder implementations ([#18338](https://github.com/getsentry/sentry-javascript/pull/18338)) + +
+ Internal Changes + +- chore: Allow URLs as issue ([#18372](https://github.com/getsentry/sentry-javascript/pull/18372)) +- chore(changelog): Add entry for [#18304](https://github.com/getsentry/sentry-javascript/pull/18304) ([#18329](https://github.com/getsentry/sentry-javascript/pull/18329)) +- chore(ci): Add action to track all PRs as issues ([#18363](https://github.com/getsentry/sentry-javascript/pull/18363)) +- chore(github): Adjust `BUGBOT.md` rules to flag invalid op and origin values during review ([#18352](https://github.com/getsentry/sentry-javascript/pull/18352)) +- ci: Add action to create issue on gitflow merge conflicts ([#18319](https://github.com/getsentry/sentry-javascript/pull/18319)) +- ci(deps): bump actions/checkout from 5 to 6 ([#18268](https://github.com/getsentry/sentry-javascript/pull/18268)) +- ci(deps): bump peter-evans/create-pull-request from 7.0.8 to 7.0.9 ([#18361](https://github.com/getsentry/sentry-javascript/pull/18361)) +- test(cloudflare): Add typechecks for cloudflare-worker e2e test ([#18321](https://github.com/getsentry/sentry-javascript/pull/18321)) + +
+ +## 10.27.0 + +### Important Changes + +- **feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239))** + - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/core from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/resources from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/sdk-trace-base from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/sdk-trace-node from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/instrumentation from 0.204.0 to 0.208.0 + - Bump @opentelemetry/instrumentation-amqplib from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-aws-sdk from 0.59.0 to 0.64.0 + - Bump @opentelemetry/instrumentation-connect from 0.48.0 to 0.52.0 + - Bump @opentelemetry/instrumentation-dataloader from 0.22.0 to 0.26.0 + - Bump @opentelemetry/instrumentation-express from 0.53.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-fs from 0.24.0 to 0.28.0 + - Bump @opentelemetry/instrumentation-generic-pool from 0.48.0 to 0.52.0 + - Bump @opentelemetry/instrumentation-graphql from 0.52.0 to 0.56.0 + - Bump @opentelemetry/instrumentation-hapi from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-http from 0.204.0 to 0.208.0 + - Bump @opentelemetry/instrumentation-ioredis from 0.52.0 to 0.56.0 + - Bump @opentelemetry/instrumentation-kafkajs from 0.14.0 to 0.18.0 + - Bump @opentelemetry/instrumentation-knex from 0.49.0 to 0.53.0 + - Bump @opentelemetry/instrumentation-koa from 0.52.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-lru-memoizer from 0.49.0 to 0.53.0 + - Bump @opentelemetry/instrumentation-mongodb from 0.57.0 to 0.61.0 + - Bump @opentelemetry/instrumentation-mongoose from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-mysql from 0.50.0 to 0.54.0 + - Bump @opentelemetry/instrumentation-mysql2 from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-nestjs-core from 0.50.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-pg from 0.57.0 to 0.61.0 + - Bump @opentelemetry/instrumentation-redis from 0.53.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-tedious from 0.23.0 to 0.27.0 + - Bump @opentelemetry/instrumentation-undici from 0.15.0 to 0.19.0 + - Bump @prisma/instrumentation from 6.15.0 to 6.19.0 + +- **feat(browserprofiling): Add `manual` mode and deprecate old profiling ([#18189](https://github.com/getsentry/sentry-javascript/pull/18189))** + + Adds the `manual` lifecycle mode for UI profiling (the default mode), allowing profiles to be captured manually with `Sentry.uiProfiler.startProfiler()` and `Sentry.uiProfiler.stopProfiler()`. + The previous transaction-based profiling is with `profilesSampleRate` is now deprecated in favor of the new UI Profiling with `profileSessionSampleRate`. + +### Other Changes + +- feat(core): Add `gibibyte` and `pebibyte` to `InformationUnit` type ([#18241](https://github.com/getsentry/sentry-javascript/pull/18241)) +- feat(core): Add scope attribute APIs ([#18165](https://github.com/getsentry/sentry-javascript/pull/18165)) +- feat(core): Re-add `_experiments.enableLogs` option ([#18299](https://github.com/getsentry/sentry-javascript/pull/18299)) +- feat(core): Use `maxValueLength` on error messages ([#18301](https://github.com/getsentry/sentry-javascript/pull/18301)) +- feat(deps): bump @sentry/bundler-plugin-core from 4.3.0 to 4.6.1 ([#18273](https://github.com/getsentry/sentry-javascript/pull/18273)) +- feat(deps): bump @sentry/cli from 2.56.0 to 2.58.2 ([#18271](https://github.com/getsentry/sentry-javascript/pull/18271)) +- feat(node): Add tracing support for AzureOpenAI ([#18281](https://github.com/getsentry/sentry-javascript/pull/18281)) +- feat(node): Fix local variables capturing for out-of-app frames ([#18245](https://github.com/getsentry/sentry-javascript/pull/18245)) +- fix(core): Add a PromiseBuffer for incoming events on the client ([#18120](https://github.com/getsentry/sentry-javascript/pull/18120)) +- fix(core): Always redact content of sensitive headers regardless of `sendDefaultPii` ([#18311](https://github.com/getsentry/sentry-javascript/pull/18311)) +- fix(metrics): Update return type of `beforeSendMetric` ([#18261](https://github.com/getsentry/sentry-javascript/pull/18261)) +- fix(nextjs): universal random tunnel path support ([#18257](https://github.com/getsentry/sentry-javascript/pull/18257)) +- ref(react): Add more guarding against wildcards in lazy route transactions ([#18155](https://github.com/getsentry/sentry-javascript/pull/18155)) +- chore(deps): bump glob from 11.0.1 to 11.1.0 in /packages/react-router ([#18243](https://github.com/getsentry/sentry-javascript/pull/18243)) + +
+ Internal Changes + - build(deps): bump hono from 4.9.7 to 4.10.3 in /dev-packages/e2e-tests/test-applications/cloudflare-hono ([#18038](https://github.com/getsentry/sentry-javascript/pull/18038)) + - chore: Add `bump_otel_instrumentations` cursor command ([#18253](https://github.com/getsentry/sentry-javascript/pull/18253)) + - chore: Add external contributor to CHANGELOG.md ([#18297](https://github.com/getsentry/sentry-javascript/pull/18297)) + - chore: Add external contributor to CHANGELOG.md ([#18300](https://github.com/getsentry/sentry-javascript/pull/18300)) + - chore: Do not update opentelemetry ([#18254](https://github.com/getsentry/sentry-javascript/pull/18254)) + - chore(angular): Add Angular 21 Support ([#18274](https://github.com/getsentry/sentry-javascript/pull/18274)) + - chore(deps): bump astro from 4.16.18 to 5.15.9 in /dev-packages/e2e-tests/test-applications/cloudflare-astro ([#18259](https://github.com/getsentry/sentry-javascript/pull/18259)) + - chore(dev-deps): Update some dev dependencies ([#17816](https://github.com/getsentry/sentry-javascript/pull/17816)) + - ci(deps): Bump actions/create-github-app-token from 2.1.1 to 2.1.4 ([#17825](https://github.com/getsentry/sentry-javascript/pull/17825)) + - ci(deps): bump actions/setup-node from 4 to 6 ([#18077](https://github.com/getsentry/sentry-javascript/pull/18077)) + - ci(deps): bump actions/upload-artifact from 4 to 5 ([#18075](https://github.com/getsentry/sentry-javascript/pull/18075)) + - ci(deps): bump github/codeql-action from 3 to 4 ([#18076](https://github.com/getsentry/sentry-javascript/pull/18076)) + - doc(sveltekit): Update documentation link for SvelteKit guide ([#18298](https://github.com/getsentry/sentry-javascript/pull/18298)) + - test(e2e): Fix astro config in test app ([#18282](https://github.com/getsentry/sentry-javascript/pull/18282)) + - test(nextjs): Remove debug logs from e2e test ([#18250](https://github.com/getsentry/sentry-javascript/pull/18250)) +
+ +Work in this release was contributed by @bignoncedric and @adam-kov. Thank you for your contributions! + +## 10.26.0 + +### Important Changes + +- **feat(core): Instrument LangGraph Agent ([#18114](https://github.com/getsentry/sentry-javascript/pull/18114))** + +Adds support for instrumenting LangGraph StateGraph operations in Node. The LangGraph integration can be configured as follows: + +```js +Sentry.init({ + dsn: '__DSN__', + sendDefaultPii: false, // Even with PII disabled globally + integrations: [ + Sentry.langGraphIntegration({ + recordInputs: true, // Force recording input messages + recordOutputs: true, // Force recording response text + }), + ], +}); +``` + +- **feat(cloudflare/vercel-edge): Add manual instrumentation for LangGraph ([#18112](https://github.com/getsentry/sentry-javascript/pull/18112))** + +Instrumentation for LangGraph in Cloudflare Workers and Vercel Edge environments is supported by manually calling `instrumentLangGraph`: + +```js +import * as Sentry from '@sentry/cloudflare'; // or '@sentry/vercel-edge' +import { StateGraph, START, END, MessagesAnnotation } from '@langchain/langgraph'; + +// Create and instrument the graph +const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', agentFn) + .addEdge(START, 'agent') + .addEdge('agent', END); + +Sentry.instrumentLangGraph(graph, { + recordInputs: true, + recordOutputs: true, +}); + +const compiled = graph.compile({ name: 'weather_assistant' }); + +await compiled.invoke({ + messages: [{ role: 'user', content: 'What is the weather in SF?' }], +}); +``` + +- **feat(node): Add OpenAI SDK v6 support ([#18244](https://github.com/getsentry/sentry-javascript/pull/18244))** + +### Other Changes + +- feat(core): Support OpenAI embeddings API ([#18224](https://github.com/getsentry/sentry-javascript/pull/18224)) +- feat(browser-utils): bump web-vitals to 5.1.0 ([#18091](https://github.com/getsentry/sentry-javascript/pull/18091)) +- feat(core): Support truncation for LangChain integration request messages ([#18157](https://github.com/getsentry/sentry-javascript/pull/18157)) +- feat(metrics): Add default `server.address` attribute on server runtimes ([#18242](https://github.com/getsentry/sentry-javascript/pull/18242)) +- feat(nextjs): Add URL to server-side transaction events ([#18230](https://github.com/getsentry/sentry-javascript/pull/18230)) +- feat(node-core): Add mechanism to prevent wrapping ai providers multiple times([#17972](https://github.com/getsentry/sentry-javascript/pull/17972)) +- feat(replay): Bump limit for minReplayDuration ([#18190](https://github.com/getsentry/sentry-javascript/pull/18190)) +- fix(browser): Add `ok` status to successful `idleSpan`s ([#18139](https://github.com/getsentry/sentry-javascript/pull/18139)) +- fix(core): Check `fetch` support with data URL ([#18225](https://github.com/getsentry/sentry-javascript/pull/18225)) +- fix(core): Decrease number of Sentry stack frames for messages from `captureConsoleIntegration` ([#18096](https://github.com/getsentry/sentry-javascript/pull/18096)) +- fix(core): Emit processed metric ([#18222](https://github.com/getsentry/sentry-javascript/pull/18222)) +- fix(core): Ensure logs past `MAX_LOG_BUFFER_SIZE` are not swallowed ([#18207](https://github.com/getsentry/sentry-javascript/pull/18207)) +- fix(core): Ensure metrics past `MAX_METRIC_BUFFER_SIZE` are not swallowed ([#18212](https://github.com/getsentry/sentry-javascript/pull/18212)) +- fix(core): Fix logs and metrics flush timeout starvation with continuous logging ([#18211](https://github.com/getsentry/sentry-javascript/pull/18211)) +- fix(core): Flatten gen_ai.request.available_tools in google-genai ([#18194](https://github.com/getsentry/sentry-javascript/pull/18194)) +- fix(core): Stringify available tools sent from vercelai ([#18197](https://github.com/getsentry/sentry-javascript/pull/18197)) +- fix(core/vue): Detect and skip normalizing Vue `VNode` objects with high `normalizeDepth` ([#18206](https://github.com/getsentry/sentry-javascript/pull/18206)) +- fix(nextjs): Avoid wrapping middleware files when in standalone mode ([#18172](https://github.com/getsentry/sentry-javascript/pull/18172)) +- fix(nextjs): Drop meta trace tags if rendered page is ISR ([#18192](https://github.com/getsentry/sentry-javascript/pull/18192)) +- fix(nextjs): Respect PORT variable for dev error symbolication ([#18227](https://github.com/getsentry/sentry-javascript/pull/18227)) +- fix(nextjs): use LRU map instead of map for ISR route cache ([#18234](https://github.com/getsentry/sentry-javascript/pull/18234)) +- fix(node): `tracingChannel` export missing in older node versions ([#18191](https://github.com/getsentry/sentry-javascript/pull/18191)) +- fix(node): Fix Spotlight configuration precedence to match specification ([#18195](https://github.com/getsentry/sentry-javascript/pull/18195)) +- fix(react): Prevent navigation span leaks for consecutive navigations ([#18098](https://github.com/getsentry/sentry-javascript/pull/18098)) +- ref(react-router): Deprecate ErrorBoundary exports ([#18208](https://github.com/getsentry/sentry-javascript/pull/18208)) + +
+ Internal Changes + +- chore: Fix missing changelog quote we use for attribution placement ([#18237](https://github.com/getsentry/sentry-javascript/pull/18237)) +- chore: move tip about prioritizing issues ([#18071](https://github.com/getsentry/sentry-javascript/pull/18071)) +- chore(e2e): Pin `@embroider/addon-shim` to 1.10.0 for the e2e ember-embroider ([#18173](https://github.com/getsentry/sentry-javascript/pull/18173)) +- chore(react-router): Fix casing on deprecation notices ([#18221](https://github.com/getsentry/sentry-javascript/pull/18221)) +- chore(test): Use correct `testTimeout` field in bundler-tests vitest config +- chore(e2e): Bump zod in e2e tests ([#18251](https://github.com/getsentry/sentry-javascript/pull/18251)) +- test(browser-integration): Fix incorrect tag value assertions ([#18162](https://github.com/getsentry/sentry-javascript/pull/18162)) +- test(profiling): Add test utils to validate Profile Chunk envelope ([#18170](https://github.com/getsentry/sentry-javascript/pull/18170)) +- ref(e2e-ember): Remove `@embroider/addon-shim` override ([#18180](https://github.com/getsentry/sentry-javascript/pull/18180)) +- ref(browser): Move trace lifecycle listeners to class function ([#18231](https://github.com/getsentry/sentry-javascript/pull/18231)) +- ref(browserprofiling): Move and rename profiler class to UIProfiler ([#18187](https://github.com/getsentry/sentry-javascript/pull/18187)) +- ref(core): Move ai integrations from utils to tracing ([#18185](https://github.com/getsentry/sentry-javascript/pull/18185)) +- ref(core): Optimize `Scope.setTag` bundle size and adjust test ([#18182](https://github.com/getsentry/sentry-javascript/pull/18182)) + +
+ +## 10.25.0 + +- feat(browser): Include Spotlight in development bundles ([#18078](https://github.com/getsentry/sentry-javascript/pull/18078)) +- feat(cloudflare): Add metrics exports ([#18147](https://github.com/getsentry/sentry-javascript/pull/18147)) +- feat(core): Truncate request string inputs in OpenAI integration ([#18136](https://github.com/getsentry/sentry-javascript/pull/18136)) +- feat(metrics): Add missing metric node exports ([#18149](https://github.com/getsentry/sentry-javascript/pull/18149)) +- feat(node): Add `maxCacheKeyLength` to Redis integration (remove truncation) ([#18045](https://github.com/getsentry/sentry-javascript/pull/18045)) +- feat(vercel-edge): Add metrics export ([#18148](https://github.com/getsentry/sentry-javascript/pull/18148)) +- fix(core): Only consider exception mechanism when updating session status from event with exceptions ([#18137](https://github.com/getsentry/sentry-javascript/pull/18137)) +- ref(browser): Remove truncation when not needed ([#18051](https://github.com/getsentry/sentry-javascript/pull/18051)) + +
+ Internal Changes + +- chore(build): Fix incorrect versions after merge ([#18154](https://github.com/getsentry/sentry-javascript/pull/18154)) +
+ +## 10.24.0 + +### Important Changes + +- **feat(metrics): Add top level option `enableMetrics` and `beforeSendMetric` ([#18088](https://github.com/getsentry/sentry-javascript/pull/18088))** + + This PR moves `enableMetrics` and `beforeSendMetric` out of the `_experiments` options. + The metrics feature will now be **enabled by default** (none of our integrations will auto-emit metrics as of now), but you can disable sending metrics via `enableMetrics: false`. + Metric options within `_experiments` got deprecated but will still work as of now, they will be removed with the next major version of our SDKs. + +### Other Changes + +- feat(aws): Add `SENTRY_LAYER_EXTENSION` to configure using the lambda layer extension via env variables ([#18101](https://github.com/getsentry/sentry-javascript/pull/18101)) +- feat(core): Include all exception object keys instead of truncating ([#18044](https://github.com/getsentry/sentry-javascript/pull/18044)) +- feat(metrics)!: Update types ([#17907](https://github.com/getsentry/sentry-javascript/pull/17907)) +- feat(replay): ignore `background-image` when `blockAllMedia` is enabled ([#18019](https://github.com/getsentry/sentry-javascript/pull/18019)) +- fix(nextjs): Delete css map files ([#18131](https://github.com/getsentry/sentry-javascript/pull/18131)) +- fix(nextjs): Stop accessing sync props in template ([#18113](https://github.com/getsentry/sentry-javascript/pull/18113)) + +
+ Internal Changes + +- chore: X handle update ([#18117](https://github.com/getsentry/sentry-javascript/pull/18117)) +- chore(eslint): Add eslint-plugin-regexp rule (dev-packages) ([#18063](https://github.com/getsentry/sentry-javascript/pull/18063)) +- test(next): fix flakey tests ([#18100](https://github.com/getsentry/sentry-javascript/pull/18100)) +- test(node-core): Proof that withMonitor doesn't create a new trace ([#18057](https://github.com/getsentry/sentry-javascript/pull/18057)) +
+ +## 10.23.0 + +- feat(core): Send `user-agent` header with envelope requests in server SDKs ([#17929](https://github.com/getsentry/sentry-javascript/pull/17929)) +- feat(browser): Limit transport buffer size ([#18046](https://github.com/getsentry/sentry-javascript/pull/18046)) +- feat(core): Remove default value of `maxValueLength: 250` ([#18043](https://github.com/getsentry/sentry-javascript/pull/18043)) +- feat(react-router): Align options with shared build time options type ([#18014](https://github.com/getsentry/sentry-javascript/pull/18014)) +- fix(browser-utils): cache element names for INP ([#18052](https://github.com/getsentry/sentry-javascript/pull/18052)) +- fix(browser): Capture unhandled rejection errors for web worker integration ([#18054](https://github.com/getsentry/sentry-javascript/pull/18054)) +- fix(cloudflare): Ensure types for cloudflare handlers ([#18064](https://github.com/getsentry/sentry-javascript/pull/18064)) +- fix(nextjs): Update proxy template wrapping ([#18086](https://github.com/getsentry/sentry-javascript/pull/18086)) +- fix(nuxt): Added top-level fallback exports ([#18083](https://github.com/getsentry/sentry-javascript/pull/18083)) +- fix(nuxt): check for H3 error cause before re-capturing ([#18035](https://github.com/getsentry/sentry-javascript/pull/18035)) +- fix(replay): Linked errors not resetting session id ([#17854](https://github.com/getsentry/sentry-javascript/pull/17854)) +- fix(tracemetrics): Bump metrics buffer to 1k ([#18039](https://github.com/getsentry/sentry-javascript/pull/18039)) +- fix(vue): Make `options` parameter optional on `attachErrorHandler` ([#18072](https://github.com/getsentry/sentry-javascript/pull/18072)) +- ref(core): Set span status `internal_error` instead of `unknown_error` ([#17909](https://github.com/getsentry/sentry-javascript/pull/17909)) + +
+ Internal Changes + +- fix(tests): un-override nitro dep version for nuxt-3 test ([#18056](https://github.com/getsentry/sentry-javascript/pull/18056)) +- fix(e2e): Add p-map override to fix React Router 7 test builds ([#18068](https://github.com/getsentry/sentry-javascript/pull/18068)) +- feat: Add a note to save changes before starting ([#17987](https://github.com/getsentry/sentry-javascript/pull/17987)) +- test(browser): Add test for INP target name after navigation or DOM changes ([#18033](https://github.com/getsentry/sentry-javascript/pull/18033)) +- chore: Add external contributor to CHANGELOG.md ([#18032](https://github.com/getsentry/sentry-javascript/pull/18032)) +- chore(aws-serverless): Fix typo in timeout warning function name ([#18031](https://github.com/getsentry/sentry-javascript/pull/18031)) +- chore(browser): upgrade fake-indexeddb to v6 ([#17975](https://github.com/getsentry/sentry-javascript/pull/17975)) +- chore(tests): pass test flags through to the test command ([#18062](https://github.com/getsentry/sentry-javascript/pull/18062)) + +
+ +Work in this release was contributed by @hanseo0507. Thank you for your contribution! + ## 10.22.0 ### Important Changes diff --git a/README.md b/README.md index 5f76eb4f7a11..3fdd9ddfc452 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ convenient interface and improved consistency between various JavaScript environ - [![Forum](https://img.shields.io/badge/forum-sentry-green.svg)](https://forum.sentry.io/c/sdks) - [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) - [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](http://stackoverflow.com/questions/tagged/sentry) -- [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) +- [![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry) ## Contents diff --git a/dev-packages/browser-integration-tests/.eslintrc.js b/dev-packages/browser-integration-tests/.eslintrc.js index a19cfba8812a..8c07222e9a7c 100644 --- a/dev-packages/browser-integration-tests/.eslintrc.js +++ b/dev-packages/browser-integration-tests/.eslintrc.js @@ -3,7 +3,9 @@ module.exports = { browser: true, node: true, }, - extends: ['../../.eslintrc.js'], + // todo: remove regexp plugin from here once we add it to base.js eslint config for the whole project + extends: ['../../.eslintrc.js', 'plugin:regexp/recommended'], + plugins: ['regexp'], ignorePatterns: [ 'suites/**/subject.js', 'suites/**/dist/*', diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 6de23391a0ba..a42cd499956a 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "10.22.0", + "version": "10.30.0", "main": "index.js", "license": "MIT", "engines": { @@ -43,7 +43,7 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.53.2", "@sentry-internal/rrweb": "2.34.0", - "@sentry/browser": "10.22.0", + "@sentry/browser": "10.30.0", "@supabase/supabase-js": "2.49.3", "axios": "^1.12.2", "babel-loader": "^8.2.2", @@ -54,6 +54,7 @@ "devDependencies": { "@types/glob": "8.0.0", "@types/node": "^18.19.1", + "eslint-plugin-regexp": "^1.15.0", "glob": "8.0.3" }, "volta": { diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts index c145e64bd1da..b37fa79ed97e 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts @@ -143,7 +143,7 @@ sentryTest('should capture Supabase authentication errors', async ({ getLocalTes start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: transactionEvent.contexts?.trace?.trace_id, - status: 'unknown_error', + status: 'internal_error', data: expect.objectContaining({ 'sentry.op': 'db', 'sentry.origin': 'auto.db.supabase', diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js b/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js index 59af46d764e2..8b70a34fc46e 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js @@ -1,14 +1,34 @@ +// This worker manually replicates what Sentry.registerWebWorker() does +// (In real code with a bundler, you'd import and call Sentry.registerWebWorker({ self })) + self._sentryDebugIds = { 'Error at http://sentry-test.io/worker.js': 'worker-debug-id-789', }; +// Send debug IDs self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds, }); +// Set up unhandledrejection handler (same as registerWebWorker) +self.addEventListener('unhandledrejection', event => { + self.postMessage({ + _sentryMessage: true, + _sentryWorkerError: { + reason: event.reason, + filename: self.location.href, + }, + }); +}); + self.addEventListener('message', event => { if (event.data.type === 'throw-error') { throw new Error('Worker error for testing'); } + + if (event.data.type === 'throw-rejection') { + // Create an unhandled rejection + Promise.reject(new Error('Worker unhandled rejection')); + } }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js index aa08cd652418..100b16a2d408 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js @@ -9,10 +9,17 @@ const worker = new Worker('/worker.js'); Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); -const btn = document.getElementById('errWorker'); +const btnError = document.getElementById('errWorker'); +const btnRejection = document.getElementById('rejectionWorker'); -btn.addEventListener('click', () => { +btnError.addEventListener('click', () => { worker.postMessage({ type: 'throw-error', }); }); + +btnRejection.addEventListener('click', () => { + worker.postMessage({ + type: 'throw-rejection', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html b/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html index 1c36227c5a3d..d1124baa59a9 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html @@ -5,5 +5,6 @@ + diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts b/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts index bb5adf0ac70a..8133a24253f9 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts @@ -36,3 +36,32 @@ sentryTest('Assigns web worker debug IDs when using webWorkerIntegration', async expect(image.code_file).toEqual('http://sentry-test.io/worker.js'); }); }); + +sentryTest('Captures unhandled rejections from web workers', async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE; + if (bundle != null && !bundle.includes('esm') && !bundle.includes('cjs')) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const errorEventPromise = getFirstSentryEnvelopeRequest(page, url); + + page.route('**/worker.js', route => { + route.fulfill({ + path: `${__dirname}/assets/worker.js`, + }); + }); + + const button = page.locator('#rejectionWorker'); + await button.click(); + + const errorEvent = await errorEventPromise; + + // Verify the unhandled rejection was captured + expect(errorEvent.exception?.values?.[0]?.value).toContain('Worker unhandled rejection'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.browser.web_worker.onunhandledrejection'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + expect(errorEvent.contexts?.worker).toBeDefined(); + expect(errorEvent.contexts?.worker?.filename).toContain('worker.js'); +}); diff --git a/dev-packages/browser-integration-tests/suites/manual-client/browser-context/test.ts b/dev-packages/browser-integration-tests/suites/manual-client/browser-context/test.ts index 4637fcc5555d..bb963a975049 100644 --- a/dev-packages/browser-integration-tests/suites/manual-client/browser-context/test.ts +++ b/dev-packages/browser-integration-tests/suites/manual-client/browser-context/test.ts @@ -46,7 +46,7 @@ sentryTest('allows to setup a client manually & capture exceptions', async ({ ge }, }, contexts: { - trace: { trace_id: expect.stringMatching(/[a-f0-9]{32}/), span_id: expect.stringMatching(/[a-f0-9]{16}/) }, + trace: { trace_id: expect.stringMatching(/[a-f\d]{32}/), span_id: expect.stringMatching(/[a-f\d]{16}/) }, }, }); }); diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index d473236cdfda..4d8caa3a2be3 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -6,6 +6,7 @@ import { shouldSkipTracingTest, waitForTransactionRequestOnUrl, } from '../../../utils/helpers'; +import { validateProfile } from '../test-utils'; sentryTest( 'does not send profile envelope when document-policy is not set', @@ -41,79 +42,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU const profile = profileEvent.profile; expect(profileEvent.profile).toBeDefined(); - expect(profile.samples).toBeDefined(); - expect(profile.stacks).toBeDefined(); - expect(profile.frames).toBeDefined(); - expect(profile.thread_metadata).toBeDefined(); - - // Samples - expect(profile.samples.length).toBeGreaterThanOrEqual(2); - for (const sample of profile.samples) { - expect(typeof sample.elapsed_since_start_ns).toBe('string'); - expect(sample.elapsed_since_start_ns).toMatch(/^\d+$/); // Numeric string - expect(parseInt(sample.elapsed_since_start_ns, 10)).toBeGreaterThanOrEqual(0); - - expect(typeof sample.stack_id).toBe('number'); - expect(sample.stack_id).toBeGreaterThanOrEqual(0); - expect(sample.thread_id).toBe('0'); // Should be main thread - } - - // Stacks - expect(profile.stacks.length).toBeGreaterThan(0); - for (const stack of profile.stacks) { - expect(Array.isArray(stack)).toBe(true); - for (const frameIndex of stack) { - expect(typeof frameIndex).toBe('number'); - expect(frameIndex).toBeGreaterThanOrEqual(0); - expect(frameIndex).toBeLessThan(profile.frames.length); - } - } - - // Frames - expect(profile.frames.length).toBeGreaterThan(0); - for (const frame of profile.frames) { - expect(frame).toHaveProperty('function'); - expect(typeof frame.function).toBe('string'); - - if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); - } - } - - const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // Function names are minified in minified bundles - expect(functionNames.length).toBeGreaterThan(0); - expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startProfileForSpan', - 'startJSSelfProfile', - ]), - ); - } - - expect(profile.thread_metadata).toHaveProperty('0'); - expect(profile.thread_metadata['0']).toHaveProperty('name'); - expect(profile.thread_metadata['0'].name).toBe('main'); - - // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTime = parseInt(profile.samples[0].elapsed_since_start_ns, 10); - const endTime = parseInt(profile.samples[profile.samples.length - 1].elapsed_since_start_ns, 10); - const durationNs = endTime - startTime; - const durationMs = durationNs / 1_000_000; // Convert ns to ms - - // Should be at least 20ms based on our setTimeout(21) in the test - expect(durationMs).toBeGreaterThan(20); + validateProfile(profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startProfileForSpan', + 'startJSSelfProfile', + ], + minSampleDurationMs: 20, + isChunkFormat: false, + }); }); diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js new file mode 100644 index 000000000000..906f14d06693 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js @@ -0,0 +1,76 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'manual', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +function fibonacci1(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function fibonacci2(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function notProfiledFib(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +// Adding setTimeout to ensure we cross the sampling interval to avoid flakes + +Sentry.uiProfiler.startProfiler(); + +fibonacci(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +largeSum(); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +// --- + +notProfiledFib(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +// --- + +Sentry.uiProfiler.startProfiler(); + +fibonacci2(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +const client = Sentry.getClient(); +await client?.flush(8000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts new file mode 100644 index 000000000000..2e4358563aa2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts @@ -0,0 +1,93 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Assert that no profile_chunk envelope is sent without policy header + const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 }); + expect(chunkCount).toBe(0); + }, +); + +sentryTest('sends profile_chunk envelopes in manual mode', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + // In manual mode we start and stop once -> expect exactly one chunk + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 8000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBe(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload1.profile).toBeDefined(); + + const profilerId1 = envelopeItemPayload1.profiler_id; + + validateProfilePayloadMetadata(envelopeItemPayload1); + + validateProfile(envelopeItemPayload1.profile, { + expectedFunctionNames: ['startJSSelfProfile', 'fibonacci', 'largeSum'], + minSampleDurationMs: 20, + isChunkFormat: true, + }); + + // only contains fibonacci + const functionNames1 = envelopeItemPayload1.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames1).toEqual(expect.not.arrayContaining(['fibonacci1', 'fibonacci2', 'fibonacci3'])); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + + expect(envelopeItemPayload2.profiler_id).toBe(profilerId1); // same profiler id for the whole session + + validateProfilePayloadMetadata(envelopeItemPayload2); + + validateProfile(envelopeItemPayload2.profile, { + expectedFunctionNames: [ + 'startJSSelfProfile', + 'fibonacci1', // called by fibonacci2 + 'fibonacci2', + ], + isChunkFormat: true, + }); + + // does not contain notProfiledFib (called during unprofiled part) + const functionNames2 = envelopeItemPayload2.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames2).toEqual(expect.not.arrayContaining(['notProfiledFib'])); +}); diff --git a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts new file mode 100644 index 000000000000..39e6d2ca20b7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts @@ -0,0 +1,151 @@ +import { expect } from '@playwright/test'; +import type { ContinuousThreadCpuProfile, ProfileChunk, ThreadCpuProfile } from '@sentry/core'; + +interface ValidateProfileOptions { + expectedFunctionNames?: string[]; + minSampleDurationMs?: number; + isChunkFormat?: boolean; +} + +/** + * Validates the metadata of a profile chunk envelope. + * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + */ +export function validateProfilePayloadMetadata(profileChunk: ProfileChunk): void { + expect(profileChunk.version).toBe('2'); + expect(profileChunk.platform).toBe('javascript'); + + expect(typeof profileChunk.profiler_id).toBe('string'); + expect(profileChunk.profiler_id).toMatch(/^[a-f\d]{32}$/); + + expect(typeof profileChunk.chunk_id).toBe('string'); + expect(profileChunk.chunk_id).toMatch(/^[a-f\d]{32}$/); + + expect(profileChunk.client_sdk).toBeDefined(); + expect(typeof profileChunk.client_sdk.name).toBe('string'); + expect(typeof profileChunk.client_sdk.version).toBe('string'); + + expect(typeof profileChunk.release).toBe('string'); + + expect(profileChunk.debug_meta).toBeDefined(); + expect(Array.isArray(profileChunk?.debug_meta?.images)).toBe(true); +} + +/** + * Validates the basic structure and content of a Sentry profile. + */ +export function validateProfile( + profile: ThreadCpuProfile | ContinuousThreadCpuProfile, + options: ValidateProfileOptions = {}, +): void { + const { expectedFunctionNames, minSampleDurationMs, isChunkFormat = false } = options; + + // Basic profile structure + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // SAMPLES + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp: number = Number.NEGATIVE_INFINITY; + + for (const sample of profile.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile.stacks.length); + + expect(sample.thread_id).toBe('0'); // Should be main thread + + // Timestamp validation - differs between chunk format (v2) and legacy format + if (isChunkFormat) { + const chunkProfileSample = sample as ContinuousThreadCpuProfile['samples'][number]; + + // Chunk format uses numeric timestamps (UNIX timestamp in seconds with microseconds precision) + expect(typeof chunkProfileSample.timestamp).toBe('number'); + const ts = chunkProfileSample.timestamp; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + } else { + // Legacy format uses elapsed_since_start_ns as a string + const legacyProfileSample = sample as ThreadCpuProfile['samples'][number]; + + expect(typeof legacyProfileSample.elapsed_since_start_ns).toBe('string'); + expect(legacyProfileSample.elapsed_since_start_ns).toMatch(/^\d+$/); // Numeric string + expect(parseInt(legacyProfileSample.elapsed_since_start_ns, 10)).toBeGreaterThanOrEqual(0); + } + } + + // STACKS + expect(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // FRAMES + expect(profile.frames.length).toBeGreaterThan(0); + for (const frame of profile.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + // Some browser functions (fetch, setTimeout) may not have file locations + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + // Function names validation (only when not minified and expected names provided) + if (expectedFunctionNames && expectedFunctionNames.length > 0) { + const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In minified bundles, just check that we have some non-empty function names + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); + } else { + // In non-minified bundles, check for expected function names + expect(functionNames).toEqual(expect.arrayContaining(expectedFunctionNames)); + } + } + + // THREAD METADATA + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // DURATION + if (minSampleDurationMs !== undefined) { + let durationMs: number; + + if (isChunkFormat) { + // Chunk format: timestamps are in seconds + const chunkProfile = profile as ContinuousThreadCpuProfile; + + const startTimeSec = chunkProfile.samples[0].timestamp; + const endTimeSec = chunkProfile.samples[chunkProfile.samples.length - 1].timestamp; + durationMs = (endTimeSec - startTimeSec) * 1000; // Convert to ms + } else { + // Legacy format: elapsed_since_start_ns is in nanoseconds + const legacyProfile = profile as ThreadCpuProfile; + + const startTimeNs = parseInt(legacyProfile.samples[0].elapsed_since_start_ns, 10); + const endTimeNs = parseInt(legacyProfile.samples[legacyProfile.samples.length - 1].elapsed_since_start_ns, 10); + durationMs = (endTimeNs - startTimeNs) / 1_000_000; // Convert ns to ms + } + + expect(durationMs).toBeGreaterThan(minSampleDurationMs); + } +} diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index 702140b8823e..5afc23a3a75f 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -7,6 +7,7 @@ import { properFullEnvelopeRequestParser, shouldSkipTracingTest, } from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; sentryTest( 'does not send profile envelope when document-policy is not set', @@ -51,109 +52,24 @@ sentryTest( const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); - expect(envelopeItemPayload1.profile).toBeDefined(); - expect(envelopeItemPayload1.version).toBe('2'); - expect(envelopeItemPayload1.platform).toBe('javascript'); - - // Required profile metadata (Sample Format V2) - expect(typeof envelopeItemPayload1.profiler_id).toBe('string'); - expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f0-9]{32}$/); - expect(typeof envelopeItemPayload1.chunk_id).toBe('string'); - expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f0-9]{32}$/); - expect(envelopeItemPayload1.client_sdk).toBeDefined(); - expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string'); - expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string'); - expect(typeof envelopeItemPayload1.release).toBe('string'); - expect(envelopeItemPayload1.debug_meta).toBeDefined(); - expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true); - - const profile1 = envelopeItemPayload1.profile; - - expect(profile1.samples).toBeDefined(); - expect(profile1.stacks).toBeDefined(); - expect(profile1.frames).toBeDefined(); - expect(profile1.thread_metadata).toBeDefined(); - - // Samples - expect(profile1.samples.length).toBeGreaterThanOrEqual(2); - let previousTimestamp = Number.NEGATIVE_INFINITY; - for (const sample of profile1.samples) { - expect(typeof sample.stack_id).toBe('number'); - expect(sample.stack_id).toBeGreaterThanOrEqual(0); - expect(sample.stack_id).toBeLessThan(profile1.stacks.length); - - // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) - expect(typeof (sample as any).timestamp).toBe('number'); - const ts = (sample as any).timestamp as number; - expect(Number.isFinite(ts)).toBe(true); - expect(ts).toBeGreaterThan(0); - // Monotonic non-decreasing timestamps - expect(ts).toBeGreaterThanOrEqual(previousTimestamp); - previousTimestamp = ts; - - expect(sample.thread_id).toBe('0'); // Should be main thread - } - - // Stacks - expect(profile1.stacks.length).toBeGreaterThan(0); - for (const stack of profile1.stacks) { - expect(Array.isArray(stack)).toBe(true); - for (const frameIndex of stack) { - expect(typeof frameIndex).toBe('number'); - expect(frameIndex).toBeGreaterThanOrEqual(0); - expect(frameIndex).toBeLessThan(profile1.frames.length); - } - } - - // Frames - expect(profile1.frames.length).toBeGreaterThan(0); - for (const frame of profile1.frames) { - expect(frame).toHaveProperty('function'); - expect(typeof frame.function).toBe('string'); - - if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); - } - } - const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // In bundled mode, function names are minified - expect(functionNames.length).toBeGreaterThan(0); - expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startJSSelfProfile', - - // first function is captured (other one is in other chunk) - 'fibonacci', - ]), - ); - } - - expect(profile1.thread_metadata).toHaveProperty('0'); - expect(profile1.thread_metadata['0']).toHaveProperty('name'); - expect(profile1.thread_metadata['0'].name).toBe('main'); - - // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTimeSec = (profile1.samples[0] as any).timestamp as number; - const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; - const durationSec = endTimeSec - startTimeSec; - - // Should be at least 20ms based on our setTimeout(21) in the test - expect(durationSec).toBeGreaterThan(0.2); + validateProfilePayloadMetadata(envelopeItemPayload1); + + validateProfile(envelopeItemPayload1.profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + // first function is captured (other one is in other chunk) + 'fibonacci', + ], + // Should be at least 20ms based on our setTimeout(21) in the test + minSampleDurationMs: 20, + isChunkFormat: true, + }); // === PROFILE CHUNK 2 === @@ -161,46 +77,22 @@ sentryTest( const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; - // Basic sanity on the second chunk: has correct envelope type and structure expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); expect(envelopeItemPayload2.profile).toBeDefined(); - expect(envelopeItemPayload2.version).toBe('2'); - expect(envelopeItemPayload2.platform).toBe('javascript'); - - // Required profile metadata (Sample Format V2) - // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ - expect(typeof envelopeItemPayload2.profiler_id).toBe('string'); - expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f0-9]{32}$/); - expect(typeof envelopeItemPayload2.chunk_id).toBe('string'); - expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f0-9]{32}$/); - expect(envelopeItemPayload2.client_sdk).toBeDefined(); - expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string'); - expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string'); - expect(typeof envelopeItemPayload2.release).toBe('string'); - expect(envelopeItemPayload2.debug_meta).toBeDefined(); - expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true); - - const profile2 = envelopeItemPayload2.profile; - - const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // In bundled mode, function names are minified - expect(functionNames2.length).toBeGreaterThan(0); - expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames2).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startJSSelfProfile', - - // second function is captured (other one is in other chunk) - 'largeSum', - ]), - ); - } + + validateProfilePayloadMetadata(envelopeItemPayload2); + + validateProfile(envelopeItemPayload2.profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + // second function is captured (other one is in other chunk) + 'largeSum', + ], + isChunkFormat: true, + }); }, ); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index 60744def96cd..fa66a225b49b 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -8,6 +8,7 @@ import { shouldSkipTracingTest, waitForTransactionRequestOnUrl, } from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; sentryTest( 'does not send profile envelope when document-policy is not set', @@ -52,111 +53,25 @@ sentryTest( const envelopeItemPayload = profileChunkEnvelopeItem[1]; expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); - expect(envelopeItemPayload.profile).toBeDefined(); - expect(envelopeItemPayload.version).toBe('2'); - expect(envelopeItemPayload.platform).toBe('javascript'); - - // Required profile metadata (Sample Format V2) - // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ - expect(typeof envelopeItemPayload.profiler_id).toBe('string'); - expect(envelopeItemPayload.profiler_id).toMatch(/^[a-f0-9]{32}$/); - expect(typeof envelopeItemPayload.chunk_id).toBe('string'); - expect(envelopeItemPayload.chunk_id).toMatch(/^[a-f0-9]{32}$/); - expect(envelopeItemPayload.client_sdk).toBeDefined(); - expect(typeof envelopeItemPayload.client_sdk.name).toBe('string'); - expect(typeof envelopeItemPayload.client_sdk.version).toBe('string'); - expect(typeof envelopeItemPayload.release).toBe('string'); - expect(envelopeItemPayload.debug_meta).toBeDefined(); - expect(Array.isArray(envelopeItemPayload?.debug_meta?.images)).toBe(true); - - const profile = envelopeItemPayload.profile; - - expect(profile.samples).toBeDefined(); - expect(profile.stacks).toBeDefined(); - expect(profile.frames).toBeDefined(); - expect(profile.thread_metadata).toBeDefined(); - - // Samples - expect(profile.samples.length).toBeGreaterThanOrEqual(2); - let previousTimestamp = Number.NEGATIVE_INFINITY; - for (const sample of profile.samples) { - expect(typeof sample.stack_id).toBe('number'); - expect(sample.stack_id).toBeGreaterThanOrEqual(0); - expect(sample.stack_id).toBeLessThan(profile.stacks.length); - - // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) - expect(typeof sample.timestamp).toBe('number'); - const ts = sample.timestamp; - expect(Number.isFinite(ts)).toBe(true); - expect(ts).toBeGreaterThan(0); - // Monotonic non-decreasing timestamps - expect(ts).toBeGreaterThanOrEqual(previousTimestamp); - previousTimestamp = ts; - - expect(sample.thread_id).toBe('0'); // Should be main thread - } - - // Stacks - expect(profile.stacks.length).toBeGreaterThan(0); - for (const stack of profile.stacks) { - expect(Array.isArray(stack)).toBe(true); - for (const frameIndex of stack) { - expect(typeof frameIndex).toBe('number'); - expect(frameIndex).toBeGreaterThanOrEqual(0); - expect(frameIndex).toBeLessThan(profile.frames.length); - } - } - - // Frames - expect(profile.frames.length).toBeGreaterThan(0); - for (const frame of profile.frames) { - expect(frame).toHaveProperty('function'); - expect(typeof frame.function).toBe('string'); - - if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); - } - } - - const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); - - if ((process.env.PW_BUNDLE || '').endsWith('min')) { - // In bundled mode, function names are minified - expect(functionNames.length).toBeGreaterThan(0); - expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings - } else { - expect(functionNames).toEqual( - expect.arrayContaining([ - '_startRootSpan', - 'withScope', - 'createChildOrRootSpan', - 'startSpanManual', - 'startJSSelfProfile', - - // both functions are captured - 'fibonacci', - 'largeSum', - ]), - ); - } - - expect(profile.thread_metadata).toHaveProperty('0'); - expect(profile.thread_metadata['0']).toHaveProperty('name'); - expect(profile.thread_metadata['0'].name).toBe('main'); - - // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTimeSec = (profile.samples[0] as any).timestamp as number; - const endTimeSec = (profile.samples[profile.samples.length - 1] as any).timestamp as number; - const durationSec = endTimeSec - startTimeSec; - // Should be at least 20ms based on our setTimeout(21) in the test - expect(durationSec).toBeGreaterThan(0.2); + validateProfilePayloadMetadata(envelopeItemPayload); + + validateProfile(envelopeItemPayload.profile, { + expectedFunctionNames: [ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + // both functions are captured + 'fibonacci', + 'largeSum', + ], + // Test that profile duration makes sense (should be > 20ms based on test setup + minSampleDurationMs: 20, + isChunkFormat: true, + }); }, ); @@ -175,7 +90,7 @@ sentryTest('attaches thread data to child spans (trace mode)', async ({ page, ge const profilerId = rootSpan?.contexts?.profile?.profiler_id as string | undefined; expect(typeof profilerId).toBe('string'); - expect(profilerId).toMatch(/^[a-f0-9]{32}$/); + expect(profilerId).toMatch(/^[a-f\d]{32}$/); const spans = (rootSpan?.spans ?? []) as Array<{ data?: Record }>; expect(spans.length).toBeGreaterThan(0); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js index 8026df91ea46..1fa010f49659 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js @@ -4,5 +4,8 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - enableLogs: true, + // purposefully testing against the experimental flag here + _experiments: { + enableLogs: true, + }, }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js index 809b78739e77..e26b03d7fc61 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js @@ -5,5 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', enableLogs: true, + // Purposefully specifying the experimental flag here + // to ensure the top level option is used instead. + _experiments: { + enableLogs: false, + }, integrations: [Sentry.consoleLoggingIntegration()], }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/init.js new file mode 100644 index 000000000000..5590fbb90547 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/init.js @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + integrations: integrations => { + return integrations.filter(integration => integration.name !== 'BrowserSession'); + }, + beforeSendMetric: metric => { + if (metric.name === 'test.counter') { + return { + ...metric, + attributes: { + ...metric.attributes, + modified: 'by-beforeSendMetric', + original: undefined, + }, + }; + } + return metric; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/subject.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/subject.js new file mode 100644 index 000000000000..e7b9940c7f6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/subject.js @@ -0,0 +1,14 @@ +// Store captured metrics from the afterCaptureMetric event +window.capturedMetrics = []; + +const client = Sentry.getClient(); + +client.on('afterCaptureMetric', metric => { + window.capturedMetrics.push(metric); +}); + +// Capture metrics - these should be processed by beforeSendMetric +Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test', original: 'value' } }); +Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } }); + +Sentry.flush(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts new file mode 100644 index 000000000000..a89bdea81902 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts @@ -0,0 +1,59 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; + +sentryTest( + 'should emit afterCaptureMetric event with processed metric from beforeSendMetric', + async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE || ''; + if (bundle.startsWith('bundle') || bundle.startsWith('loader')) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + await page.waitForFunction(() => { + return (window as any).capturedMetrics.length >= 2; + }); + + const capturedMetrics = await page.evaluate(() => { + return (window as any).capturedMetrics; + }); + + expect(capturedMetrics).toHaveLength(2); + + // Verify the counter metric was modified by beforeSendMetric + expect(capturedMetrics[0]).toMatchObject({ + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: '/api/test', + modified: 'by-beforeSendMetric', + 'sentry.release': '1.0.0', + 'sentry.environment': 'test', + 'sentry.sdk.name': 'sentry.javascript.browser', + }, + }); + + // Verify the 'original' attribute was removed by beforeSendMetric + expect(capturedMetrics[0].attributes.original).toBeUndefined(); + + // Verify the gauge metric was not modified (no beforeSendMetric processing) + expect(capturedMetrics[1]).toMatchObject({ + name: 'test.gauge', + type: 'gauge', + unit: 'millisecond', + value: 42, + attributes: { + server: 'test-1', + 'sentry.release': '1.0.0', + 'sentry.environment': 'test', + 'sentry.sdk.name': 'sentry.javascript.browser', + }, + }); + + expect(capturedMetrics[0].attributes['sentry.sdk.version']).toBeDefined(); + expect(capturedMetrics[1].attributes['sentry.sdk.version']).toBeDefined(); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js index df4fda70e4c7..73c6e63ed335 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js @@ -4,9 +4,6 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - _experiments: { - enableMetrics: true, - }, release: '1.0.0', environment: 'test', integrations: integrations => { diff --git a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts index 181711650074..465c2f684de0 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_non_primitives/test.ts @@ -3,11 +3,24 @@ import type { Event } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; -sentryTest('should not accept non-primitive tags', async ({ getLocalTestUrl, page }) => { +sentryTest('accepts and sends non-primitive tags', async ({ getLocalTestUrl, page }) => { + // Technically, accepting and sending non-primitive tags is a specification violation. + // This slipped through because a previous version of this test should have ensured that + // we don't accept non-primitive tags. However, the test was flawed. + // Turns out, Relay and our product handle invalid tag values gracefully. + // Our type definitions for setTag(s) also only allow primitive values. + // Therefore (to save some bundle size), we'll continue accepting and sending non-primitive + // tag values for now (but not adjust types). + // This test documents this decision, so that we know why we're accepting non-primitive tags. const url = await getLocalTestUrl({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); expect(eventData.message).toBe('non_primitives'); - expect(eventData.tags).toMatchObject({}); + + expect(eventData.tags).toEqual({ + tag_1: {}, + tag_2: [], + tag_3: ['a', {}], + }); }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts index 47116b6554bb..2b4922e4a86e 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/setTag/with_primitives/test.ts @@ -9,7 +9,7 @@ sentryTest('should set primitive tags', async ({ getLocalTestUrl, page }) => { const eventData = await getFirstSentryEnvelopeRequest(page, url); expect(eventData.message).toBe('primitive_tags'); - expect(eventData.tags).toMatchObject({ + expect(eventData.tags).toEqual({ tag_1: 'foo', tag_2: 3.141592653589793, tag_3: false, diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/test.ts index 8493f4e5fd97..c1e641204b81 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone-mixed-transaction/test.ts @@ -34,8 +34,8 @@ sentryTest( const traceId = transactionEnvelopeHeader.trace!.trace_id!; const parentSpanId = transactionEnvelopeItem.contexts?.trace?.span_id; - expect(traceId).toMatch(/[a-f0-9]{32}/); - expect(parentSpanId).toMatch(/[a-f0-9]{16}/); + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(parentSpanId).toMatch(/[a-f\d]{16}/); expect(spanEnvelopeHeader).toEqual({ sent_at: expect.any(String), @@ -76,7 +76,7 @@ sentryTest( segment_id: transactionEnvelopeItem.contexts?.trace?.span_id, parent_span_id: parentSpanId, origin: 'manual', - span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: traceId, @@ -111,7 +111,7 @@ sentryTest( description: 'inner', origin: 'manual', parent_span_id: parentSpanId, - span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: traceId, diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/test.ts index aaafd99c91d5..289e907e09b3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/standalone/test.ts @@ -48,10 +48,10 @@ sentryTest('sends a segment span envelope', async ({ getLocalTestUrl, page }) => }, description: 'standalone_segment_span', origin: 'manual', - span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), - trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), is_segment: true, segment_id: spanJson.span_id, }); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js new file mode 100644 index 000000000000..f9dccbffb530 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + stickySession: true, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js new file mode 100644 index 000000000000..1c9b22455261 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js @@ -0,0 +1,11 @@ +document.getElementById('error1').addEventListener('click', () => { + throw new Error('First Error'); +}); + +document.getElementById('error2').addEventListener('click', () => { + throw new Error('Second Error'); +}); + +document.getElementById('click').addEventListener('click', () => { + // Just a click for interaction +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html new file mode 100644 index 000000000000..1beb4b281b28 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts new file mode 100644 index 000000000000..11154caaaa8b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -0,0 +1,270 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { + getReplaySnapshot, + isReplayEvent, + shouldSkipReplayTest, + waitForReplayRunning, +} from '../../../utils/replayHelpers'; + +sentryTest( + 'buffer mode remains after interrupting error event ingest', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + + if (errorCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + waitForErrorRequest(page); + await page.locator('#error1').click(); + + // This resolves, but the route doesn't get fulfilled as we want the reload to "interrupt" this flow + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(0); + expect(replayIds).toHaveLength(1); + + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.recordingMode).toBe('buffer'); + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); + +sentryTest('buffer mode remains after interrupting replay flush', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + await page.reload(); + await waitForReplayRunning(page); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(1); + // Because a flush attempt was made and not allowed to complete, segmentId increased from 0, + // so we resume in session mode + expect(secondSession.recordingMode).toBe('session'); +}); + +sentryTest( + 'starts a new session after interrupting replay flush and session "expires"', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + // Trigger first error - this should change session sampled to "session" + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + // Now expire the session by manipulating session storage + // Simulate session expiry by setting lastActivity to a time in the past + await page.evaluate(() => { + const replayIntegration = (window as any).Replay; + const replay = replayIntegration['_replay']; + + // Set session as expired (15 minutes ago) + if (replay.session) { + const fifteenMinutesAgo = Date.now() - 15 * 60 * 1000; + replay.session.lastActivity = fifteenMinutesAgo; + replay.session.started = fifteenMinutesAgo; + + // Also update session storage if sticky sessions are enabled + const sessionKey = 'sentryReplaySession'; + const sessionData = sessionStorage.getItem(sessionKey); + if (sessionData) { + const session = JSON.parse(sessionData); + session.lastActivity = fifteenMinutesAgo; + session.started = fifteenMinutesAgo; + sessionStorage.setItem(sessionKey, JSON.stringify(session)); + } + } + }); + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.recordingMode).toBe('buffer'); + expect(secondSession.session?.id).not.toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts b/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts index ef0882e0206b..e63c45e42293 100644 --- a/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts @@ -57,7 +57,7 @@ sentryTest( expect(envHeader.trace).toEqual({ environment: 'production', sample_rate: '1', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', replay_id: replay.session?.id, sampled: 'true', @@ -105,7 +105,7 @@ sentryTest( expect(envHeader.trace).toEqual({ environment: 'production', sample_rate: '1', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', sampled: 'true', sample_rand: expect.any(String), @@ -158,7 +158,7 @@ sentryTest( expect(envHeader.trace).toEqual({ environment: 'production', sample_rate: '1', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', replay_id: replay.session?.id, sampled: 'true', @@ -201,7 +201,7 @@ sentryTest( expect(envHeader.trace).toEqual({ environment: 'production', sample_rate: '1', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', sampled: 'true', sample_rand: expect.any(String), @@ -243,7 +243,7 @@ sentryTest('should add replay_id to error DSC while replay is active', async ({ expect(error1Header.trace).toBeDefined(); expect(error1Header.trace).toEqual({ environment: 'production', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', replay_id: replay.session?.id, ...(hasTracing @@ -265,7 +265,7 @@ sentryTest('should add replay_id to error DSC while replay is active', async ({ expect(error2Header.trace).toBeDefined(); expect(error2Header.trace).toEqual({ environment: 'production', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', ...(hasTracing ? { diff --git a/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/init.js b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/init.js new file mode 100644 index 000000000000..58873b85f9ec --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + // Try to set to 60s - should be capped at 50s + minReplayDuration: 60000, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/template.html b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/template.html new file mode 100644 index 000000000000..06c44ed4bc9c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/template.html @@ -0,0 +1,12 @@ + + + + + Replay - minReplayDuration Limit + + +
+

Testing that minReplayDuration is capped at 50s max

+
+ + diff --git a/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/test.ts b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/test.ts new file mode 100644 index 000000000000..125af55a6985 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/minReplayDurationLimit/test.ts @@ -0,0 +1,23 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipReplayTest } from '../../../utils/replayHelpers'; + +sentryTest('caps minReplayDuration to maximum of 50 seconds', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const actualMinReplayDuration = await page.evaluate(() => { + // @ts-expect-error - Replay is not typed on window + const replayIntegration = window.Replay; + const replay = replayIntegration._replay; + return replay.getOptions().minReplayDuration; + }); + + // Even though we configured it to 60s (60000ms), it should be capped to 50s + expect(actualMinReplayDuration).toBe(50_000); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/mocks.js new file mode 100644 index 000000000000..e7d4dbf00961 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/mocks.js @@ -0,0 +1,76 @@ +// Mock LangChain Chat Model for browser testing +export class MockChatAnthropic { + constructor(params) { + this._model = params.model; + this._temperature = params.temperature; + this._maxTokens = params.maxTokens; + } + + async invoke(messages, config = { callbacks: [] }) { + const callbacks = config.callbacks; + const runId = 'mock-run-id-123'; + + const invocationParams = { + model: this._model, + temperature: this._temperature, + max_tokens: this._maxTokens, + }; + + const serialized = { + lc: 1, + type: 'constructor', + id: ['langchain', 'anthropic', 'anthropic'], + kwargs: invocationParams, + }; + + // Call handleChatModelStart + for (const callback of callbacks) { + if (callback.handleChatModelStart) { + await callback.handleChatModelStart( + serialized, + messages, + runId, + undefined, + undefined, + { invocation_params: invocationParams }, + { ls_model_name: this._model, ls_provider: 'anthropic' }, + ); + } + } + + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // Create mock result + const result = { + generations: [ + [ + { + text: 'Mock response from Anthropic!', + generationInfo: { + finish_reason: 'stop', + }, + }, + ], + ], + llmOutput: { + tokenUsage: { + promptTokens: 10, + completionTokens: 15, + totalTokens: 25, + }, + model_name: this._model, + id: 'msg_mock123', + }, + }; + + // Call handleLLMEnd + for (const callback of callbacks) { + if (callback.handleLLMEnd) { + await callback.handleLLMEnd(result, runId); + } + } + + return result; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/subject.js new file mode 100644 index 000000000000..3df04acd505b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/subject.js @@ -0,0 +1,22 @@ +import { createLangChainCallbackHandler } from '@sentry/browser'; +import { MockChatAnthropic } from './mocks.js'; + +const callbackHandler = createLangChainCallbackHandler({ + recordInputs: false, + recordOutputs: false, +}); + +const chatModel = new MockChatAnthropic({ + model: 'claude-3-haiku-20240307', + temperature: 0.7, + maxTokens: 100, +}); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +// We can provide callbacks in the config object:https://docs.langchain.com/oss/python/langchain/models#invocation-config +const response = await chatModel.invoke('What is the capital of France?', { + callbacks: [callbackHandler], +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/test.ts new file mode 100644 index 000000000000..9cc1cc9ff98b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/test.ts @@ -0,0 +1,37 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForTransactionRequest } from '../../../../utils/helpers'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not crash in the browser +// and that gen_ai transactions are sent. + +sentryTest('manual LangChain instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('claude-3-haiku-20240307'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const req = await transactionPromise; + + const eventData = envelopeRequestParser(req); + + // Verify it's a gen_ai transaction + expect(eventData.transaction).toBe('chat claude-3-haiku-20240307'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.chat'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.langchain'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_mock123', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/mocks.js new file mode 100644 index 000000000000..54792b827a43 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/mocks.js @@ -0,0 +1,29 @@ +// Mock LangGraph graph for browser testing +export class MockStateGraph { + compile(options = {}) { + const compiledGraph = { + name: options.name, + graph_name: options.name, + lc_kwargs: { + name: options.name, + }, + builder: { + nodes: {}, + }, + invoke: async input => { + const messages = input?.messages; + return { + messages: [ + ...messages, + { + role: 'assistant', + content: 'Mock response from LangGraph', + }, + ], + }; + }, + }; + + return compiledGraph; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/subject.js new file mode 100644 index 000000000000..70741f5d111f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/subject.js @@ -0,0 +1,16 @@ +import { MockStateGraph } from './mocks.js'; +import { instrumentLangGraph } from '@sentry/browser'; + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +// Test both agent creation and invocation + +const graph = new MockStateGraph(); +instrumentLangGraph(graph, { recordInputs: false, recordOutputs: false }); +const compiledGraph = graph.compile({ name: 'mock-graph' }); + +const response = await compiledGraph.invoke({ + messages: [{ role: 'user', content: 'What is the capital of France?' }], +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/test.ts new file mode 100644 index 000000000000..1feabd48c8d2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/test.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForTransactionRequest } from '../../../../utils/helpers'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not crash in the browser +// and that gen_ai transactions are sent. + +sentryTest('manual LangGraph instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const createTransactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('create_agent mock-graph'); + }); + + const invokeTransactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('invoke_agent mock-graph'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const createReq = await createTransactionPromise; + const invokeReq = await invokeTransactionPromise; + + const createEventData = envelopeRequestParser(createReq); + const invokeEventData = envelopeRequestParser(invokeReq); + + // Verify create_agent transaction + expect(createEventData.transaction).toBe('create_agent mock-graph'); + expect(createEventData.contexts?.trace?.op).toBe('gen_ai.create_agent'); + expect(createEventData.contexts?.trace?.origin).toBe('auto.ai.langgraph'); + expect(createEventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'create_agent', + 'gen_ai.agent.name': 'mock-graph', + }); + + // Verify invoke_agent transaction + expect(invokeEventData.transaction).toBe('invoke_agent mock-graph'); + expect(invokeEventData.contexts?.trace?.op).toBe('gen_ai.invoke_agent'); + expect(invokeEventData.contexts?.trace?.origin).toBe('auto.ai.langgraph'); + expect(invokeEventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'invoke_agent', + 'gen_ai.agent.name': 'mock-graph', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts index 633be5f570b5..6894d7407349 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/test.ts @@ -34,7 +34,7 @@ sentryTest('creates fetch spans with http timing', async ({ browserName, getLoca expect(span).toMatchObject({ description: `GET http://sentry-test-site.example/${index}`, parent_span_id: tracingEvent.contexts?.trace?.span_id, - span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: tracingEvent.contexts?.trace?.trace_id, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts index 6ec7985b9dad..c8faee2f5feb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts @@ -60,7 +60,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains expect(extractTraceparentData(sentryTrace)).toEqual({ traceId: expect.not.stringContaining(metaTagTraceId), - parentSpanId: expect.stringMatching(/^[0-9a-f]{16}$/), + parentSpanId: expect.stringMatching(/^[\da-f]{16}$/), parentSampled: false, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts index ece2b1f85790..3dab9594ba7c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts @@ -48,7 +48,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains expect(extractTraceparentData(sentryTrace)).toEqual({ traceId: expect.not.stringContaining(metaTagTraceIdIndex), - parentSpanId: expect.stringMatching(/^[0-9a-f]{16}$/), + parentSpanId: expect.stringMatching(/^[\da-f]{16}$/), parentSampled: false, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts index 9e896798be90..2bb196c898fd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts @@ -104,8 +104,8 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { { attributes: { 'sentry.link.type': 'previous_trace' }, sampled: false, - span_id: expect.stringMatching(/^[0-9a-f]{16}$/), - trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts index 7a8b69fdb364..b84aefb8887d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts @@ -24,8 +24,8 @@ sentryTest('includes a span link to a previously negatively sampled span', async expect(navigationTraceContext?.op).toBe('navigation'); expect(navigationTraceContext?.links).toEqual([ { - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), sampled: false, attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', @@ -34,7 +34,7 @@ sentryTest('includes a span link to a previously negatively sampled span', async ]); expect(navigationTraceContext?.data).toMatchObject({ - 'sentry.previous_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-0/), + 'sentry.previous_trace': expect.stringMatching(/[a-f\d]{32}-[a-f\d]{16}-0/), }); expect(navigationTraceContext?.trace_id).not.toEqual(navigationTraceContext?.links![0].trace_id); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors-meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors-meta/test.ts index 5bed055dbc0a..ca716b2e2648 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors-meta/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors-meta/test.ts @@ -24,12 +24,12 @@ sentryTest('errors in TwP mode have same trace ID & span IDs', async ({ getLocal // Span ID is a virtual span, not the propagated one expect(spanId1).not.toEqual(spanId); - expect(spanId1).toMatch(/^[a-f0-9]{16}$/); + expect(spanId1).toMatch(/^[a-f\d]{16}$/); const contexts2 = event2.contexts; const { trace_id: traceId2, span_id: spanId2 } = contexts2?.trace || {}; expect(traceId2).toEqual(traceId); - expect(spanId2).toMatch(/^[a-f0-9]{16}$/); + expect(spanId2).toMatch(/^[a-f\d]{16}$/); expect(spanId2).toEqual(spanId1); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors/test.ts index 3048de92b2f1..fa579509ba87 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/twp-errors/test.ts @@ -17,13 +17,13 @@ sentryTest('errors in TwP mode have same trace ID & span IDs', async ({ getLocal const contexts1 = event1.contexts; const { trace_id: traceId1, span_id: spanId1 } = contexts1?.trace || {}; - expect(traceId1).toMatch(/^[a-f0-9]{32}$/); - expect(spanId1).toMatch(/^[a-f0-9]{16}$/); + expect(traceId1).toMatch(/^[a-f\d]{32}$/); + expect(spanId1).toMatch(/^[a-f\d]{16}$/); const contexts2 = event2.contexts; const { trace_id: traceId2, span_id: spanId2 } = contexts2?.trace || {}; - expect(traceId2).toMatch(/^[a-f0-9]{32}$/); - expect(spanId2).toMatch(/^[a-f0-9]{16}$/); + expect(traceId2).toMatch(/^[a-f\d]{32}$/); + expect(spanId2).toMatch(/^[a-f\d]{16}$/); expect(traceId2).toEqual(traceId1); expect(spanId2).toEqual(spanId1); diff --git a/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts b/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts index 0136b1043617..7d33497a988b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts @@ -48,7 +48,7 @@ sentryTest('updates the DSC when the txn name is updated and high-quality', asyn return (window as any).__traceId; }); - expect(traceId).toMatch(/^[0-9a-f]{32}$/); + expect(traceId).toMatch(/^[\da-f]{32}$/); // 2 const baggageItems = await makeRequestAndGetBaggageItems(page); @@ -56,7 +56,7 @@ sentryTest('updates the DSC when the txn name is updated and high-quality', asyn 'sentry-environment=production', 'sentry-public_key=public', 'sentry-release=1.1.1', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + expect.stringMatching(/sentry-sample_rand=0\.\d+/), 'sentry-sample_rate=1', 'sentry-sampled=true', `sentry-trace_id=${traceId}`, @@ -83,7 +83,7 @@ sentryTest('updates the DSC when the txn name is updated and high-quality', asyn 'sentry-environment=production', 'sentry-public_key=public', 'sentry-release=1.1.1', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + expect.stringMatching(/sentry-sample_rand=0\.\d+/), 'sentry-sample_rate=1', 'sentry-sampled=true', `sentry-trace_id=${traceId}`, @@ -112,7 +112,7 @@ sentryTest('updates the DSC when the txn name is updated and high-quality', asyn 'sentry-environment=production', 'sentry-public_key=public', 'sentry-release=1.1.1', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + expect.stringMatching(/sentry-sample_rand=0\.\d+/), 'sentry-sample_rate=1', 'sentry-sampled=true', `sentry-trace_id=${traceId}`, diff --git a/dev-packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/test.ts b/dev-packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/test.ts index 88a268e69ea2..23043ebc770d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/envelope-header-transaction-name/test.ts @@ -23,7 +23,7 @@ sentryTest( environment: 'production', sample_rate: '1', transaction: expect.stringContaining('/index.html'), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', sampled: 'true', sample_rand: expect.any(String), diff --git a/dev-packages/browser-integration-tests/suites/tracing/envelope-header/test.ts b/dev-packages/browser-integration-tests/suites/tracing/envelope-header/test.ts index 9274cd0bb8ba..7617b498efb2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/envelope-header/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/envelope-header/test.ts @@ -26,7 +26,7 @@ sentryTest( expect(envHeader.trace).toEqual({ environment: 'production', sample_rate: '1', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', sampled: 'true', sample_rand: expect.any(String), diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts index f748c339ce14..91c8ec9ff216 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-resource-spans/test.ts @@ -104,7 +104,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU op: 'resource.img', origin: 'auto.resource.browser.metrics', parent_span_id: spanId, - span_id: expect.stringMatching(/^[a-f0-9]{16}$/), + span_id: expect.stringMatching(/^[a-f\d]{16}$/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: traceId, @@ -151,7 +151,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU op: 'resource.link', origin: 'auto.resource.browser.metrics', parent_span_id: spanId, - span_id: expect.stringMatching(/^[a-f0-9]{16}$/), + span_id: expect.stringMatching(/^[a-f\d]{16}$/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: traceId, @@ -192,7 +192,7 @@ sentryTest('adds resource spans to pageload transaction', async ({ getLocalTestU op: 'resource.script', origin: 'auto.resource.browser.metrics', parent_span_id: spanId, - span_id: expect.stringMatching(/^[a-f0-9]{16}$/), + span_id: expect.stringMatching(/^[a-f\d]{16}$/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: traceId, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts index 24c949c63afa..fd4b3b8fa06b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts @@ -68,7 +68,7 @@ sentryTest('captures a "GOOD" CLS vital with its source as a standalone span', a 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), - 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/), 'cls.source.1': expect.stringContaining('body > div#content > p'), }, description: expect.stringContaining('body > div#content > p'), @@ -81,12 +81,12 @@ sentryTest('captures a "GOOD" CLS vital with its source as a standalone span', a }, op: 'ui.webvital.cls', origin: 'auto.http.browser.cls', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - segment_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: expect.stringMatching(/[a-f\d]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + segment_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: spanEnvelopeItem.start_timestamp, - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), }); // Flakey value dependent on timings -> we check for a range @@ -138,7 +138,7 @@ sentryTest('captures a "MEH" CLS vital with its source as a standalone span', as 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), - 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/), 'cls.source.1': expect.stringContaining('body > div#content > p'), }, description: expect.stringContaining('body > div#content > p'), @@ -151,12 +151,12 @@ sentryTest('captures a "MEH" CLS vital with its source as a standalone span', as }, op: 'ui.webvital.cls', origin: 'auto.http.browser.cls', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - segment_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: expect.stringMatching(/[a-f\d]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + segment_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: spanEnvelopeItem.start_timestamp, - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), }); // Flakey value dependent on timings -> we check for a range @@ -206,7 +206,7 @@ sentryTest('captures a "POOR" CLS vital with its source as a standalone span.', 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), - 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/), 'cls.source.1': expect.stringContaining('body > div#content > p'), }, description: expect.stringContaining('body > div#content > p'), @@ -219,12 +219,12 @@ sentryTest('captures a "POOR" CLS vital with its source as a standalone span.', }, op: 'ui.webvital.cls', origin: 'auto.http.browser.cls', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - segment_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: expect.stringMatching(/[a-f\d]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + segment_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: spanEnvelopeItem.start_timestamp, - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), }); // Flakey value dependent on timings -> we check for a range @@ -275,7 +275,7 @@ sentryTest( 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), - 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f\d]{16}/), }, description: 'Layout shift', exclusive_time: 0, @@ -287,12 +287,12 @@ sentryTest( }, op: 'ui.webvital.cls', origin: 'auto.http.browser.cls', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - segment_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: expect.stringMatching(/[a-f\d]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + segment_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: spanEnvelopeItem.start_timestamp, - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), }); expect(spanEnvelopeHeaders).toEqual({ @@ -323,8 +323,8 @@ sentryTest( const pageloadSpanId = eventData.contexts?.trace?.span_id; const pageloadTraceId = eventData.contexts?.trace?.trace_id; - expect(pageloadSpanId).toMatch(/[a-f0-9]{16}/); - expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/); + expect(pageloadSpanId).toMatch(/[a-f\d]{16}/); + expect(pageloadTraceId).toMatch(/[a-f\d]{32}/); const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( page, @@ -371,7 +371,7 @@ sentryTest('sends CLS of the initial page when soft-navigating to a new page', a await page.goto(`${url}#soft-navigation`); const pageloadTraceId = pageloadEventData.contexts?.trace?.trace_id; - expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/); + expect(pageloadTraceId).toMatch(/[a-f\d]{32}/); const spanEnvelope = (await spanEnvelopePromise)[0]; const spanEnvelopeItem = spanEnvelope[1][0][1]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 942230b4594e..a882c06c1e11 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -43,7 +43,7 @@ sentryTest('should capture an INP click event span after pageload', async ({ bro const spanEnvelopeItem = spanEnvelope[1][0][1]; const traceId = spanEnvelopeHeaders.trace!.trace_id; - expect(traceId).toMatch(/[a-f0-9]{32}/); + expect(traceId).toMatch(/[a-f\d]{32}/); expect(spanEnvelopeHeaders).toEqual({ sent_at: expect.any(String), @@ -81,7 +81,7 @@ sentryTest('should capture an INP click event span after pageload', async ({ bro origin: 'auto.http.browser.inp', is_segment: true, segment_id: spanEnvelopeItem.span_id, - span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: traceId, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js new file mode 100644 index 000000000000..1044a4b68bda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-url', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js new file mode 100644 index 000000000000..730caa3b381e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js @@ -0,0 +1,44 @@ +const simulateNavigationKeepDOM = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 100) { + // Block UI for 100ms to simulate some processing work during navigation + } + + const contentDiv = document.getElementById('content'); + contentDiv.innerHTML = '

Page 1

Successfully navigated!

'; + + contentDiv.classList.add('navigated'); +}; + +const simulateNavigationChangeDOM = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 100) { + // Block UI for 100ms to simulate some processing work during navigation + } + + const navigationHTML = + ' '; + + const body = document.querySelector('body'); + body.innerHTML = `${navigationHTML}

Page 2

Successfully navigated!

`; + + body.classList.add('navigated'); +}; + +document.querySelector('[data-test-id=nav-link-keepDOM]').addEventListener('click', simulateNavigationKeepDOM); +document.querySelector('[data-test-id=nav-link-changeDOM]').addEventListener('click', simulateNavigationChangeDOM); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html new file mode 100644 index 000000000000..de677aa9a838 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html @@ -0,0 +1,16 @@ + + + + + + + +
+

Home Page

+

Click the navigation link to simulate a route change

+
+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts new file mode 100644 index 000000000000..d1cc7cce020d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -0,0 +1,174 @@ +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, SpanEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + hidePage, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +const supportedBrowsers = ['chromium']; + +sentryTest( + 'should capture INP with correct target name when navigation keeps DOM element', + async ({ browserName, getLocalTestUrl, page }) => { + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); // wait for page load + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + // Simulating route change (keeping