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..cfaf6db8abef 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} @@ -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 4066a18eefe2..881b5f4b6580 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -142,7 +142,7 @@ jobs: 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 }} @@ -242,7 +242,7 @@ jobs: 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 @@ -271,7 +271,7 @@ jobs: 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 @@ -300,7 +300,7 @@ jobs: 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 @@ -330,7 +330,7 @@ jobs: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' @@ -352,7 +352,7 @@ jobs: 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 @@ -374,7 +374,7 @@ jobs: 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 @@ -415,7 +415,7 @@ jobs: 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 @@ -456,7 +456,7 @@ jobs: 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 @@ -481,7 +481,7 @@ jobs: 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 @@ -518,7 +518,7 @@ jobs: 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 @@ -607,7 +607,7 @@ jobs: 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: @@ -671,7 +671,7 @@ jobs: 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}} @@ -719,7 +719,7 @@ jobs: 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 @@ -757,7 +757,7 @@ jobs: 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 @@ -793,7 +793,7 @@ jobs: 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 @@ -821,7 +821,7 @@ jobs: 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 @@ -873,7 +873,7 @@ jobs: 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 @@ -941,7 +941,7 @@ jobs: 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 @@ -1005,7 +1005,7 @@ jobs: run: ${{ matrix.assert-command || 'pnpm test:assert' }} - 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 +1019,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 }}) @@ -1071,7 +1071,7 @@ jobs: 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 +1131,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 }}) @@ -1168,6 +1168,7 @@ jobs: job_lint, job_check_format, job_circular_dep_check, + job_size_check, ] # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index fbf476c369a4..57290080c8de 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -35,7 +35,7 @@ jobs: 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 @@ -76,11 +76,8 @@ jobs: build-command: 'test:build-canary' label: 'create-react-app (canary)' - test-application: 'nextjs-app-dir' - build-command: 'test:build-canary' - label: 'nextjs-app-dir (canary)' - - test-application: 'nextjs-app-dir' - build-command: 'test:build-latest' - label: 'nextjs-app-dir (latest)' + build-command: 'test:build-15' + label: 'nextjs-app-dir (next@15)' - test-application: 'nextjs-13' build-command: 'test:build-latest' label: 'nextjs-13 (latest)' @@ -90,12 +87,15 @@ jobs: - test-application: 'nextjs-14' build-command: 'test:build-latest' label: 'nextjs-14 (latest)' - - test-application: 'nextjs-15' - build-command: 'test:build-canary' - label: 'nextjs-15 (canary)' - test-application: 'nextjs-15' build-command: 'test:build-latest' label: 'nextjs-15 (latest)' + - test-application: 'nextjs-16' + build-command: 'test:build-canary' + label: 'nextjs-16 (canary)' + - test-application: 'nextjs-16' + build-command: 'test:build-canary-webpack' + label: 'nextjs-16 (canary-webpack)' - test-application: 'nextjs-turbo' build-command: 'test:build-canary' label: 'nextjs-turbo (canary)' @@ -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/clear-cache.yml b/.github/workflows/clear-cache.yml index 97aeb53365e7..0f5f2241b34a 100644 --- a/.github/workflows/clear-cache.yml +++ b/.github/workflows/clear-cache.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v5 - 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..6d6b67201d5e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: # 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/external-contributors.yml b/.github/workflows/external-contributors.yml index 1735a89a5446..c085f9958452 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v5 - 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/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 5103f1f43a2d..a6ed22e04f6a 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -32,7 +32,7 @@ jobs: - name: Check out current branch uses: actions/checkout@v5 - 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/release.yml b/.github/workflows/release.yml index 05c465036ce4..a2cb3fcd9600 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} @@ -28,7 +28,7 @@ jobs: 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/.gitignore b/.gitignore index f381e7e6e24d..36f8a3f6b9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ packages/gatsby/gatsby-node.d.ts # intellij *.iml /**/.wrangler/* + +#junit reports +packages/**/*.junit.xml diff --git a/.size-limit.js b/.size-limit.js index 59ad29c3ccf8..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,21 +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: '40.7 KB', + limit: '42 KB', + }, + { + name: '@sentry/browser (incl. Tracing, Profiling)', + 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', @@ -72,38 +79,38 @@ 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: '84 KB', + 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: '96 KB', + 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: '34 KB', + limit: '35 KB', }, // React SDK (ESM) { @@ -120,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) { @@ -128,14 +135,14 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '29 KB', + limit: '30 KB', }, { name: '@sentry/vue (incl. Tracing)', path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43 KB', + limit: '44 KB', }, // Svelte SDK (ESM) { @@ -150,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)', @@ -176,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: '123 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', @@ -206,7 +213,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '45 KB', + limit: '46 KB', }, // SvelteKit SDK (ESM) { @@ -215,7 +222,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '41 KB', + limit: '42 KB', }, // Node-Core SDK (ESM) { @@ -224,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '51 KB', + limit: '52 KB', }, // Node SDK (ESM) { @@ -233,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '156 KB', + limit: '160 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d065c6c800..58e2cf7bd830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,395 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 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 + +- **feat(node): Instrument cloud functions for firebase v2 ([#17952](https://github.com/getsentry/sentry-javascript/pull/17952))** + + We added instrumentation for Cloud Functions for Firebase v2, enabling automatic performance tracking and error monitoring. This will be added automatically if you have enabled tracing. + +- **feat(core): Instrument LangChain AI ([#17955](https://github.com/getsentry/sentry-javascript/pull/17955))** + + Instrumentation was added for LangChain AI operations. You can configure what is recorded like this: + + ```ts + Sentry.init({ + integrations: [ + Sentry.langChainIntegration({ + recordInputs: true, // Record prompts/messages + recordOutputs: true, // Record responses + }), + ], + }); + ``` + +### Other Changes + +- feat(cloudflare,vercel-edge): Add support for LangChain instrumentation ([#17986](https://github.com/getsentry/sentry-javascript/pull/17986)) +- feat: Align sentry origin with documentation ([#17998](https://github.com/getsentry/sentry-javascript/pull/17998)) +- feat(core): Truncate request messages in AI integrations ([#17921](https://github.com/getsentry/sentry-javascript/pull/17921)) +- feat(nextjs): Support node runtime on proxy files ([#17995](https://github.com/getsentry/sentry-javascript/pull/17995)) +- feat(node): Pass requestHook and responseHook option to OTel ([#17996](https://github.com/getsentry/sentry-javascript/pull/17996)) +- fix(core): Fix wrong async types when instrumenting anthropic's stream api ([#18007](https://github.com/getsentry/sentry-javascript/pull/18007)) +- fix(nextjs): Remove usage of chalk to avoid runtime errors ([#18010](https://github.com/getsentry/sentry-javascript/pull/18010)) +- fix(node): Pino capture serialized `err` ([#17999](https://github.com/getsentry/sentry-javascript/pull/17999)) +- fix(node): Pino child loggers ([#17934](https://github.com/getsentry/sentry-javascript/pull/17934)) +- fix(react): Don't trim index route `/` when getting pathname ([#17985](https://github.com/getsentry/sentry-javascript/pull/17985)) +- fix(react): Patch `spanEnd` for potentially cancelled lazy-route transactions ([#17962](https://github.com/getsentry/sentry-javascript/pull/17962)) + +
+ Internal Changes + +- chore: Add required size_check for GH Actions ([#18009](https://github.com/getsentry/sentry-javascript/pull/18009)) +- chore: Upgrade madge to v8 ([#17957](https://github.com/getsentry/sentry-javascript/pull/17957)) +- test(hono): Fix hono e2e tests ([#18000](https://github.com/getsentry/sentry-javascript/pull/18000)) +- test(react-router): Fix `getMetaTagTransformer` tests for Vitest compatibility ([#18013](https://github.com/getsentry/sentry-javascript/pull/18013)) +- test(react): Add parameterized route tests for `createHashRouter` ([#17789](https://github.com/getsentry/sentry-javascript/pull/17789)) + +
+ +## 10.21.0 + +### Important Changes + +- **feat(browserProfiling): Add `trace` lifecycle mode for UI profiling ([#17619](https://github.com/getsentry/sentry-javascript/pull/17619))** + + Adds a new `trace` lifecycle mode for UI profiling, allowing profiles to be captured for the duration of a trace. A `manual` mode will be added in a future release. + +- **feat(nuxt): Instrument Database ([#17899](https://github.com/getsentry/sentry-javascript/pull/17899))** + + Adds instrumentation for Nuxt database operations, enabling better performance tracking of database queries. + +- **feat(nuxt): Instrument server cache API ([#17886](https://github.com/getsentry/sentry-javascript/pull/17886))** + + Adds instrumentation for Nuxt's server cache API, providing visibility into cache operations. + +- **feat(nuxt): Instrument storage API ([#17858](https://github.com/getsentry/sentry-javascript/pull/17858))** + + Adds instrumentation for Nuxt's storage API, enabling tracking of storage operations. + +### Other Changes + +- feat(browser): Add `onRequestSpanEnd` hook to browser tracing integration ([#17884](https://github.com/getsentry/sentry-javascript/pull/17884)) +- feat(nextjs): Support Next.js proxy files ([#17926](https://github.com/getsentry/sentry-javascript/pull/17926)) +- feat(replay): Record outcome when event buffer size exceeded ([#17946](https://github.com/getsentry/sentry-javascript/pull/17946)) +- fix(cloudflare): copy execution context in durable objects and handlers ([#17786](https://github.com/getsentry/sentry-javascript/pull/17786)) +- fix(core): Fix and add missing cache attributes in Vercel AI ([#17982](https://github.com/getsentry/sentry-javascript/pull/17982)) +- fix(core): Improve uuid performance ([#17938](https://github.com/getsentry/sentry-javascript/pull/17938)) +- fix(ember): Use updated version for `clean-css` ([#17979](https://github.com/getsentry/sentry-javascript/pull/17979)) +- fix(nextjs): Don't set experimental instrumentation hook flag for next 16 ([#17978](https://github.com/getsentry/sentry-javascript/pull/17978)) +- fix(nextjs): Inconsistent transaction naming for i18n routing ([#17927](https://github.com/getsentry/sentry-javascript/pull/17927)) +- fix(nextjs): Update bundler detection ([#17976](https://github.com/getsentry/sentry-javascript/pull/17976)) + +
+ Internal Changes + +- build: Update to typescript 5.8.0 ([#17710](https://github.com/getsentry/sentry-javascript/pull/17710)) +- chore: Add external contributor to CHANGELOG.md ([#17949](https://github.com/getsentry/sentry-javascript/pull/17949)) +- chore(build): Upgrade nodemon to 3.1.10 ([#17956](https://github.com/getsentry/sentry-javascript/pull/17956)) +- chore(ci): Fix external contributor action when multiple contributions existed ([#17950](https://github.com/getsentry/sentry-javascript/pull/17950)) +- chore(solid): Remove unnecessary import from README ([#17947](https://github.com/getsentry/sentry-javascript/pull/17947)) +- test(nextjs): Fix proxy/middleware test ([#17970](https://github.com/getsentry/sentry-javascript/pull/17970)) + +
+ +Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! + +## 10.20.0 + +### Important Changes + +- **feat(flags): Add Growthbook integration ([#17440](https://github.com/getsentry/sentry-javascript/pull/17440))** + + Adds a new Growthbook integration for feature flag support. + +- **feat(solid): Add support for TanStack Router Solid ([#17735](https://github.com/getsentry/sentry-javascript/pull/17735))** + + Adds support for TanStack Router in the Solid SDK, enabling better routing instrumentation for Solid applications. + +- **feat(nextjs): Support native debugIds in turbopack ([#17853](https://github.com/getsentry/sentry-javascript/pull/17853))** + + Adds support for native Debug IDs in Turbopack, improving source map resolution and error tracking for Next.js applications using Turbopack. Native Debug ID generation will be enabled automatically for compatible versions. + +### Other Changes + +- feat(nextjs): Prepare for next 16 bundler default ([#17868](https://github.com/getsentry/sentry-javascript/pull/17868)) +- feat(node): Capture `pino` logger name ([#17930](https://github.com/getsentry/sentry-javascript/pull/17930)) +- fix(browser): Ignore React 19.2+ component render measure entries ([#17905](https://github.com/getsentry/sentry-javascript/pull/17905)) +- fix(nextjs): Fix createRouteManifest with basePath ([#17838](https://github.com/getsentry/sentry-javascript/pull/17838)) +- fix(react): Add `POP` guard for long-running `pageload` spans ([#17867](https://github.com/getsentry/sentry-javascript/pull/17867)) +- fix(tracemetrics): Send boolean for internal replay attribute ([#17908](https://github.com/getsentry/sentry-javascript/pull/17908)) +- ref(core): Add weight tracking logic to browser logs/metrics ([#17901](https://github.com/getsentry/sentry-javascript/pull/17901)) + +
+ Internal Changes +- chore(nextjs): Add Next.js 16 peer dependency ([#17925](https://github.com/getsentry/sentry-javascript/pull/17925)) +- chore(ci): Update Next.js canary testing ([#17939](https://github.com/getsentry/sentry-javascript/pull/17939)) +- chore: Bump size limit ([#17941](https://github.com/getsentry/sentry-javascript/pull/17941)) +- test(nextjs): Add next@16 e2e test ([#17922](https://github.com/getsentry/sentry-javascript/pull/17922)) +- test(nextjs): Update next 15 tests ([#17919](https://github.com/getsentry/sentry-javascript/pull/17919)) +- chore: Add external contributor to CHANGELOG.md ([#17915](https://github.com/getsentry/sentry-javascript/pull/17915)) +- chore: Add external contributor to CHANGELOG.md ([#17928](https://github.com/getsentry/sentry-javascript/pull/17928)) +- chore: Add external contributor to CHANGELOG.md ([#17940](https://github.com/getsentry/sentry-javascript/pull/17940)) +
+ +Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez. Thank you for your contributions! + ## 10.19.0 - feat(tracemetrics): Add trace metrics behind an experiments flag ([#17883](https://github.com/getsentry/sentry-javascript/pull/17883)) 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 329c061c49d5..e5ff62a58084 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.19.0", + "version": "10.27.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.19.0", + "@sentry/browser": "10.27.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/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts new file mode 100644 index 000000000000..fc23f80927ff --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,68 @@ +import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; + +sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + await page.evaluate(bufferSize => { + const gb = new (window as any).GrowthBook(); + + for (let i = 1; i <= bufferSize; i++) { + gb.isOn(`feat${i}`); + } + + gb.__setOn(`feat${bufferSize + 1}`, true); + gb.isOn(`feat${bufferSize + 1}`); // eviction + + gb.__setOn('feat3', true); + gb.isOn('feat3'); // update + + // Test getFeatureValue with boolean values (should be captured) + gb.__setFeatureValue('bool-feat', true); + gb.getFeatureValue('bool-feat', false); + + // Test getFeatureValue with non-boolean values (should be ignored) + gb.__setFeatureValue('string-feat', 'hello'); + gb.getFeatureValue('string-feat', 'default'); + gb.__setFeatureValue('number-feat', 42); + gb.getFeatureValue('number-feat', 0); + }, FLAG_BUFFER_SIZE); + + const reqPromise = waitForErrorRequest(page); + await page.locator('#error').click(); + const req = await reqPromise; + const event = envelopeRequestParser(req); + + const values = event.contexts?.flags?.values || []; + + // After the sequence of operations: + // 1. feat1-feat100 are added (100 items) + // 2. feat101 is added, evicts feat1 (100 items: feat2-feat100, feat101) + // 3. feat3 is updated to true, moves to end (100 items: feat2, feat4-feat100, feat101, feat3) + // 4. bool-feat is added, evicts feat2 (100 items: feat4-feat100, feat101, feat3, bool-feat) + + const expectedFlags = []; + for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { + expectedFlags.push({ flag: `feat${i}`, result: false }); + } + expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true }); + expectedFlags.push({ flag: 'feat3', result: true }); + expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured + + expect(values).toEqual(expectedFlags); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js new file mode 100644 index 000000000000..e7831a1c2c0b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/browser'; + +// Minimal mock GrowthBook class for tests +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + // Helpers for tests + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryGrowthBookIntegration], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js @@ -0,0 +1,3 @@ +document.getElementById('error').addEventListener('click', () => { + throw new Error('Button triggered error'); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html new file mode 100644 index 000000000000..da7d69a24c97 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts new file mode 100644 index 000000000000..48fa4718b856 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import type { Scope } from '@sentry/browser'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; + +sentryTest('GrowthBook onError: forked scopes are isolated', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === true); + const mainReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === false); + + await page.evaluate(() => { + const Sentry = (window as any).Sentry; + const errorButton = document.querySelector('#error') as HTMLButtonElement; + const gb = new (window as any).GrowthBook(); + + gb.__setOn('shared', true); + gb.__setOn('main', true); + + gb.isOn('shared'); + + Sentry.withScope((scope: Scope) => { + gb.__setOn('forked', true); + gb.__setOn('shared', false); + gb.isOn('forked'); + gb.isOn('shared'); + scope.setTag('isForked', true); + errorButton.click(); + }); + + gb.isOn('main'); + Sentry.getCurrentScope().setTag('isForked', false); + errorButton.click(); + return true; + }); + + const forkedReq = await forkedReqPromise; + const forkedEvent = envelopeRequestParser(forkedReq); + + const mainReq = await mainReqPromise; + const mainEvent = envelopeRequestParser(mainReq); + + expect(forkedEvent.contexts?.flags?.values).toEqual([ + { flag: 'forked', result: true }, + { flag: 'shared', result: false }, + ]); + + expect(mainEvent.contexts?.flags?.values).toEqual([ + { flag: 'shared', result: true }, + { flag: 'main', result: true }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js new file mode 100644 index 000000000000..d755d7a1d972 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; + +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + integrations: [ + window.sentryGrowthBookIntegration, + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js new file mode 100644 index 000000000000..ad874b2bd697 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js @@ -0,0 +1,16 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html new file mode 100644 index 000000000000..4efb91e75451 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts new file mode 100644 index 000000000000..6661edc9723d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { + type EventAndTraceHeader, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; + +sentryTest( + "GrowthBook onSpan: flags are added to active span's attributes on span end", + async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + eventAndTraceHeaderRequestParser, + ); + + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const gb = new (window as any).GrowthBook(); + for (let i = 1; i <= maxFlags; i++) { + gb.isOn(`feat${i}`); + } + gb.__setOn(`feat${maxFlags + 1}`, true); + gb.isOn(`feat${maxFlags + 1}`); // dropped + gb.__setOn('feat3', true); + gb.isOn('feat3'); // update + }); + return true; + }, MAX_FLAGS_PER_SPAN); + + const event = (await envelopeRequestPromise)[0][0]; + const innerSpan = event.spans?.[0]; + const outerSpan = event.spans?.[1]; + const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + + expect(innerSpanFlags).toEqual([]); + + const expectedOuterSpanFlags = [] as Array<[string, unknown]>; + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); + } + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); + }, +); 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/subject.js b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js index 230e9ee1fb9e..aad9fd2a764c 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js @@ -17,7 +17,7 @@ function fibonacci(n) { return fibonacci(n - 1) + fibonacci(n - 2); } -await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { +await Sentry.startSpanManual({ name: 'root-fibonacci', parentSpan: null, forceTransaction: true }, async span => { fibonacci(30); // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled 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 35f4e17bec0a..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,77 +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(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - - expect(typeof frame.function).toBe('string'); - 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/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js new file mode 100644 index 000000000000..0095eb5743d9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js @@ -0,0 +1,48 @@ +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: 'trace', +}); + +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); +} + +// Create two NON-overlapping root spans so that the profiler stops and emits a chunk +// after each span (since active root span count returns to 0 between them). +await Sentry.startSpanManual({ name: 'root-fibonacci-1', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +// Small delay to ensure the first chunk is collected and sent +await new Promise(r => setTimeout(r, 25)); + +await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceTransaction: true }, async span => { + largeSum(); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +const client = Sentry.getClient(); +await client?.flush(5000); 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 new file mode 100644 index 000000000000..5afc23a3a75f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -0,0 +1,98 @@ +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 trace mode (multiple chunks)', + 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' } }); + + // Expect at least 2 chunks because subject creates two separate root spans, + // causing the profiler to stop and emit a chunk after each root span ends. + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(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(); + + 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 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + + 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/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js new file mode 100644 index 000000000000..071afe1ed059 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -0,0 +1,52 @@ +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: 'trace', +}); + +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); +} + +let firstSpan; + +Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => { + largeSum(); + firstSpan = span; +}); + +await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + + Sentry.startSpan({ name: 'child-fibonacci', parentSpan: span }, childSpan => { + console.log('child span'); + }); + + // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled + await new Promise(resolve => setTimeout(resolve, 21)); + span.end(); +}); + +await new Promise(r => setTimeout(r, 21)); + +firstSpan.end(); + +const client = Sentry.getClient(); +await client?.flush(5000); 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 new file mode 100644 index 000000000000..fa66a225b49b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -0,0 +1,102 @@ +import { expect } from '@playwright/test'; +import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properEnvelopeRequestParser, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} 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 }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); + + expect(transactionEvent).toBeDefined(); + + expect(profileEvent).toBeUndefined(); + }, +); + +sentryTest( + 'sends profile envelope in trace mode (single chunk for overlapping spans)', + 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' } }); + await page.goto(url); + + const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'profile_chunk' }, + properFullEnvelopeRequestParser, + ); + + const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload.profile).toBeDefined(); + + 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, + }); + }, +); + +sentryTest('attaches thread data to child spans (trace mode)', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + const req = await waitForTransactionRequestOnUrl(page, url); + const rootSpan = properEnvelopeRequestParser(req, 0) as any; + + expect(rootSpan?.type).toBe('transaction'); + expect(rootSpan.transaction).toBe('root-fibonacci-2'); + + const profilerId = rootSpan?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId).toBe('string'); + + expect(profilerId).toMatch(/^[a-f\d]{32}$/); + + const spans = (rootSpan?.spans ?? []) as Array<{ data?: Record }>; + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.data).toBeDefined(); + expect(span.data?.['thread.id']).toBe('0'); + expect(span.data?.['thread.name']).toBe('main'); + } +}); 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/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 442800456f9b..dd4bd7e8ebc3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -30,7 +30,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.trace 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.trace {} {}', type: 'string' }, @@ -45,7 +45,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.debug 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.debug {} {}', type: 'string' }, @@ -60,7 +60,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.log 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.log {} {}', type: 'string' }, @@ -75,7 +75,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.info 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.info {} {}', type: 'string' }, @@ -90,7 +90,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.warn 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.warn {} {}', type: 'string' }, @@ -105,7 +105,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.error 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.error {} {}', type: 'string' }, @@ -120,7 +120,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Assertion failed: console.assert 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -132,7 +132,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object: {"key":"value","nested":{"prop":123}}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Object: {}', type: 'string' }, @@ -146,7 +146,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Array: [1,2,3,"string"]', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Array: {}', type: 'string' }, @@ -160,7 +160,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Mixed: prefix {"obj":true} [4,5,6] suffix', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' }, @@ -177,7 +177,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: '', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -189,7 +189,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'String substitution %s %d test 42', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -201,7 +201,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object substitution %o {"key":"value"}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -213,7 +213,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'first 0 1 2', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'first {} {} {}', type: 'string' }, @@ -229,7 +229,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'hello true null undefined', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'hello {} {} {}', type: 'string' }, 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/anthropic/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/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/anthropic/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/mocks.js new file mode 100644 index 000000000000..01c6c31ce596 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/mocks.js @@ -0,0 +1,55 @@ +// Mock Anthropic client for browser testing +export class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + // Main focus: messages.create functionality + this.messages = { + create: async (...args) => { + const params = args[0]; + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + const response = { + id: 'msg_mock123', + type: 'message', + role: 'assistant', + model: params.model, + content: [ + { + type: 'text', + text: 'Hello from Anthropic mock!', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }; + return response; + }, + countTokens: async (..._args) => ({ id: 'mock', type: 'model', model: 'mock', input_tokens: 0 }), + }; + + // Minimal implementations for required interface compliance + this.models = { + list: async (..._args) => ({ id: 'mock', type: 'model', model: 'mock' }), + get: async (..._args) => ({ id: 'mock', type: 'model', model: 'mock' }), + }; + + this.completions = { + create: async (..._args) => ({ id: 'mock', type: 'completion', model: 'mock' }), + }; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/subject.js new file mode 100644 index 000000000000..febfe938139e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/subject.js @@ -0,0 +1,19 @@ +import { instrumentAnthropicAiClient } from '@sentry/browser'; +import { MockAnthropic } from './mocks.js'; + +const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', +}); + +const client = instrumentAnthropicAiClient(mockClient); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +const response = await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + temperature: 0.7, + max_tokens: 100, +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/test.ts new file mode 100644 index 000000000000..206e29be16e5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/test.ts @@ -0,0 +1,36 @@ +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 Anthropic 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('messages claude-3-haiku-20240307'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.messages'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.anthropic'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'messages', + '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, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/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/google-genai/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js new file mode 100644 index 000000000000..8aab37fb3a1e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js @@ -0,0 +1,118 @@ +// Mock Google GenAI client for browser testing +export class MockGoogleGenAI { + constructor(config) { + this.apiKey = config.apiKey; + + // models.generateContent functionality + this.models = { + generateContent: async (...args) => { + const params = args[0]; + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + candidates: [ + { + content: { + parts: [ + { + text: 'Hello from Google GenAI mock!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + }; + }, + generateContentStream: async () => { + // Return a promise that resolves to an async generator + return (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Streaming response' }], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + }; + })(); + }, + }; + + // chats.create implementation + this.chats = { + create: (...args) => { + const params = args[0]; + const model = params.model; + + return { + modelVersion: model, + sendMessage: async (..._messageArgs) => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + const response = { + candidates: [ + { + content: { + parts: [ + { + text: 'This is a joke from the chat!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + modelVersion: model, // Include model version in response + }; + return response; + }, + sendMessageStream: async () => { + // Return a promise that resolves to an async generator + return (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Streaming chat response' }], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + }; + })(); + }, + }; + }, + }; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js new file mode 100644 index 000000000000..14b95f2b6942 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js @@ -0,0 +1,32 @@ +import { instrumentGoogleGenAIClient } from '@sentry/browser'; +import { MockGoogleGenAI } from './mocks.js'; + +const mockClient = new MockGoogleGenAI({ + apiKey: 'mock-api-key', +}); + +const client = instrumentGoogleGenAIClient(mockClient); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +// Test both chats and models APIs +const chat = client.chats.create({ + model: 'gemini-1.5-pro', + config: { + temperature: 0.8, + topP: 0.9, + maxOutputTokens: 150, + }, + history: [ + { + role: 'user', + parts: [{ text: 'Hello, how are you?' }], + }, + ], +}); + +const response = await chat.sendMessage({ + message: 'Tell me a joke', +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts new file mode 100644 index 000000000000..6774129f183e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts @@ -0,0 +1,31 @@ +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 Google GenAI instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('gemini-1.5-pro'); + }); + + 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 gemini-1.5-pro create'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.chat'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.google_genai'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/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/openai/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/mocks.js new file mode 100644 index 000000000000..a1fe56dd30c2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/mocks.js @@ -0,0 +1,47 @@ +// Mock OpenAI client for browser testing +export class MockOpenAi { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async (...args) => { + const params = args[0]; + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + const response = { + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: params.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + return response; + }, + }, + }; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/subject.js new file mode 100644 index 000000000000..aadc2864ceee --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/subject.js @@ -0,0 +1,22 @@ +import { instrumentOpenAiClient } from '@sentry/browser'; +import { MockOpenAi } from './mocks.js'; + +const mockClient = new MockOpenAi({ + apiKey: 'mock-api-key', +}); + +const client = instrumentOpenAiClient(mockClient); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +const response = await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/test.ts new file mode 100644 index 000000000000..c71c0786ff96 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/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 OpenAI instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('gpt-3.5-turbo'); + }); + + 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 gpt-3.5-turbo'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.chat'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.openai'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-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/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/on-request-span-end/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js new file mode 100644 index 000000000000..9627bfc003e7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + onRequestSpanEnd(span, { headers }) { + if (headers) { + span.setAttribute('hook.called.response-type', headers.get('x-response-type')); + } + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js new file mode 100644 index 000000000000..8a1ec65972f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js @@ -0,0 +1,11 @@ +fetch('http://sentry-test.io/fetch', { + headers: { + foo: 'fetch', + }, +}); + +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test.io/xhr'); +xhr.setRequestHeader('foo', 'xhr'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts new file mode 100644 index 000000000000..03bfc13814af --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should call onRequestSpanEnd hook', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('http://sentry-test.io/fetch', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'fetch', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + await page.route('http://sentry-test.io/xhr', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'xhr', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'xhr', + 'hook.called.response-type': 'xhr', + }), + }), + ); + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'fetch', + 'hook.called.response-type': 'fetch', + }), + }), + ); +}); 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